diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7942d44541b..6eb60e045a5 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -23,8 +23,8 @@ /docs/ @crazywoola # CLI -/cli/ @langgenius/maintainers -/.github/workflows/cli-tests.yml @langgenius/maintainers +/cli/ @GareArc +/.github/workflows/cli-tests.yml @GareArc # Backend (default owner, more specific rules below will override) /api/ @QuantumGhost diff --git a/.github/workflows/cli-edge.yml b/.github/workflows/cli-edge.yml new file mode 100644 index 00000000000..159f3545e13 --- /dev/null +++ b/.github/workflows/cli-edge.yml @@ -0,0 +1,74 @@ +name: CLI Edge Publish + +on: + push: + branches: [main] + paths: + - 'cli/**' + - 'packages/contracts/generated/api/openapi/**' + workflow_dispatch: + +concurrency: + group: difyctl-edge-publish + cancel-in-progress: false + +jobs: + publish: + name: build + publish edge to R2 + runs-on: ${{ github.repository == 'langgenius/dify' && 'depot-ubuntu-24.04' || 'ubuntu-24.04' }} + if: vars.DIFYCTL_R2_BUCKET != '' + defaults: + run: + shell: bash + working-directory: ./cli + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Enable cross-arch native prebuilds + working-directory: ./ + run: cat cli/scripts/cross-arch.pnpm.yaml >> pnpm-workspace.yaml + + - name: Setup web environment + uses: ./.github/actions/setup-web + + - name: Setup Bun + uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.2 + with: + bun-version-file: cli/.bun-version + + - name: Compute edge version + id: ver + run: echo "version=$(node scripts/release-naming.mjs edge-version "$(git rev-parse --short HEAD)")" >> "$GITHUB_OUTPUT" + + - name: Compile standalone binaries (all targets, all-or-nothing) + run: | + CLI_VERSION="${{ steps.ver.outputs.version }}" \ + DIFYCTL_CHANNEL=edge \ + DIFYCTL_COMMIT="$(git rev-parse HEAD)" \ + DIFYCTL_BUILD_DATE="$(git log -1 --format=%cI HEAD)" \ + pnpm build:bin + + - name: Generate sha256 checksums + run: CLI_VERSION="${{ steps.ver.outputs.version }}" scripts/release-write-checksums.sh + + - name: Smoke the runner-arch binary + run: ./dist/bin/difyctl-v${{ steps.ver.outputs.version }}-linux-x64 version + + - name: Publish to R2 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.DIFYCTL_R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.DIFYCTL_R2_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + AWS_REQUEST_CHECKSUM_CALCULATION: WHEN_REQUIRED + AWS_RESPONSE_CHECKSUM_VALIDATION: WHEN_REQUIRED + DIFYCTL_R2_S3_ENDPOINT: ${{ vars.DIFYCTL_R2_S3_ENDPOINT }} + DIFYCTL_R2_BUCKET: ${{ vars.DIFYCTL_R2_BUCKET }} + DIFYCTL_R2_PUBLIC_BASE: ${{ vars.DIFYCTL_R2_PUBLIC_BASE }} + DIFYCTL_COMMIT: ${{ github.sha }} + run: | + DIFYCTL_BUILD_DATE="$(git log -1 --format=%cI HEAD)" \ + scripts/release-r2-publish.sh edge "${{ steps.ver.outputs.version }}" diff --git a/.github/workflows/deploy-agent.yml b/.github/workflows/deploy-agent.yml new file mode 100644 index 00000000000..e244bb3f949 --- /dev/null +++ b/.github/workflows/deploy-agent.yml @@ -0,0 +1,28 @@ +name: Deploy Agent + +permissions: + contents: read + +on: + workflow_run: + workflows: ["Build and Push API & Web"] + branches: + - "deploy/agent" + types: + - completed + +jobs: + deploy: + runs-on: depot-ubuntu-24.04 + if: | + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_branch == 'deploy/agent' + steps: + - name: Deploy to server + uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5 + with: + host: ${{ secrets.AGENT_SSH_HOST }} + username: ${{ secrets.SSH_USER }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + script: | + ${{ vars.SSH_SCRIPT_AGENT || secrets.SSH_SCRIPT_AGENT }} diff --git a/.github/workflows/web-tests.yml b/.github/workflows/web-tests.yml index 29503d7b6b0..56cf8d5fca4 100644 --- a/.github/workflows/web-tests.yml +++ b/.github/workflows/web-tests.yml @@ -113,7 +113,7 @@ jobs: run: vp exec playwright install --with-deps chromium - name: Run dify-ui tests - run: vp test run --coverage --silent=passed-only + run: vp test run --project unit --coverage --silent=passed-only - name: Report coverage if: ${{ env.CODECOV_TOKEN != '' }} @@ -123,3 +123,26 @@ jobs: flags: dify-ui env: CODECOV_TOKEN: ${{ env.CODECOV_TOKEN }} + + dify-ui-storybook-test: + name: dify-ui Storybook Tests + runs-on: depot-ubuntu-24.04-4 + defaults: + run: + shell: bash + working-directory: ./packages/dify-ui + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup web environment + uses: ./.github/actions/setup-web + + - name: Install Chromium for Browser Mode + run: vp exec playwright install --with-deps chromium + + - name: Run dify-ui Storybook tests + run: vp run test:storybook diff --git a/Makefile b/Makefile index be665e71231..801c59cc281 100644 --- a/Makefile +++ b/Makefile @@ -157,7 +157,7 @@ build-web: build-api: @echo "Building API Docker image: $(API_IMAGE):$(VERSION)..." - docker build -t $(API_IMAGE):$(VERSION) ./api + docker build -t $(API_IMAGE):$(VERSION) -f api/Dockerfile . @echo "API Docker image built successfully: $(API_IMAGE):$(VERSION)" # Push Docker images diff --git a/api/.env.example b/api/.env.example index f645ba7bf02..8a2af53c6e7 100644 --- a/api/.env.example +++ b/api/.env.example @@ -774,3 +774,11 @@ EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS=2000 ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true # Human input timeout check interval in minutes HUMAN_INPUT_TIMEOUT_TASK_INTERVAL=1 + +# Nacos remote settings source HTTP timeouts (seconds). +# Bound how long requests to the Nacos endpoint wait before failing, so a slow or +# unresponsive Nacos server cannot stall API startup or token refresh. +# Read timeout for Nacos requests (default: 10.0) +DIFY_ENV_NACOS_REQUEST_TIMEOUT=10.0 +# Connect timeout for Nacos requests (default: 3.0) +DIFY_ENV_NACOS_CONNECT_TIMEOUT=3.0 diff --git a/api/Dockerfile.dockerignore b/api/Dockerfile.dockerignore index 8ec1323c3f6..1ce0ddb0aea 100644 --- a/api/Dockerfile.dockerignore +++ b/api/Dockerfile.dockerignore @@ -8,18 +8,30 @@ !dify-agent/src/ !dify-agent/src/** -api/.venv -api/.venv/** -api/.env -api/*.env.* -api/.idea -api/.mypy_cache -api/.ruff_cache -api/storage/generate_files/* -api/storage/privkeys/* -api/storage/tools/* -api/storage/upload_files/* -api/logs -api/*.log* +# Environment configuration and example +.env +*.env.* + +# Python related files **/__pycache__ **/*.pyc +**/.venv/ +**/.mypy_cache/ +**/.ruff_cache/ +**/.import_linter_cache/ +**/.pytest_cache/ +**/.hypothesis/ + + +# Upload files and logs +api/storage/** +api/logs/ +api/*.log* + +# Tests +api/tests + + +# Editor configuration +**/.vscode/ +**/.idea/ diff --git a/api/app.py b/api/app.py index e53b037be50..7b7fa58c22f 100644 --- a/api/app.py +++ b/api/app.py @@ -55,7 +55,7 @@ else: if __name__ == "__main__": from gevent import pywsgi - from geventwebsocket.handler import WebSocketHandler # type: ignore[reportMissingTypeStubs] + from geventwebsocket.handler import WebSocketHandler log_startup_banner(HOST, PORT) server = pywsgi.WSGIServer((HOST, PORT), socketio_app, handler_class=WebSocketHandler) diff --git a/api/app_factory.py b/api/app_factory.py index 49be0257311..2cea8cfb3f7 100644 --- a/api/app_factory.py +++ b/api/app_factory.py @@ -1,7 +1,7 @@ import logging import time -import socketio # type: ignore[reportMissingTypeStubs] +import socketio from flask import request from opentelemetry.trace import get_current_span from opentelemetry.trace.span import INVALID_SPAN_ID, INVALID_TRACE_ID diff --git a/api/celery_entrypoint.py b/api/celery_entrypoint.py index 28fa0972e8a..339f03c9916 100644 --- a/api/celery_entrypoint.py +++ b/api/celery_entrypoint.py @@ -1,5 +1,5 @@ -import psycogreen.gevent as pscycogreen_gevent # type: ignore -from grpc.experimental import gevent as grpc_gevent # type: ignore +import psycogreen.gevent as pscycogreen_gevent +from grpc.experimental import gevent as grpc_gevent # grpc gevent grpc_gevent.init_gevent() diff --git a/api/clients/agent_backend/__init__.py b/api/clients/agent_backend/__init__.py index bbee164d5cb..238c48a9de3 100644 --- a/api/clients/agent_backend/__init__.py +++ b/api/clients/agent_backend/__init__.py @@ -5,6 +5,8 @@ API adapters: request building from Dify product concepts, a thin client wrapper event adaptation for future workflow integration, and deterministic fakes. """ +from dify_agent.protocol import RuntimeLayerSpec, extract_runtime_layer_specs + from clients.agent_backend.client import AgentBackendRunClient, DifyAgentBackendRunClient from clients.agent_backend.errors import ( AgentBackendError, @@ -16,12 +18,12 @@ from clients.agent_backend.errors import ( AgentBackendValidationError, ) from clients.agent_backend.event_adapter import ( + AgentBackendDeferredToolCallInternalEvent, AgentBackendInternalEvent, AgentBackendInternalEventType, AgentBackendRunCancelledInternalEvent, AgentBackendRunEventAdapter, AgentBackendRunFailedInternalEvent, - AgentBackendRunPausedInternalEvent, AgentBackendRunStartedInternalEvent, AgentBackendRunSucceededInternalEvent, AgentBackendStreamInternalEvent, @@ -39,8 +41,6 @@ from clients.agent_backend.request_builder import ( AgentBackendOutputConfig, AgentBackendRunRequestBuilder, AgentBackendWorkflowNodeRunInput, - CleanupLayerSpec, - extract_cleanup_layer_specs, redact_for_agent_backend_log, ) @@ -51,6 +51,7 @@ __all__ = [ "WORKFLOW_NODE_JOB_PROMPT_LAYER_ID", "WORKFLOW_USER_PROMPT_LAYER_ID", "AgentBackendAgentAppRunInput", + "AgentBackendDeferredToolCallInternalEvent", "AgentBackendError", "AgentBackendHTTPError", "AgentBackendInternalEvent", @@ -63,7 +64,6 @@ __all__ = [ "AgentBackendRunEventAdapter", "AgentBackendRunFailedError", "AgentBackendRunFailedInternalEvent", - "AgentBackendRunPausedInternalEvent", "AgentBackendRunRequestBuilder", "AgentBackendRunStartedInternalEvent", "AgentBackendRunSucceededInternalEvent", @@ -72,11 +72,11 @@ __all__ = [ "AgentBackendTransportError", "AgentBackendValidationError", "AgentBackendWorkflowNodeRunInput", - "CleanupLayerSpec", "DifyAgentBackendRunClient", "FakeAgentBackendRunClient", "FakeAgentBackendScenario", + "RuntimeLayerSpec", "create_agent_backend_run_client", - "extract_cleanup_layer_specs", + "extract_runtime_layer_specs", "redact_for_agent_backend_log", ] diff --git a/api/clients/agent_backend/event_adapter.py b/api/clients/agent_backend/event_adapter.py index 02b30e6c6b3..e983aa336d7 100644 --- a/api/clients/agent_backend/event_adapter.py +++ b/api/clients/agent_backend/event_adapter.py @@ -2,7 +2,9 @@ The adapter does not define a new cross-service event contract. It consumes ``dify_agent.protocol.RunEvent`` and produces small API-internal models that the -future workflow Agent Node can map to Graphon/AppQueue events in phase 3. +workflow Agent Node maps to Graphon/AppQueue events. Deferred external tool calls +remain Dify Agent ``run_succeeded`` payloads on the wire; API code turns them +into an internal event so workflow pause/session handling stays local to API. """ from __future__ import annotations @@ -12,11 +14,11 @@ from typing import Annotated, Literal, cast from agenton.compositor import CompositorSessionSnapshot from dify_agent.protocol import ( + DeferredToolCallPayload, PydanticAIStreamRunEvent, RunCancelledEvent, RunEvent, RunFailedEvent, - RunPausedEvent, RunStartedEvent, RunSucceededEvent, ) @@ -30,7 +32,7 @@ class AgentBackendInternalEventType(StrEnum): RUN_STARTED = "run_started" STREAM_EVENT = "stream_event" - RUN_PAUSED = "run_paused" + DEFERRED_TOOL_CALL = "deferred_tool_call" RUN_SUCCEEDED = "run_succeeded" RUN_FAILED = "run_failed" RUN_CANCELLED = "run_cancelled" @@ -67,13 +69,13 @@ class AgentBackendRunSucceededInternalEvent(AgentBackendInternalEventBase): session_snapshot: CompositorSessionSnapshot -class AgentBackendRunPausedInternalEvent(AgentBackendInternalEventBase): - """API-internal resumable pause event for human handoff and Babysit flows.""" +class AgentBackendDeferredToolCallInternalEvent(AgentBackendInternalEventBase): + """API-internal representation of a Dify Agent deferred external tool call.""" - type: Literal[AgentBackendInternalEventType.RUN_PAUSED] = AgentBackendInternalEventType.RUN_PAUSED - reason: str + type: Literal[AgentBackendInternalEventType.DEFERRED_TOOL_CALL] = AgentBackendInternalEventType.DEFERRED_TOOL_CALL + deferred_tool_call: DeferredToolCallPayload message: str | None = None - session_snapshot: CompositorSessionSnapshot | None = None + session_snapshot: CompositorSessionSnapshot class AgentBackendRunFailedInternalEvent(AgentBackendInternalEventBase): @@ -95,7 +97,7 @@ class AgentBackendRunCancelledInternalEvent(AgentBackendInternalEventBase): type AgentBackendInternalEvent = Annotated[ AgentBackendRunStartedInternalEvent | AgentBackendStreamInternalEvent - | AgentBackendRunPausedInternalEvent + | AgentBackendDeferredToolCallInternalEvent | AgentBackendRunSucceededInternalEvent | AgentBackendRunFailedInternalEvent | AgentBackendRunCancelledInternalEvent, @@ -128,6 +130,18 @@ class AgentBackendRunEventAdapter: ) ] case RunSucceededEvent(): + if "deferred_tool_call" in event.data.model_fields_set: + if event.data.deferred_tool_call is None: + raise TypeError("run_succeeded deferred_tool_call branch is missing payload") + return [ + AgentBackendDeferredToolCallInternalEvent( + run_id=event.run_id, + source_event_id=event.id, + deferred_tool_call=event.data.deferred_tool_call, + message=_deferred_tool_call_message(event.data.deferred_tool_call), + session_snapshot=event.data.session_snapshot, + ) + ] return [ AgentBackendRunSucceededInternalEvent( run_id=event.run_id, @@ -136,16 +150,6 @@ class AgentBackendRunEventAdapter: session_snapshot=event.data.session_snapshot, ) ] - case RunPausedEvent(): - return [ - AgentBackendRunPausedInternalEvent( - run_id=event.run_id, - source_event_id=event.id, - reason=event.data.reason, - message=event.data.message, - session_snapshot=event.data.session_snapshot, - ) - ] case RunFailedEvent(): return [ AgentBackendRunFailedInternalEvent( @@ -165,3 +169,18 @@ class AgentBackendRunEventAdapter: ) ] raise TypeError(f"unsupported agent backend run event: {type(event).__name__}") + + +def _deferred_tool_call_message(payload: DeferredToolCallPayload) -> str: + """Return a concise workflow pause message from deferred-tool arguments.""" + args = payload.args + if isinstance(args, dict): + question = args.get("question") + if isinstance(question, str) and question.strip(): + return question + + title = args.get("title") + if isinstance(title, str) and title.strip(): + return title + + return f"Agent backend requested external input via deferred tool '{payload.tool_name}'." diff --git a/api/clients/agent_backend/fake_client.py b/api/clients/agent_backend/fake_client.py index a768777039d..11de90c94b7 100644 --- a/api/clients/agent_backend/fake_client.py +++ b/api/clients/agent_backend/fake_client.py @@ -17,11 +17,10 @@ from dify_agent.protocol import ( CancelRunResponse, CreateRunRequest, CreateRunResponse, + DeferredToolCallPayload, RunEvent, RunFailedEvent, RunFailedEventData, - RunPausedEvent, - RunPausedEventData, RunStartedEvent, RunStatusResponse, RunSucceededEvent, @@ -32,7 +31,11 @@ _FIXED_TIME = datetime(2026, 1, 1, tzinfo=UTC) class FakeAgentBackendScenario(StrEnum): - """Deterministic fake scenarios for API-side integration tests.""" + """Deterministic fake scenarios for API-side integration tests. + + ``PAUSED`` represents the API workflow effect. On the Dify Agent wire + protocol it is a succeeded run carrying a deferred external tool call. + """ SUCCESS = "success" FAILED = "failed" @@ -95,7 +98,7 @@ class FakeAgentBackendRunClient: case FakeAgentBackendScenario.PAUSED: return RunStatusResponse( run_id=run_id, - status="paused", + status="succeeded", created_at=_FIXED_TIME, updated_at=_FIXED_TIME, ) @@ -128,13 +131,17 @@ class FakeAgentBackendRunClient: case FakeAgentBackendScenario.PAUSED: return ( RunStartedEvent(id="1-0", run_id=run_id, created_at=_FIXED_TIME), - RunPausedEvent( + RunSucceededEvent( id="2-0", run_id=run_id, created_at=_FIXED_TIME, - data=RunPausedEventData( - reason="human_input_required", - message="Agent requested human input.", + data=RunSucceededEventData( + deferred_tool_call=DeferredToolCallPayload( + tool_call_id="fake-ask-human-1", + tool_name="ask_human", + args={"question": "Agent requested human input."}, + metadata={"layer_type": "dify.ask_human", "schema_version": 1}, + ), session_snapshot=CompositorSessionSnapshot(layers=[]), ), ), diff --git a/api/clients/agent_backend/request_builder.py b/api/clients/agent_backend/request_builder.py index e315a989985..55944929ddc 100644 --- a/api/clients/agent_backend/request_builder.py +++ b/api/clients/agent_backend/request_builder.py @@ -11,13 +11,15 @@ composition-driven. from __future__ import annotations -from typing import ClassVar, cast +from collections.abc import Mapping +from typing import ClassVar from agenton.compositor import CompositorSessionSnapshot from agenton.compositor.schemas import LayerSessionSnapshot from agenton.layers import ExitIntent from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig from agenton_collections.layers.pydantic_ai import PYDANTIC_AI_HISTORY_LAYER_TYPE_ID +from dify_agent.layers.ask_human import DIFY_ASK_HUMAN_LAYER_TYPE_ID, DifyAskHumanLayerConfig from dify_agent.layers.dify_plugin import ( DIFY_PLUGIN_LLM_LAYER_TYPE_ID, DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID, @@ -25,6 +27,7 @@ from dify_agent.layers.dify_plugin import ( DifyPluginLLMLayerConfig, DifyPluginToolsLayerConfig, ) +from dify_agent.layers.drive import DIFY_DRIVE_LAYER_TYPE_ID, DifyDriveLayerConfig from dify_agent.layers.execution_context import ( DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig, @@ -36,10 +39,12 @@ from dify_agent.protocol import ( DIFY_AGENT_MODEL_LAYER_ID, DIFY_AGENT_OUTPUT_LAYER_ID, CreateRunRequest, + DeferredToolResultsPayload, LayerExitSignals, RunComposition, RunLayerSpec, RunPurpose, + RuntimeLayerSpec, ) from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator @@ -48,74 +53,15 @@ WORKFLOW_NODE_JOB_PROMPT_LAYER_ID = "workflow_node_job_prompt" WORKFLOW_USER_PROMPT_LAYER_ID = "workflow_user_prompt" AGENT_APP_USER_PROMPT_LAYER_ID = "agent_app_user_prompt" DIFY_EXECUTION_CONTEXT_LAYER_ID = "execution_context" +DIFY_DRIVE_LAYER_ID = "drive" DIFY_PLUGIN_TOOLS_LAYER_ID = "tools" +DIFY_ASK_HUMAN_LAYER_ID = "ask_human" DIFY_SHELL_LAYER_ID = "shell" -# Layer types that hold credentials in their per-run config. These are excluded -# from the cleanup-replay composition (and from the snapshot that is sent with -# the cleanup request) because we deliberately do not persist plaintext -# credentials between runs. -_CLEANUP_EXCLUDED_LAYER_TYPES: tuple[str, ...] = ( - DIFY_PLUGIN_LLM_LAYER_TYPE_ID, - DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID, -) - - -class CleanupLayerSpec(BaseModel): - """One layer node replayed by an Agent backend cleanup-only run. - - Cleanup composition cannot include credential-bearing plugin layers, so we - persist only the non-plugin layer specs together with the original config. - Storing the config (rather than just ``name``/``type``) means cleanup does - not depend on the original build-time inputs being re-derivable. - """ - - name: str - type: str - deps: dict[str, str] = Field(default_factory=dict) - metadata: dict[str, JsonValue] = Field(default_factory=dict) - config: JsonValue = None - - model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") - - -def extract_cleanup_layer_specs(composition: RunComposition) -> list[CleanupLayerSpec]: - """Project the in-flight composition into the persistable cleanup spec list. - - Plugin layers are intentionally dropped (their configs hold credentials and - the lifecycle contract says "do not include an LLM layer" during cleanup). - The filtered names must later drive snapshot filtering so the agenton - compositor's name-order check still passes for the cleanup run. - """ - excluded = set(_CLEANUP_EXCLUDED_LAYER_TYPES) - specs: list[CleanupLayerSpec] = [] - for layer in composition.layers: - if layer.type in excluded: - continue - config_value: JsonValue = None - if isinstance(layer.config, BaseModel): - config_value = layer.config.model_dump(mode="json", warnings=False) - else: - # ``RunLayerSpec.config`` is typed as ``LayerConfigInput`` which - # includes ``Mapping[str, object] | bytes``. In the cleanup-replay - # pipeline our builder only emits BaseModel-derived configs or - # ``None``, so the wider input alias narrows safely here. - config_value = cast(JsonValue, layer.config) - specs.append( - CleanupLayerSpec( - name=layer.name, - type=layer.type, - deps=dict(layer.deps), - metadata=dict(layer.metadata), - config=config_value, - ) - ) - return specs - def _filter_snapshot_to_specs( snapshot: CompositorSessionSnapshot, - specs: list[CleanupLayerSpec], + specs: list[RuntimeLayerSpec], ) -> CompositorSessionSnapshot: """Keep only snapshot layers whose names appear in the cleanup spec list. @@ -142,6 +88,30 @@ class AgentBackendModelConfig(BaseModel): model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") +# ``DifyPluginLLMLayerConfig.model_settings`` is pydantic_ai's ``ModelSettings`` +# TypedDict (closed: unknown keys are rejected, explicit ``None`` values fail the +# per-field type checks). Agent Soul model settings carry a wider, nullable shape +# (``stop`` / ``response_format`` plus null-padded fields), so the layer config +# only receives the keys the runtime contract accepts. +_AGENT_MODEL_SETTINGS_PASSTHROUGH_KEYS = ( + "temperature", + "top_p", + "presence_penalty", + "frequency_penalty", + "max_tokens", +) + + +def _agent_model_settings(settings: Mapping[str, JsonValue]) -> dict[str, JsonValue] | None: + sanitized: dict[str, JsonValue] = { + key: settings[key] for key in _AGENT_MODEL_SETTINGS_PASSTHROUGH_KEYS if settings.get(key) is not None + } + stop = settings.get("stop") + if isinstance(stop, list) and stop: + sanitized["stop_sequences"] = stop + return sanitized or None + + class AgentBackendOutputConfig(BaseModel): """API-side structured output declaration for the conventional output layer. @@ -169,11 +139,21 @@ class AgentBackendWorkflowNodeRunInput(BaseModel): idempotency_key: str | None = None output: AgentBackendOutputConfig | None = None tools: DifyPluginToolsLayerConfig | None = None + # Drive Skills & Files declaration (dify.drive) — an index the agent pulls + # through the back proxy, never inline content; see AGENT_DRIVE_MANIFEST_ENABLED. + drive_config: DifyDriveLayerConfig | None = None + # Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when + # the Agent Soul configures human involvement; a deferred call ends the run and + # the workflow pauses via the existing HITL form mechanism (ENG-635). + ask_human_config: DifyAskHumanLayerConfig | None = None # Inject the sandboxed shell layer (dify.shell). Requires the agent backend # to be wired with a shellctl entrypoint; see configs AGENT_SHELL_ENABLED. include_shell: bool = False shell_config: DifyShellLayerConfig | None = None session_snapshot: CompositorSessionSnapshot | None = None + # Human tool results fed back into a continuation run after a HITL submission + # (ENG-638). Keyed by the original deferred tool_call_id. + deferred_tool_results: DeferredToolResultsPayload | None = None include_history: bool = True suspend_on_exit: bool = True metadata: dict[str, JsonValue] = Field(default_factory=dict) @@ -205,11 +185,20 @@ class AgentBackendAgentAppRunInput(BaseModel): idempotency_key: str | None = None output: AgentBackendOutputConfig | None = None tools: DifyPluginToolsLayerConfig | None = None + # Drive Skills & Files declaration (dify.drive) — an index the agent pulls + # through the back proxy, never inline content; see AGENT_DRIVE_MANIFEST_ENABLED. + drive_config: DifyDriveLayerConfig | None = None + # Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when + # the Agent Soul configures human involvement (ENG-635). + ask_human_config: DifyAskHumanLayerConfig | None = None # Inject the sandboxed shell layer (dify.shell). Requires the agent backend # to be wired with a shellctl entrypoint; see configs AGENT_SHELL_ENABLED. include_shell: bool = False shell_config: DifyShellLayerConfig | None = None session_snapshot: CompositorSessionSnapshot | None = None + # Human tool results fed back into a continuation run after a HITL submission + # (ENG-638). Keyed by the original deferred tool_call_id. + deferred_tool_results: DeferredToolResultsPayload | None = None include_history: bool = True suspend_on_exit: bool = True metadata: dict[str, JsonValue] = Field(default_factory=dict) @@ -263,6 +252,18 @@ class AgentBackendRunRequestBuilder: ] ) + if run_input.drive_config is not None: + # Drive Skills & Files declaration (dify.drive): a config-only index; + # the agent pulls listed entries through the back proxy by drive_ref. + layers.append( + RunLayerSpec( + name=DIFY_DRIVE_LAYER_ID, + type=DIFY_DRIVE_LAYER_TYPE_ID, + metadata=run_input.metadata, + config=run_input.drive_config, + ) + ) + if run_input.include_history: layers.append( RunLayerSpec( @@ -283,7 +284,7 @@ class AgentBackendRunRequestBuilder: model_provider=run_input.model.model_provider, model=run_input.model.model, credentials=run_input.model.credentials, - model_settings=run_input.model.model_settings or None, + model_settings=_agent_model_settings(run_input.model.model_settings), ), ) ) @@ -299,13 +300,28 @@ class AgentBackendRunRequestBuilder: ) ) + if run_input.ask_human_config is not None: + # Human-in-the-loop ask_human deferred tool (dify.ask_human). A call ends + # the run with a deferred_tool_call; the caller pauses (workflow HITL) and + # later resumes with deferred_tool_results. Needs the history layer above. + layers.append( + RunLayerSpec( + name=DIFY_ASK_HUMAN_LAYER_ID, + type=DIFY_ASK_HUMAN_LAYER_TYPE_ID, + metadata=run_input.metadata, + config=run_input.ask_human_config, + ) + ) + if run_input.include_shell: - # Sandboxed bash workspace (dify.shell). The layer declares NoLayerDeps, - # so the spec carries no deps; shellctl connection is server-injected. + # Sandboxed bash workspace (dify.shell). Depends on execution_context so + # the agent server can mint per-command Agent Stub env (back proxy); + # shellctl connection itself is server-injected. layers.append( RunLayerSpec( name=DIFY_SHELL_LAYER_ID, type=DIFY_SHELL_LAYER_TYPE_ID, + deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}, metadata=run_input.metadata, config=run_input.shell_config or DifyShellLayerConfig(), ) @@ -331,6 +347,7 @@ class AgentBackendRunRequestBuilder: idempotency_key=run_input.idempotency_key, metadata=run_input.metadata, session_snapshot=run_input.session_snapshot, + deferred_tool_results=run_input.deferred_tool_results, on_exit=LayerExitSignals( default=ExitIntent.SUSPEND if run_input.suspend_on_exit else ExitIntent.DELETE, ), @@ -340,7 +357,7 @@ class AgentBackendRunRequestBuilder: self, *, session_snapshot: CompositorSessionSnapshot, - composition_layer_specs: list[CleanupLayerSpec], + runtime_layer_specs: list[RuntimeLayerSpec], idempotency_key: str | None = None, metadata: dict[str, JsonValue] | None = None, ) -> CreateRunRequest: @@ -353,9 +370,9 @@ class AgentBackendRunRequestBuilder: composition and the snapshot before submission because their configs require credentials that are not persisted between runs. """ - if not composition_layer_specs: + if not runtime_layer_specs: raise ValueError( - "build_cleanup_request requires composition_layer_specs; an empty " + "build_cleanup_request requires runtime_layer_specs; an empty " "composition would fail the agent backend's snapshot validation." ) request_metadata = dict(metadata or {}) @@ -368,9 +385,9 @@ class AgentBackendRunRequestBuilder: metadata=dict(spec.metadata), config=spec.config, ) - for spec in composition_layer_specs + for spec in runtime_layer_specs ] - filtered_snapshot = _filter_snapshot_to_specs(session_snapshot, composition_layer_specs) + filtered_snapshot = _filter_snapshot_to_specs(session_snapshot, runtime_layer_specs) return CreateRunRequest( composition=RunComposition(layers=layers), purpose="workflow_node", @@ -416,6 +433,18 @@ class AgentBackendRunRequestBuilder: ] ) + if run_input.drive_config is not None: + # Drive Skills & Files declaration (dify.drive): a config-only index; + # the agent pulls listed entries through the back proxy by drive_ref. + layers.append( + RunLayerSpec( + name=DIFY_DRIVE_LAYER_ID, + type=DIFY_DRIVE_LAYER_TYPE_ID, + metadata=run_input.metadata, + config=run_input.drive_config, + ) + ) + if run_input.include_history: layers.append( RunLayerSpec( @@ -437,7 +466,7 @@ class AgentBackendRunRequestBuilder: model_provider=run_input.model.model_provider, model=run_input.model.model, credentials=run_input.model.credentials, - model_settings=run_input.model.model_settings or None, + model_settings=_agent_model_settings(run_input.model.model_settings), ), ), ] @@ -454,13 +483,28 @@ class AgentBackendRunRequestBuilder: ) ) + if run_input.ask_human_config is not None: + # Human-in-the-loop ask_human deferred tool (dify.ask_human). A call ends + # the run with a deferred_tool_call; the caller pauses (workflow HITL) and + # later resumes with deferred_tool_results. Needs the history layer above. + layers.append( + RunLayerSpec( + name=DIFY_ASK_HUMAN_LAYER_ID, + type=DIFY_ASK_HUMAN_LAYER_TYPE_ID, + metadata=run_input.metadata, + config=run_input.ask_human_config, + ) + ) + if run_input.include_shell: - # Sandboxed bash workspace (dify.shell). The layer declares NoLayerDeps, - # so the spec carries no deps; shellctl connection is server-injected. + # Sandboxed bash workspace (dify.shell). Depends on execution_context so + # the agent server can mint per-command Agent Stub env (back proxy); + # shellctl connection itself is server-injected. layers.append( RunLayerSpec( name=DIFY_SHELL_LAYER_ID, type=DIFY_SHELL_LAYER_TYPE_ID, + deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}, metadata=run_input.metadata, config=run_input.shell_config or DifyShellLayerConfig(), ) @@ -486,6 +530,7 @@ class AgentBackendRunRequestBuilder: idempotency_key=run_input.idempotency_key, metadata=run_input.metadata, session_snapshot=run_input.session_snapshot, + deferred_tool_results=run_input.deferred_tool_results, on_exit=LayerExitSignals( default=ExitIntent.SUSPEND if run_input.suspend_on_exit else ExitIntent.DELETE, ), diff --git a/api/clients/agent_backend/workspace_files_client.py b/api/clients/agent_backend/workspace_files_client.py deleted file mode 100644 index bd41f457ba9..00000000000 --- a/api/clients/agent_backend/workspace_files_client.py +++ /dev/null @@ -1,135 +0,0 @@ -"""API-side client for the agent backend's read-only workspace file endpoints. - -The agent backend exposes ``/workspaces/{session_id}/files{,/preview,/download}`` -to inspect a shell-layer sandbox workspace. This thin synchronous client proxies -those reads for the console FS inspector and normalizes transport/HTTP failures -into the API backend's ``AgentBackendError`` boundary, preserving the backend's -status code and ``{code, message}`` detail so the controller can relay them. -""" - -from __future__ import annotations - -import base64 -import binascii -from dataclasses import dataclass -from typing import Literal - -import httpx -from pydantic import BaseModel - -from clients.agent_backend.errors import AgentBackendHTTPError, AgentBackendTransportError - -_DEFAULT_TIMEOUT_SECONDS = 30.0 - - -class WorkspaceFileEntry(BaseModel): - """One entry in a workspace directory listing.""" - - name: str - type: Literal["file", "dir", "symlink"] - size: int - mtime: int - - -class WorkspaceListResult(BaseModel): - """Directory listing of a workspace path.""" - - path: str - entries: list[WorkspaceFileEntry] - truncated: bool - - -class WorkspacePreviewResult(BaseModel): - """Inline preview of a workspace file.""" - - path: str - size: int - truncated: bool - binary: bool - text: str | None = None - - -@dataclass(frozen=True, slots=True) -class WorkspaceDownloadResult: - """Decoded bytes of a workspace file for download.""" - - path: str - size: int - truncated: bool - content: bytes - - -class WorkspaceFilesBackendClient: - """Synchronous proxy to the agent backend workspace file endpoints.""" - - def __init__( - self, - base_url: str, - *, - timeout: float = _DEFAULT_TIMEOUT_SECONDS, - transport: httpx.BaseTransport | None = None, - ) -> None: - self._base_url = base_url.rstrip("/") - self._timeout = timeout - self._transport = transport - - def list_files(self, session_id: str, path: str) -> WorkspaceListResult: - data = self._get(f"/workspaces/{session_id}/files", params={"path": path}) - return WorkspaceListResult.model_validate(data) - - def preview(self, session_id: str, path: str) -> WorkspacePreviewResult: - data = self._get(f"/workspaces/{session_id}/files/preview", params={"path": path}) - return WorkspacePreviewResult.model_validate(data) - - def download(self, session_id: str, path: str) -> WorkspaceDownloadResult: - data = self._get(f"/workspaces/{session_id}/files/download", params={"path": path}) - encoded = data.get("content_base64") - if not isinstance(encoded, str): - raise AgentBackendHTTPError("agent backend download response missing content", status_code=502, detail=data) - try: - content = base64.b64decode(encoded, validate=True) - except (binascii.Error, ValueError) as exc: - raise AgentBackendHTTPError( - "agent backend returned undecodable download content", status_code=502, detail=str(exc) - ) from exc - size = data.get("size") - return WorkspaceDownloadResult( - path=str(data.get("path", path)), - size=int(size) if isinstance(size, (int, float)) else len(content), - truncated=bool(data.get("truncated")), - content=content, - ) - - def _get(self, route: str, *, params: dict[str, str]) -> dict[str, object]: - url = f"{self._base_url}{route}" - try: - with httpx.Client(timeout=self._timeout, transport=self._transport, trust_env=False) as client: - response = client.get(url, params=params) - except httpx.HTTPError as exc: - raise AgentBackendTransportError(f"failed to reach agent backend workspace endpoint: {exc}") from exc - if response.status_code >= 400: - detail: object - try: - detail = response.json().get("detail", response.text) - except ValueError: - detail = response.text - raise AgentBackendHTTPError( - f"agent backend workspace request failed ({response.status_code})", - status_code=response.status_code, - detail=detail, - ) - body = response.json() - if not isinstance(body, dict): - raise AgentBackendHTTPError( - "agent backend workspace response was not an object", status_code=502, detail=body - ) - return body - - -__all__ = [ - "WorkspaceDownloadResult", - "WorkspaceFileEntry", - "WorkspaceFilesBackendClient", - "WorkspaceListResult", - "WorkspacePreviewResult", -] diff --git a/api/commands/__init__.py b/api/commands/__init__.py index 86f6faa78c2..ea4c5aaa2a8 100644 --- a/api/commands/__init__.py +++ b/api/commands/__init__.py @@ -11,6 +11,7 @@ from .data_migration import ( migration_data_wizard, ) from .plugin import ( + backfill_plugin_auto_upgrade, extract_plugins, extract_unique_plugins, install_plugins, @@ -49,6 +50,7 @@ from .vector import ( __all__ = [ "add_qdrant_index", "archive_workflow_runs", + "backfill_plugin_auto_upgrade", "clean_expired_messages", "clean_workflow_runs", "cleanup_orphaned_draft_variables", diff --git a/api/commands/data_migrate.py b/api/commands/data_migrate.py index 2b33f46cd8c..4a71d864b26 100644 --- a/api/commands/data_migrate.py +++ b/api/commands/data_migrate.py @@ -145,7 +145,7 @@ def legacy_model_types( option_name="--model-types", ) selected_model_types = ( - tuple(ModelType.value_of(model_type) for model_type in normalized_model_types) + tuple(ModelType(model_type) for model_type in normalized_model_types) if normalized_model_types else ( ModelType.LLM, diff --git a/api/commands/plugin.py b/api/commands/plugin.py index e1b3cf0fa1e..71c19f842fb 100644 --- a/api/commands/plugin.py +++ b/api/commands/plugin.py @@ -1,10 +1,11 @@ import json import logging +import time from typing import Any, cast import click from pydantic import TypeAdapter -from sqlalchemy import delete, select +from sqlalchemy import delete, func, select from sqlalchemy.engine import CursorResult from configs import dify_config @@ -15,11 +16,13 @@ from core.plugin.plugin_service import PluginService from core.tools.utils.system_encryption import encrypt_system_params from extensions.ext_database import db from models import Tenant +from models.account import TenantPluginAutoUpgradeStrategy from models.oauth import DatasourceOauthParamConfig, DatasourceProvider from models.provider_ids import DatasourceProviderID, ToolProviderID from models.source import DataSourceApiKeyAuthBinding, DataSourceOauthBinding from models.tools import ToolOAuthSystemClient from services.plugin.data_migration import PluginDataMigration +from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService from services.plugin.plugin_migration import PluginMigration logger = logging.getLogger(__name__) @@ -402,6 +405,110 @@ def migrate_data_for_plugin(): click.echo(click.style("Migrate data for plugin completed.", fg="green")) +def _candidate_auto_upgrade_strategy_tenant_ids_stmt(limit: int | None = None): + category_count = len(TenantPluginAutoUpgradeStrategy.PluginCategory) + stmt = ( + select(TenantPluginAutoUpgradeStrategy.tenant_id) + .group_by(TenantPluginAutoUpgradeStrategy.tenant_id) + .having(func.count(func.distinct(TenantPluginAutoUpgradeStrategy.category)) < category_count) + .order_by(TenantPluginAutoUpgradeStrategy.tenant_id) + ) + + if limit is not None: + stmt = stmt.limit(limit) + + return stmt + + +def _count_auto_upgrade_strategy_tenant_ids(limit: int | None) -> int: + candidate_stmt = _candidate_auto_upgrade_strategy_tenant_ids_stmt(limit).subquery() + return db.session.scalar(select(func.count()).select_from(candidate_stmt)) or 0 + + +def _iter_auto_upgrade_strategy_tenant_ids(limit: int | None): + stmt = _candidate_auto_upgrade_strategy_tenant_ids_stmt(limit).execution_options(yield_per=1000) + yield from db.session.scalars(stmt) + + +@click.command( + "backfill-plugin-auto-upgrade", + help="Backfill category-scoped plugin auto-upgrade strategies and normalize plugin lists.", +) +@click.option("--tenant-id", multiple=True, help="Tenant ID to backfill. Can be passed multiple times.") +@click.option("--limit", type=int, default=None, help="Maximum number of candidate tenants to process.") +@click.option("--batch-size", type=int, default=500, show_default=True, help="Progress reporting batch size.") +@click.option("--dry-run", is_flag=True, help="Only print candidate tenant count.") +def backfill_plugin_auto_upgrade( + tenant_id: tuple[str, ...], + limit: int | None, + batch_size: int, + dry_run: bool, +): + """ + Backfill historical auto-upgrade strategies after the category column exists. + + Missing category rows are created from the tenant's tool/default row. Pure default + strategies become latest for model plugins and fix-only for all other categories. + Tenants with include/exclude plugin IDs are split + by installed plugin category using plugin daemon metadata. + """ + start_at = time.perf_counter() + candidate_count = len(tenant_id) if tenant_id else _count_auto_upgrade_strategy_tenant_ids(limit) + click.echo(click.style(f"Found {candidate_count} candidate tenants.", fg="yellow")) + + if dry_run: + elapsed = time.perf_counter() - start_at + click.echo(click.style(f"Dry run completed. elapsed={elapsed:.2f}s", fg="green")) + return + + tenant_ids = list(tenant_id) if tenant_id else _iter_auto_upgrade_strategy_tenant_ids(limit) + + backfilled_count = 0 + created_count = 0 + normalized_count = 0 + skipped_count = 0 + failed_count = 0 + for index, current_tenant_id in enumerate(tenant_ids, start=1): + try: + result = PluginAutoUpgradeService.backfill_strategy_categories( + current_tenant_id, + ) + except Exception as e: + failed_count += 1 + click.echo(click.style(f"Failed tenant {current_tenant_id}: {str(e)}", fg="red")) + continue + + if result.created_count > 0: + backfilled_count += 1 + created_count += result.created_count + elif not result.normalized: + skipped_count += 1 + if result.normalized: + normalized_count += 1 + + if batch_size > 0 and index % batch_size == 0: + click.echo( + click.style( + f"Processed {index}/{candidate_count} tenants. " + f"backfilled={backfilled_count}, created_rows={created_count}, " + f"normalized={normalized_count}, skipped={skipped_count}, failed={failed_count}, " + f"elapsed={time.perf_counter() - start_at:.2f}s", + fg="yellow", + ) + ) + + elapsed = time.perf_counter() - start_at + click.echo( + click.style( + f"Backfill plugin auto-upgrade strategy categories completed. " + f"backfilled={backfilled_count}, created_rows={created_count}, " + f"normalized={normalized_count}, skipped={skipped_count}, failed={failed_count}, " + f"elapsed={elapsed:.2f}s", + fg="green", + ) + ) + + @click.command("extract-plugins", help="Extract plugins.") @click.option("--output_file", prompt=True, help="The file to store the extracted plugins.", default="plugins.jsonl") @click.option("--workers", prompt=True, help="The number of workers to extract plugins.", default=10) diff --git a/api/configs/enterprise/__init__.py b/api/configs/enterprise/__init__.py index b3a93d97736..705ea67bcbc 100644 --- a/api/configs/enterprise/__init__.py +++ b/api/configs/enterprise/__init__.py @@ -16,7 +16,7 @@ class EnterpriseFeatureConfig(BaseSettings): CAN_REPLACE_LOGO: bool = Field( description="Allow customization of the enterprise logo.", - default=False, + default=True, ) ENTERPRISE_REQUEST_TIMEOUT: int = Field( diff --git a/api/configs/extra/agent_backend_config.py b/api/configs/extra/agent_backend_config.py index 347302ceb37..0d65d3de97e 100644 --- a/api/configs/extra/agent_backend_config.py +++ b/api/configs/extra/agent_backend_config.py @@ -31,3 +31,13 @@ class AgentBackendConfig(BaseSettings): ), default=False, ) + + AGENT_DRIVE_MANIFEST_ENABLED: bool = Field( + description=( + "Inject the dify.drive layer (Skills & Files drive manifest declaration) " + "into Agent runs. The declaration is an index only — the agent backend " + "pulls the actual SKILL.md / files through the back proxy. Keep it off " + "until the agent backend registers the dify.drive layer type." + ), + default=False, + ) diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index 109ce749a64..dc8c840da9c 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -943,9 +943,10 @@ class AuthConfig(BaseSettings): default=True, ) - OPENAPI_RATE_LIMIT_PER_TOKEN: PositiveInt = Field( + OPENAPI_RATE_LIMIT_PER_TOKEN: NonNegativeInt = Field( description="Per-token rate limit on /openapi/v1/* (requests per minute). " - "Bucket keyed on sha256(token), shared across api replicas via Redis.", + "Bucket keyed on sha256(token), shared across api replicas via Redis. " + "Set to 0 to disable the per-token limit entirely.", default=60, ) diff --git a/api/configs/remote_settings_sources/nacos/http_request.py b/api/configs/remote_settings_sources/nacos/http_request.py index 1a0744a21bd..a15b6aeaae5 100644 --- a/api/configs/remote_settings_sources/nacos/http_request.py +++ b/api/configs/remote_settings_sources/nacos/http_request.py @@ -20,6 +20,12 @@ class NacosHttpClient: self.token: str | None = None self.token_ttl = 18000 self.token_expire_time: float = 0 + # Bounded timeouts so a slow or unresponsive Nacos server cannot hang the API + # service indefinitely during startup or token refresh. + self.timeout = httpx.Timeout( + float(os.getenv("DIFY_ENV_NACOS_REQUEST_TIMEOUT", "10.0")), + connect=float(os.getenv("DIFY_ENV_NACOS_CONNECT_TIMEOUT", "3.0")), + ) def http_request( self, url: str, method: str = "GET", headers: dict[str, str] | None = None, params: dict[str, str] | None = None @@ -28,12 +34,17 @@ class NacosHttpClient: headers = {} if params is None: params = {} + full_url = "http://" + self.server + url try: self._inject_auth_info(headers, params) - response = httpx.request(method, url="http://" + self.server + url, headers=headers, params=params) + response = httpx.request(method, url=full_url, headers=headers, params=params, timeout=self.timeout) response.raise_for_status() return response.text + except httpx.TimeoutException as e: + logger.warning("Request to Nacos timed out (url=%s, timeout=%s): %s", full_url, self.timeout, e) + return f"Request to Nacos timed out: {e}" except httpx.RequestError as e: + logger.warning("Request to Nacos failed (url=%s): %s", full_url, e) return f"Request to Nacos failed: {e}" def _inject_auth_info(self, headers: dict[str, str], params: dict[str, str], module: str = "config") -> None: @@ -78,13 +89,16 @@ class NacosHttpClient: params = {"username": self.username, "password": self.password} url = "http://" + self.server + "/nacos/v1/auth/login" try: - resp = httpx.request("POST", url, headers=None, params=params) + resp = httpx.request("POST", url, headers=None, params=params, timeout=self.timeout) resp.raise_for_status() response_data = resp.json() self.token = response_data.get("accessToken") self.token_ttl = response_data.get("tokenTtl", 18000) self.token_expire_time = current_time + self.token_ttl - 10 return self.token + except httpx.TimeoutException: + logger.exception("[get-access-token] request to Nacos timed out (url=%s, timeout=%s)", url, self.timeout) + raise except Exception: logger.exception("[get-access-token] exception occur") raise diff --git a/api/controllers/common/controller_schemas.py b/api/controllers/common/controller_schemas.py index c12d5764737..8eeed8f0a0a 100644 --- a/api/controllers/common/controller_schemas.py +++ b/api/controllers/common/controller_schemas.py @@ -62,7 +62,7 @@ class WorkflowListQuery(BaseModel): class WorkflowRunPayload(BaseModel): inputs: dict[str, Any] - files: list[dict[str, Any]] | None = None + files: list[dict[str, Any]] | None = Field(default=None) class WorkflowUpdatePayload(BaseModel): diff --git a/api/controllers/common/fields.py b/api/controllers/common/fields.py index a90a26e7301..06d68a5df16 100644 --- a/api/controllers/common/fields.py +++ b/api/controllers/common/fields.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any -from pydantic import BaseModel, ConfigDict, Field, computed_field +from pydantic import BaseModel, ConfigDict, Field, RootModel, computed_field from fields.base import ResponseModel from graphon.file import helpers as file_helpers @@ -24,6 +24,34 @@ class SimpleResultResponse(ResponseModel): result: str +class GeneratedAppResponse(RootModel[JSONValue]): + root: JSONValue + + +class EventStreamResponse(RootModel[str]): + root: str + + +class TextFileResponse(RootModel[str]): + root: str + + +class RedirectResponse(RootModel[str]): + root: str + + +class BinaryFileResponse(RootModel[bytes]): + root: bytes + + +class AudioBinaryResponse(RootModel[bytes]): + root: bytes + + +class AudioTranscriptResponse(ResponseModel): + text: str + + class SimpleResultMessageResponse(ResponseModel): result: str message: str diff --git a/api/controllers/common/schema.py b/api/controllers/common/schema.py index 70d24b005ff..0fb2f3c5884 100644 --- a/api/controllers/common/schema.py +++ b/api/controllers/common/schema.py @@ -1,9 +1,9 @@ """Helpers for registering Pydantic models with Flask-RESTX namespaces. Flask-RESTX treats `SchemaModel` bodies as opaque JSON schemas; it does not -promote Pydantic's nested `$defs` into top-level Swagger `definitions`. +promote Pydantic's nested `$defs` into top-level OpenAPI component schemas. These helpers keep that translation centralized so models registered through -`register_schema_models` emit resolvable Swagger 2.0 references. +`register_schema_models` emit resolvable OpenAPI 3 references. """ from collections.abc import Iterable, Mapping @@ -14,7 +14,7 @@ from flask import request from flask_restx import Namespace from pydantic import BaseModel, TypeAdapter -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" +DEFAULT_REF_TEMPLATE_OPENAPI_3_0 = "#/components/schemas/{model}" QueryParamDoc = TypedDict( @@ -27,12 +27,18 @@ QueryParamDoc = TypedDict( "description": NotRequired[str], "enum": NotRequired[list[object]], "default": NotRequired[object], + "format": NotRequired[str], "minimum": NotRequired[int | float], "maximum": NotRequired[int | float], + "exclusiveMinimum": NotRequired[int | float], + "exclusiveMaximum": NotRequired[int | float], "minLength": NotRequired[int], "maxLength": NotRequired[int], + "pattern": NotRequired[str], "minItems": NotRequired[int], "maxItems": NotRequired[int], + "uniqueItems": NotRequired[bool], + "multipleOf": NotRequired[int | float], }, ) @@ -48,7 +54,6 @@ class QueryArgs(Protocol): def _register_json_schema(namespace: Namespace, name: str, schema: dict) -> None: """Register a JSON schema and promote any nested Pydantic `$defs`.""" - schema = _swagger_2_compatible_schema(schema) nested_definitions = schema.get("$defs") schema_to_register = dict(schema) if isinstance(nested_definitions, dict): @@ -71,41 +76,12 @@ def _register_schema_model(namespace: Namespace, model: type[BaseModel], *, mode _register_json_schema( namespace, model.__name__, - model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0, mode=mode), + model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0, mode=mode), ) -def _swagger_2_compatible_schema(value: Any) -> Any: - if isinstance(value, list): - return [_swagger_2_compatible_schema(item) for item in value] - - if not isinstance(value, dict): - return value - - converted = {key: _swagger_2_compatible_schema(child) for key, child in value.items()} - any_of = value.get("anyOf") - if not isinstance(any_of, list): - return converted - - non_null_candidates = [ - candidate for candidate in any_of if isinstance(candidate, Mapping) and candidate.get("type") != "null" - ] - has_null_candidate = any(isinstance(candidate, Mapping) and candidate.get("type") == "null" for candidate in any_of) - if not has_null_candidate or len(non_null_candidates) != 1: - return converted - - non_null_schema = _swagger_2_compatible_schema(dict(non_null_candidates[0])) - if not isinstance(non_null_schema, dict): - return converted - - converted.pop("anyOf", None) - converted.update(non_null_schema) - converted["x-nullable"] = True - return converted - - def register_schema_model(namespace: Namespace, model: type[BaseModel]) -> None: - """Register a BaseModel and its nested schema definitions for Swagger documentation.""" + """Register a BaseModel and its nested component schemas for OpenAPI documentation.""" _register_schema_model(namespace, model, mode="validation") @@ -146,7 +122,7 @@ def register_enum_models(namespace: Namespace, *models: type[StrEnum]) -> None: _register_json_schema( namespace, model.__name__, - TypeAdapter(model).json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), + TypeAdapter(model).json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0), ) @@ -155,10 +131,11 @@ def query_params_from_model(model: type[BaseModel]) -> dict[str, QueryParamDoc]: `Namespace.expect()` treats Pydantic schema models as request bodies, so GET endpoints should keep runtime validation on the Pydantic model and feed this - derived mapping to `Namespace.doc(params=...)` for Swagger documentation. + derived mapping to `Namespace.doc(params=...)` for OpenAPI documentation. """ - schema = model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) + schema = model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0) + definitions = _schema_definitions(schema) properties = schema.get("properties", {}) if not isinstance(properties, Mapping): return {} @@ -171,7 +148,11 @@ def query_params_from_model(model: type[BaseModel]) -> dict[str, QueryParamDoc]: if not isinstance(name, str) or not isinstance(property_schema, Mapping): continue - params[name] = _query_param_from_property(property_schema, required=name in required_names) + params[name] = _query_param_from_property( + property_schema, + required=name in required_names, + definitions=definitions, + ) return params @@ -203,7 +184,7 @@ def query_params_from_request[ModelT: BaseModel]( def _drop_malformed_defaulted_integer_params(model: type[BaseModel], params: dict[str, Any]) -> None: - properties = model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0).get("properties", {}) + properties = model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0).get("properties", {}) if not isinstance(properties, Mapping): return @@ -228,8 +209,18 @@ def _drop_malformed_defaulted_integer_params(model: type[BaseModel], params: dic params.pop(name) -def _query_param_from_property(property_schema: Mapping[str, Any], *, required: bool) -> QueryParamDoc: - param_schema = _nullable_property_schema(property_schema) +def _schema_definitions(schema: Mapping[str, Any]) -> Mapping[str, Any]: + definitions = schema.get("$defs") + return definitions if isinstance(definitions, Mapping) else {} + + +def _query_param_from_property( + property_schema: Mapping[str, Any], + *, + required: bool, + definitions: Mapping[str, Any], +) -> QueryParamDoc: + param_schema = _resolve_schema_ref(_nullable_property_schema(property_schema), definitions) param_doc: QueryParamDoc = {"in": "query", "required": required} description = param_schema.get("description") @@ -242,9 +233,16 @@ def _query_param_from_property(property_schema: Mapping[str, Any], *, required: if schema_type == "array": items = param_schema.get("items") if isinstance(items, Mapping): - item_type = items.get("type") + item_schema = _resolve_schema_ref(items, definitions) + item_type = item_schema.get("type") if isinstance(item_type, str): param_doc["items"] = {"type": item_type} + item_enum = item_schema.get("enum") + if isinstance(item_enum, list): + param_doc.setdefault("items", {})["enum"] = item_enum + item_format = item_schema.get("format") + if isinstance(item_format, str): + param_doc.setdefault("items", {})["format"] = item_format enum = param_schema.get("enum") if isinstance(enum, list): @@ -254,6 +252,10 @@ def _query_param_from_property(property_schema: Mapping[str, Any], *, required: if default is not None: param_doc["default"] = default + schema_format = param_schema.get("format") + if isinstance(schema_format, str): + param_doc["format"] = schema_format + minimum = param_schema.get("minimum") if isinstance(minimum, int | float): param_doc["minimum"] = minimum @@ -262,6 +264,14 @@ def _query_param_from_property(property_schema: Mapping[str, Any], *, required: if isinstance(maximum, int | float): param_doc["maximum"] = maximum + exclusive_minimum = param_schema.get("exclusiveMinimum") + if isinstance(exclusive_minimum, int | float): + param_doc["exclusiveMinimum"] = exclusive_minimum + + exclusive_maximum = param_schema.get("exclusiveMaximum") + if isinstance(exclusive_maximum, int | float): + param_doc["exclusiveMaximum"] = exclusive_maximum + min_length = param_schema.get("minLength") if isinstance(min_length, int): param_doc["minLength"] = min_length @@ -270,6 +280,10 @@ def _query_param_from_property(property_schema: Mapping[str, Any], *, required: if isinstance(max_length, int): param_doc["maxLength"] = max_length + pattern = param_schema.get("pattern") + if isinstance(pattern, str): + param_doc["pattern"] = pattern + min_items = param_schema.get("minItems") if isinstance(min_items, int): param_doc["minItems"] = min_items @@ -278,9 +292,31 @@ def _query_param_from_property(property_schema: Mapping[str, Any], *, required: if isinstance(max_items, int): param_doc["maxItems"] = max_items + unique_items = param_schema.get("uniqueItems") + if isinstance(unique_items, bool): + param_doc["uniqueItems"] = unique_items + + multiple_of = param_schema.get("multipleOf") + if isinstance(multiple_of, int | float): + param_doc["multipleOf"] = multiple_of + return param_doc +def _resolve_schema_ref(property_schema: Mapping[str, Any], definitions: Mapping[str, Any]) -> Mapping[str, Any]: + ref = property_schema.get("$ref") + if not isinstance(ref, str): + return property_schema + + ref_name = ref.rsplit("/", 1)[-1] + resolved = definitions.get(ref_name) + if not isinstance(resolved, Mapping): + return property_schema + + property_without_ref = {key: value for key, value in property_schema.items() if key != "$ref"} + return {**resolved, **property_without_ref} + + def _nullable_property_schema(property_schema: Mapping[str, Any]) -> Mapping[str, Any]: any_of = property_schema.get("anyOf") if not isinstance(any_of, list): @@ -297,7 +333,7 @@ def _nullable_property_schema(property_schema: Mapping[str, Any]) -> Mapping[str __all__ = [ - "DEFAULT_REF_TEMPLATE_SWAGGER_2_0", + "DEFAULT_REF_TEMPLATE_OPENAPI_3_0", "get_or_create_model", "query_params_from_model", "query_params_from_request", diff --git a/api/controllers/common/wraps.py b/api/controllers/common/wraps.py new file mode 100644 index 00000000000..c481f6eca94 --- /dev/null +++ b/api/controllers/common/wraps.py @@ -0,0 +1,30 @@ +from collections.abc import Callable +from functools import wraps + +from core.rbac import RBACPermission, RBACResourceScope + +__all__ = ["RBACPermission", "RBACResourceScope", "rbac_permission_required"] + + +def rbac_permission_required[**P, R]( + resource_type: RBACResourceScope, + scene: RBACPermission, + *, + resource_required: bool = True, +) -> Callable[[Callable[P, R]], Callable[P, R]]: + """Check enterprise RBAC permissions for the current user. + + Args: + resource_type: The :class:`RBACResourceScope` member (app/dataset/workspace). + scene: The :class:`RBACPermission` permission point. + resource_required: Whether a concrete resource ID is required. + """ + + def decorator(view: Callable[P, R]) -> Callable[P, R]: + @wraps(view) + def decorated(*args: P.args, **kwargs: P.kwargs) -> R: + return view(*args, **kwargs) + + return decorated + + return decorator diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index 3af02f37dcf..e2bf0bd22ce 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -53,7 +53,8 @@ from .app import ( agent, agent_app_access, agent_app_feature, - agent_app_workspace, + agent_app_sandbox, + agent_drive_inspector, annotation, app, audio, @@ -153,8 +154,9 @@ __all__ = [ "agent", "agent_app_access", "agent_app_feature", - "agent_app_workspace", + "agent_app_sandbox", "agent_composer", + "agent_drive_inspector", "agent_providers", "agent_roster", "annotation", diff --git a/api/controllers/console/agent/app_helpers.py b/api/controllers/console/agent/app_helpers.py new file mode 100644 index 00000000000..51adc1e136e --- /dev/null +++ b/api/controllers/console/agent/app_helpers.py @@ -0,0 +1,10 @@ +from uuid import UUID + +from extensions.ext_database import db +from models.model import App +from services.agent.roster_service import AgentRosterService + + +def resolve_agent_app_model(*, tenant_id: str, agent_id: UUID) -> App: + """Resolve the hidden Agent App backing an Agent Console resource.""" + return AgentRosterService(db.session).get_agent_app_model(tenant_id=tenant_id, agent_id=str(agent_id)) diff --git a/api/controllers/console/agent/composer.py b/api/controllers/console/agent/composer.py index 8d2297a1b82..6915a54db93 100644 --- a/api/controllers/console/agent/composer.py +++ b/api/controllers/console/agent/composer.py @@ -1,7 +1,10 @@ +from uuid import UUID + from flask_restx import Resource 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 from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( account_initialization_required, @@ -35,6 +38,10 @@ register_response_schema_models( ) +def _resolve_agent_app_id(*, tenant_id: str, agent_id: UUID) -> str: + return resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id).id + + @console_ns.route("/apps//workflows/draft/nodes//agent-composer") class WorkflowAgentComposerApi(Resource): @console_ns.response( @@ -94,7 +101,13 @@ class WorkflowAgentComposerValidateApi(Resource): def post(self, tenant_id: str, app_model: App, node_id: str): payload = ComposerSavePayload.model_validate(console_ns.payload or {}) ComposerConfigValidator.validate_save_payload(payload) - findings = AgentComposerService.collect_validation_findings(tenant_id=tenant_id, payload=payload) + findings = AgentComposerService.collect_validation_findings( + tenant_id=tenant_id, + payload=payload, + agent_id=AgentComposerService.resolve_workflow_node_agent_id( + tenant_id=tenant_id, app_id=app_model.id, node_id=node_id + ), + ) return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings}) @@ -170,18 +183,18 @@ class WorkflowAgentComposerSaveToRosterApi(Resource): ) -@console_ns.route("/apps//agent-composer") -class AgentAppComposerApi(Resource): +@console_ns.route("/agent//composer") +class AgentComposerApi(Resource): @console_ns.response(200, "Agent app composer state", console_ns.models[AgentAppComposerResponse.__name__]) @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.AGENT]) @with_current_tenant_id - def get(self, tenant_id: str, app_model: App): + def get(self, tenant_id: str, agent_id: UUID): + app_id = _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id) return dump_response( AgentAppComposerResponse, - AgentComposerService.load_agent_app_composer(tenant_id=tenant_id, app_id=app_model.id), + AgentComposerService.load_agent_app_composer(tenant_id=tenant_id, app_id=app_id), ) @console_ns.expect(console_ns.models[ComposerSavePayload.__name__]) @@ -190,24 +203,24 @@ class AgentAppComposerApi(Resource): @login_required @account_initialization_required @edit_permission_required - @get_app_model(mode=[AppMode.AGENT]) @with_current_user_id @with_current_tenant_id - def put(self, tenant_id: str, account_id: str, app_model: App): + def put(self, tenant_id: str, account_id: str, agent_id: UUID): + app_id = _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id) payload = ComposerSavePayload.model_validate(console_ns.payload or {}) return dump_response( AgentAppComposerResponse, AgentComposerService.save_agent_app_composer( tenant_id=tenant_id, - app_id=app_model.id, + app_id=app_id, account_id=account_id, payload=payload, ), ) -@console_ns.route("/apps//agent-composer/validate") -class AgentAppComposerValidateApi(Resource): +@console_ns.route("/agent//composer/validate") +class AgentComposerValidateApi(Resource): @console_ns.expect(console_ns.models[ComposerSavePayload.__name__]) @console_ns.response( 200, "Agent app composer validation result", console_ns.models[AgentComposerValidateResponse.__name__] @@ -215,32 +228,36 @@ class AgentAppComposerValidateApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.AGENT]) @with_current_tenant_id - def post(self, tenant_id: str, app_model: App): + def post(self, tenant_id: str, agent_id: UUID): + _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id) payload = ComposerSavePayload.model_validate(console_ns.payload or {}) ComposerConfigValidator.validate_save_payload(payload) - findings = AgentComposerService.collect_validation_findings(tenant_id=tenant_id, payload=payload) + findings = AgentComposerService.collect_validation_findings( + tenant_id=tenant_id, + payload=payload, + agent_id=str(agent_id), + ) return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings}) -@console_ns.route("/apps//agent-composer/candidates") -class AgentAppComposerCandidatesApi(Resource): +@console_ns.route("/agent//composer/candidates") +class AgentComposerCandidatesApi(Resource): @console_ns.response( 200, "Agent app composer candidates", console_ns.models[AgentComposerCandidatesResponse.__name__] ) @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.AGENT]) @with_current_user_id @with_current_tenant_id - def get(self, tenant_id: str, current_user_id: str, app_model: App): + def get(self, tenant_id: str, current_user_id: str, agent_id: UUID): + app_id = _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id) return dump_response( AgentComposerCandidatesResponse, AgentComposerService.get_agent_app_candidates( tenant_id=tenant_id, - app_id=app_model.id, + app_id=app_id, user_id=current_user_id, ), ) diff --git a/api/controllers/console/agent/roster.py b/api/controllers/console/agent/roster.py index c305a816eec..9d64b141d9d 100644 --- a/api/controllers/console/agent/roster.py +++ b/api/controllers/console/agent/roster.py @@ -1,30 +1,55 @@ from uuid import UUID -from flask import request +from flask import abort, request from flask_restx import Resource -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator 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.agent.app_helpers import resolve_agent_app_model +from controllers.console.app.app import ( + AppDetailWithSite, + AppListQuery, + AppPagination, + AppPartial, + UpdateAppPayload, + _normalize_app_list_query_args, +) from controllers.console.wraps import ( account_initialization_required, + cloud_edition_billing_resource_check, edit_permission_required, + enterprise_license_required, setup_required, with_current_tenant_id, - with_current_user_id, + with_current_user, ) from extensions.ext_database import db from fields.agent_fields import ( AgentConfigSnapshotDetailResponse, AgentConfigSnapshotListResponse, AgentInviteOptionsResponse, + AgentLogListResponse, + AgentPublishedReferenceResponse, AgentRosterListResponse, - AgentRosterResponse, + AgentStatisticSummaryEnvelopeResponse, ) +from libs.datetime_utils import parse_time_range from libs.helper import dump_response from libs.login import login_required +from models import Account +from models.model import IconType +from services.agent.errors import AgentNotFoundError +from services.agent.observability_service import ( + AgentLogQueryParams, + AgentObservabilityService, + AgentStatisticsQueryParams, +) from services.agent.roster_service import AgentRosterService -from services.entities.agent_entities import RosterAgentCreatePayload, RosterAgentUpdatePayload, RosterListQuery +from services.app_service import AppListParams, AppService, CreateAppParams +from services.enterprise.enterprise_service import EnterpriseService +from services.entities.agent_entities import RosterListQuery +from services.feature_service import FeatureService class AgentInviteOptionsQuery(RosterListQuery): @@ -35,21 +60,100 @@ class AgentIdPath(BaseModel): agent_id: str +class AgentAppCreatePayload(BaseModel): + name: str = Field(..., min_length=1, description="Agent name") + description: str | None = Field(default=None, description="Agent description (max 400 chars)", max_length=400) + role: str = Field(default="", description="Agent role", max_length=255) + icon_type: IconType | None = Field(default=None, description="Icon type") + icon: str | None = Field(default=None, description="Icon") + icon_background: str | None = Field(default=None, description="Icon background color") + + +class AgentAppUpdatePayload(UpdateAppPayload): + role: str | None = Field(default=None, description="Agent role", max_length=255) + + +class AgentAppPublishedReferenceResponse(BaseModel): + app_id: str + app_name: str + app_icon_type: str | None = None + app_icon: str | None = None + app_icon_background: str | None = None + + +class AgentAppPartial(AppPartial): + published_reference_count: int = 0 + published_references: list[AgentAppPublishedReferenceResponse] = Field(default_factory=list) + + +class AgentAppPagination(BaseModel): + page: int + limit: int + total: int + has_more: bool + data: list[AgentAppPartial] + + +class AgentLogsQuery(BaseModel): + page: int = Field(default=1, ge=1, description="Page number") + limit: int = Field(default=20, ge=1, le=100, description="Page size") + keyword: str | None = Field(default=None, description="Search query, answer, or conversation name") + status: str | None = Field(default=None, description="Filter by success, failed, or paused") + source: str | None = Field( + default=None, + description="Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger", + ) + start: str | None = Field(default=None, description="Start date (YYYY-MM-DD HH:MM)") + end: str | None = Field(default=None, description="End date (YYYY-MM-DD HH:MM)") + + @field_validator("keyword", "status", "source", "start", "end", mode="before") + @classmethod + def empty_string_to_none(cls, value: str | None) -> str | None: + if value == "": + return None + return value + + +class AgentStatisticsQuery(BaseModel): + source: str | None = Field( + default=None, + description="Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger", + ) + start: str | None = Field(default=None, description="Start date (YYYY-MM-DD HH:MM)") + end: str | None = Field(default=None, description="End date (YYYY-MM-DD HH:MM)") + + @field_validator("source", "start", "end", mode="before") + @classmethod + def empty_string_to_none(cls, value: str | None) -> str | None: + if value == "": + return None + return value + + register_schema_models( console_ns, + AgentAppCreatePayload, + AgentAppUpdatePayload, AgentInviteOptionsQuery, + AgentLogsQuery, + AgentStatisticsQuery, AgentIdPath, - RosterAgentCreatePayload, - RosterAgentUpdatePayload, + AppListQuery, + UpdateAppPayload, RosterListQuery, ) register_response_schema_models( console_ns, + AppDetailWithSite, + AgentAppPagination, + AgentAppPublishedReferenceResponse, AgentConfigSnapshotDetailResponse, AgentConfigSnapshotListResponse, AgentInviteOptionsResponse, + AgentLogListResponse, + AgentPublishedReferenceResponse, AgentRosterListResponse, - AgentRosterResponse, + AgentStatisticSummaryEnvelopeResponse, ) @@ -57,42 +161,194 @@ def _agent_roster_service() -> AgentRosterService: return AgentRosterService(db.session) -@console_ns.route("/agents") -class AgentRosterListApi(Resource): - @console_ns.doc(params=query_params_from_model(RosterListQuery)) - @console_ns.response(200, "Agent roster list", console_ns.models[AgentRosterListResponse.__name__]) +def _serialize_agent_app_detail(app_model) -> dict: + app_model = AppService().get_app(app_model) + if FeatureService.get_system_features().webapp_auth.enabled: + app_setting = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=str(app_model.id)) + app_model.access_mode = app_setting.access_mode # type: ignore[attr-defined] + + roster_service = _agent_roster_service() + agent = roster_service.get_app_backing_agent(tenant_id=app_model.tenant_id, app_id=app_model.id) + if not agent: + raise AgentNotFoundError() + payload = AppDetailWithSite.model_validate(app_model, from_attributes=True).model_dump(mode="json") + payload.pop("bound_agent_id", None) + payload["app_id"] = str(app_model.id) + payload["id"] = agent.id + payload["role"] = agent.role or "" + payload["active_config_is_published"] = roster_service.active_config_is_published( + tenant_id=app_model.tenant_id, + agent=agent, + ) + return payload + + +def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str) -> dict: + app_ids = [str(app.id) for app in app_pagination.items] + roster_service = _agent_roster_service() + agents_by_app_id = roster_service.load_app_backing_agents_by_app_id( + tenant_id=tenant_id, + app_ids=app_ids, + ) + active_config_is_published_by_agent_id = roster_service.load_active_config_is_published_by_agent_id( + tenant_id=tenant_id, + agents=list(agents_by_app_id.values()), + ) + published_references_by_agent_id = roster_service.load_published_references_by_agent_id( + tenant_id=tenant_id, + agent_ids=[agent.id for agent in agents_by_app_id.values()], + ) + payload = AppPagination.model_validate(app_pagination, from_attributes=True).model_dump(mode="json") + for item in payload["data"]: + app_id = item["id"] + item.pop("bound_agent_id", None) + agent = agents_by_app_id.get(app_id) + if agent: + item["app_id"] = app_id + item["id"] = agent.id + item["role"] = agent.role or "" + item["active_config_is_published"] = active_config_is_published_by_agent_id.get(agent.id, False) + published_references = published_references_by_agent_id.get(agent.id, []) + item["published_reference_count"] = len(published_references) + item["published_references"] = [ + { + "app_id": reference["app_id"], + "app_name": reference["app_name"], + "app_icon_type": reference["app_icon_type"], + "app_icon": reference["app_icon"], + "app_icon_background": reference["app_icon_background"], + } + for reference in published_references + ] + return AgentAppPagination.model_validate(payload).model_dump( + mode="json", + exclude={"data": {"__all__": {"bound_agent_id"}}}, + ) + + +def _resolve_agent_app_model(*, tenant_id: str, agent_id: UUID): + return resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + + +def _agent_observability_service() -> AgentObservabilityService: + return AgentObservabilityService(db.session) + + +def _parse_observability_time_range(start: str | None, end: str | None, account: Account): + timezone = account.timezone or "UTC" + try: + return parse_time_range(start, end, timezone) + except ValueError as exc: + abort(400, description=str(exc)) + + +@console_ns.route("/agent") +class AgentAppListApi(Resource): + @console_ns.doc(params=query_params_from_model(AppListQuery)) + @console_ns.response(200, "Agent app list", console_ns.models[AgentAppPagination.__name__]) @setup_required @login_required @account_initialization_required + @with_current_user @with_current_tenant_id - def get(self, tenant_id: str): - query = RosterListQuery.model_validate(request.args.to_dict(flat=True)) - return dump_response( - AgentRosterListResponse, - _agent_roster_service().list_roster_agents( - tenant_id=tenant_id, page=query.page, limit=query.limit, keyword=query.keyword - ), + def get(self, current_tenant_id: str, current_user: Account): + args = AppListQuery.model_validate(_normalize_app_list_query_args(request.args)) + params = AppListParams( + page=args.page, + limit=args.limit, + mode="agent", + name=args.name, + tag_ids=args.tag_ids, + creator_ids=args.creator_ids, + is_created_by_me=args.is_created_by_me, + status="normal", ) - @console_ns.expect(console_ns.models[RosterAgentCreatePayload.__name__]) - @console_ns.response(201, "Agent created", console_ns.models[AgentRosterResponse.__name__]) + app_pagination = AppService().get_paginate_apps(current_user.id, current_tenant_id, params) + if app_pagination is None: + empty = AgentAppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[]) + return empty.model_dump(mode="json") + + return _serialize_agent_app_pagination(app_pagination, tenant_id=current_tenant_id) + + @console_ns.expect(console_ns.models[AgentAppCreatePayload.__name__]) + @console_ns.response(201, "Agent app created successfully", console_ns.models[AppDetailWithSite.__name__]) + @console_ns.response(403, "Insufficient permissions") + @console_ns.response(400, "Invalid request parameters") + @setup_required + @login_required + @account_initialization_required + @cloud_edition_billing_resource_check("apps") + @edit_permission_required + @with_current_user + @with_current_tenant_id + def post(self, current_tenant_id: str, current_user: Account): + args = AgentAppCreatePayload.model_validate(console_ns.payload) + params = CreateAppParams( + name=args.name, + description=args.description, + mode="agent", + agent_role=args.role, + icon_type=args.icon_type, + icon=args.icon, + icon_background=args.icon_background, + ) + + app = AppService().create_app(current_tenant_id, params, current_user) + return _serialize_agent_app_detail(app), 201 + + +@console_ns.route("/agent/") +class AgentAppApi(Resource): + @console_ns.response(200, "Agent app detail", console_ns.models[AppDetailWithSite.__name__]) + @setup_required + @login_required + @account_initialization_required + @enterprise_license_required + @with_current_tenant_id + def get(self, tenant_id: str, agent_id: UUID): + app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + return _serialize_agent_app_detail(app_model) + + @console_ns.expect(console_ns.models[AgentAppUpdatePayload.__name__]) + @console_ns.response(200, "Agent app updated successfully", console_ns.models[AppDetailWithSite.__name__]) + @console_ns.response(403, "Insufficient permissions") + @console_ns.response(400, "Invalid request parameters") @setup_required @login_required @account_initialization_required @edit_permission_required - @with_current_user_id @with_current_tenant_id - def post(self, tenant_id: str, account_id: str): - payload = RosterAgentCreatePayload.model_validate(console_ns.payload or {}) - service = _agent_roster_service() - agent = service.create_roster_agent(tenant_id=tenant_id, account_id=account_id, payload=payload) - return dump_response( - AgentRosterResponse, - service.get_roster_agent_detail(tenant_id=tenant_id, agent_id=agent.id), - ), 201 + def put(self, tenant_id: str, agent_id: UUID): + app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + args = AgentAppUpdatePayload.model_validate(console_ns.payload) + args_dict: AppService.ArgsDict = { + "name": args.name, + "description": args.description or "", + "icon_type": args.icon_type, + "icon": args.icon or "", + "icon_background": args.icon_background or "", + "use_icon_as_answer_icon": args.use_icon_as_answer_icon or False, + "max_active_requests": args.max_active_requests or 0, + "role": args.role, + } + updated = AppService().update_app(app_model, args_dict) + return _serialize_agent_app_detail(updated) + + @console_ns.response(204, "Agent app deleted successfully") + @console_ns.response(403, "Insufficient permissions") + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @with_current_tenant_id + def delete(self, tenant_id: str, agent_id: UUID): + app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + AppService().delete_app(app_model) + return "", 204 -@console_ns.route("/agents/invite-options") +@console_ns.route("/agent/invite-options") class AgentInviteOptionsApi(Resource): @console_ns.doc(params=query_params_from_model(AgentInviteOptionsQuery)) @console_ns.response(200, "Agent invite options", console_ns.models[AgentInviteOptionsResponse.__name__]) @@ -114,49 +370,66 @@ class AgentInviteOptionsApi(Resource): ) -@console_ns.route("/agents/") -class AgentRosterDetailApi(Resource): - @console_ns.response(200, "Agent detail", console_ns.models[AgentRosterResponse.__name__]) +@console_ns.route("/agent//logs") +class AgentLogsApi(Resource): + @console_ns.doc(params=query_params_from_model(AgentLogsQuery)) + @console_ns.response(200, "Agent logs", console_ns.models[AgentLogListResponse.__name__]) @setup_required @login_required @account_initialization_required + @with_current_user @with_current_tenant_id - def get(self, tenant_id: str, agent_id: UUID): - return dump_response( - AgentRosterResponse, - _agent_roster_service().get_roster_agent_detail(tenant_id=tenant_id, agent_id=str(agent_id)), - ) + def get(self, tenant_id: str, current_user: Account, agent_id: UUID): + app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + query = AgentLogsQuery.model_validate(request.args.to_dict(flat=True)) + start, end = _parse_observability_time_range(query.start, query.end, current_user) + try: + payload = _agent_observability_service().list_logs( + app=app_model, + params=AgentLogQueryParams( + page=query.page, + limit=query.limit, + keyword=query.keyword, + status=query.status, + source=query.source, + start=start, + end=end, + ), + ) + except ValueError as exc: + abort(400, description=str(exc)) + return dump_response(AgentLogListResponse, payload) - @console_ns.expect(console_ns.models[RosterAgentUpdatePayload.__name__]) - @console_ns.response(200, "Agent updated", console_ns.models[AgentRosterResponse.__name__]) + +@console_ns.route("/agent//statistics/summary") +class AgentStatisticsSummaryApi(Resource): + @console_ns.doc(params=query_params_from_model(AgentStatisticsQuery)) + @console_ns.response( + 200, + "Agent monitoring summary and chart data", + console_ns.models[AgentStatisticSummaryEnvelopeResponse.__name__], + ) @setup_required @login_required @account_initialization_required - @edit_permission_required - @with_current_user_id + @with_current_user @with_current_tenant_id - def patch(self, tenant_id: str, account_id: str, agent_id: UUID): - payload = RosterAgentUpdatePayload.model_validate(console_ns.payload or {}) - return dump_response( - AgentRosterResponse, - _agent_roster_service().update_roster_agent( - tenant_id=tenant_id, agent_id=str(agent_id), account_id=account_id, payload=payload - ), - ) - - @console_ns.response(204, "Agent archived") - @setup_required - @login_required - @account_initialization_required - @edit_permission_required - @with_current_user_id - @with_current_tenant_id - def delete(self, tenant_id: str, account_id: str, agent_id: UUID): - _agent_roster_service().archive_roster_agent(tenant_id=tenant_id, agent_id=str(agent_id), account_id=account_id) - return "", 204 + def get(self, tenant_id: str, current_user: Account, agent_id: UUID): + app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + query = AgentStatisticsQuery.model_validate(request.args.to_dict(flat=True)) + timezone = current_user.timezone or "UTC" + start, end = _parse_observability_time_range(query.start, query.end, current_user) + try: + payload = _agent_observability_service().get_statistics_summary( + app=app_model, + params=AgentStatisticsQueryParams(source=query.source, start=start, end=end, timezone=timezone), + ) + except ValueError as exc: + abort(400, description=str(exc)) + return dump_response(AgentStatisticSummaryEnvelopeResponse, payload) -@console_ns.route("/agents//versions") +@console_ns.route("/agent//versions") class AgentRosterVersionsApi(Resource): @console_ns.response(200, "Agent versions", console_ns.models[AgentConfigSnapshotListResponse.__name__]) @setup_required @@ -170,7 +443,7 @@ class AgentRosterVersionsApi(Resource): ) -@console_ns.route("/agents//versions/") +@console_ns.route("/agent//versions/") class AgentRosterVersionDetailApi(Resource): @console_ns.response(200, "Agent version detail", console_ns.models[AgentConfigSnapshotDetailResponse.__name__]) @setup_required diff --git a/api/controllers/console/app/advanced_prompt_template.py b/api/controllers/console/app/advanced_prompt_template.py index ad21671176f..90098739a45 100644 --- a/api/controllers/console/app/advanced_prompt_template.py +++ b/api/controllers/console/app/advanced_prompt_template.py @@ -1,9 +1,17 @@ +from typing import Any + from flask import request -from flask_restx import Resource, fields +from flask_restx import Resource from pydantic import BaseModel, Field +from controllers.common.schema import ( + DEFAULT_REF_TEMPLATE_OPENAPI_3_0, + query_params_from_model, + register_response_schema_models, +) from controllers.console import console_ns from controllers.console.wraps import account_initialization_required, setup_required +from fields.base import ResponseModel from libs.login import login_required from services.advanced_prompt_template_service import AdvancedPromptTemplateArgs, AdvancedPromptTemplateService @@ -15,19 +23,27 @@ class AdvancedPromptTemplateQuery(BaseModel): model_name: str = Field(..., description="Model name") +class AdvancedPromptTemplateResponse(ResponseModel): + chat_prompt_config: dict[str, Any] | None = Field(default=None) + completion_prompt_config: dict[str, Any] | None = Field(default=None) + + console_ns.schema_model( AdvancedPromptTemplateQuery.__name__, - AdvancedPromptTemplateQuery.model_json_schema(ref_template="#/definitions/{model}"), + AdvancedPromptTemplateQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0), ) +register_response_schema_models(console_ns, AdvancedPromptTemplateResponse) @console_ns.route("/app/prompt-templates") class AdvancedPromptTemplateList(Resource): @console_ns.doc("get_advanced_prompt_templates") @console_ns.doc(description="Get advanced prompt templates based on app mode and model configuration") - @console_ns.expect(console_ns.models[AdvancedPromptTemplateQuery.__name__]) + @console_ns.doc(params=query_params_from_model(AdvancedPromptTemplateQuery)) @console_ns.response( - 200, "Prompt templates retrieved successfully", fields.List(fields.Raw(description="Prompt template data")) + 200, + "Prompt templates retrieved successfully", + console_ns.models[AdvancedPromptTemplateResponse.__name__], ) @console_ns.response(400, "Invalid request parameters") @setup_required diff --git a/api/controllers/console/app/agent.py b/api/controllers/console/app/agent.py index 2c3adb6a018..23ccd28ad6f 100644 --- a/api/controllers/console/app/agent.py +++ b/api/controllers/console/app/agent.py @@ -1,22 +1,56 @@ -from flask import request -from flask_restx import Resource, fields -from pydantic import BaseModel, Field, field_validator +import logging +from typing import Any +from uuid import UUID -from controllers.common.schema import register_schema_models +from flask import request +from flask_restx import Resource +from pydantic import BaseModel, Field, field_validator +from sqlalchemy import select + +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.agent.app_helpers import resolve_agent_app_model from controllers.console.app.wraps import get_app_model -from controllers.console.wraps import account_initialization_required, setup_required, with_current_user +from controllers.console.wraps import ( + account_initialization_required, + setup_required, + with_current_tenant_id, + with_current_user, +) from extensions.ext_database import db +from fields.base import ResponseModel from libs.helper import uuid_value from libs.login import login_required from models import Account -from models.model import App, AppMode -from services.agent.skill_package_service import SkillPackageError, SkillPackageService +from models.agent_config_entities import AgentFileRefConfig, AgentSkillRefConfig +from models.model import App, AppMode, UploadFile +from services.agent.composer_service import AgentComposerService +from services.agent.skill_package_service import SkillManifest, SkillPackageError, SkillPackageService from services.agent.skill_standardize_service import SkillStandardizeService -from services.agent_drive_service import AgentDriveError +from services.agent.skill_tool_inference_service import ( + SkillToolInferenceError, + SkillToolInferenceResult, + SkillToolInferenceService, +) +from services.agent_drive_service import ( + AgentDriveError, + AgentDriveService, + DriveCommitItem, + DriveFileRef, + normalize_drive_key, +) from services.agent_service import AgentService from services.file_service import FileService +logger = logging.getLogger(__name__) + +_WORKFLOW_AGENT_DRIVE_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT] + class AgentLogQuery(BaseModel): message_id: str = Field(..., description="Message UUID") @@ -28,7 +62,302 @@ class AgentLogQuery(BaseModel): return uuid_value(value) -register_schema_models(console_ns, AgentLogQuery) +class AgentDriveFilePayload(BaseModel): + upload_file_id: str = Field(..., description="UploadFile UUID from POST /console/api/files/upload") + + @field_validator("upload_file_id") + @classmethod + def validate_upload_file_id(cls, value: str) -> str: + return uuid_value(value) + + +class AgentDriveMutationQuery(BaseModel): + node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)") + + +class AgentDriveDeleteFileQuery(AgentDriveMutationQuery): + key: str = Field(min_length=1, description="Drive key, e.g. files/sample.pdf") + + +class AgentDriveDeleteFileByAgentQuery(BaseModel): + key: str = Field(min_length=1, description="Drive key, e.g. files/sample.pdf") + + +class AgentLogMetaResponse(ResponseModel): + status: str + executor: str + start_time: str + elapsed_time: float | None = None + total_tokens: int + agent_mode: str + iterations: int + + +class AgentToolCallResponse(ResponseModel): + status: str + error: str | None = None + time_cost: float | int + tool_name: str + tool_label: str + tool_input: dict[str, Any] + tool_output: dict[str, Any] + tool_parameters: dict[str, Any] + tool_icon: Any = Field(default=None) + + +class AgentIterationLogResponse(ResponseModel): + tokens: int + tool_calls: list[AgentToolCallResponse] + tool_raw: dict[str, Any] + thought: str | None = None + created_at: str + files: list[Any] = Field(default_factory=list) + + +class AgentLogResponse(ResponseModel): + meta: AgentLogMetaResponse + iterations: list[AgentIterationLogResponse] + files: list[Any] = Field(default_factory=list) + + +class AgentSkillUploadResponse(ResponseModel): + skill: AgentSkillRefConfig + manifest: SkillManifest + + +class AgentSkillStandardizeResponse(ResponseModel): + skill: AgentSkillRefConfig + manifest: SkillManifest + + +class AgentDriveFileResponse(ResponseModel): + name: str + drive_key: str + file_id: str + size: int | None = None + mime_type: str | None = None + + +class AgentDriveFileCommitResponse(ResponseModel): + file: AgentDriveFileResponse + config_version_id: str | None = None + + +class AgentDriveDeleteResponse(ResponseModel): + result: str + removed_keys: list[str] = Field(default_factory=list) + config_version_id: str | None = None + + +register_schema_models(console_ns, AgentLogQuery, AgentDriveFilePayload, AgentDriveDeleteFileByAgentQuery) +register_response_schema_models( + console_ns, + AgentDriveDeleteResponse, + AgentDriveFileCommitResponse, + AgentDriveFileResponse, + AgentLogResponse, + AgentSkillStandardizeResponse, + AgentSkillUploadResponse, + SkillToolInferenceResult, +) + + +def _resolve_agent_id(app_model: App, node_id: str | None) -> str | None: + if node_id and app_model.mode != AppMode.AGENT: + return AgentComposerService.resolve_workflow_node_agent_id( + tenant_id=app_model.tenant_id, app_id=app_model.id, node_id=node_id + ) + return app_model.bound_agent_id + + +def _agent_not_bound() -> tuple[dict[str, str], int]: + return {"code": "agent_not_bound", "message": "no agent is bound for this app/node"}, 400 + + +def _upload_skill_for_app(*, current_user: Account): + if "file" not in request.files: + return {"code": "no_file", "message": "no skill file uploaded"}, 400 + if len(request.files) > 1: + return {"code": "too_many_files", "message": "only one skill file is allowed"}, 400 + + upload = request.files["file"] + content = upload.stream.read() + try: + manifest = SkillPackageService().validate_and_extract(content=content, filename=upload.filename or "") + except SkillPackageError as exc: + return {"code": exc.code, "message": exc.message}, exc.status_code + + upload_file = FileService(db.engine).upload_file( + filename=upload.filename or "skill.zip", + content=content, + mimetype=upload.mimetype or "application/zip", + user=current_user, + ) + skill_ref = manifest.to_skill_ref(file_id=upload_file.id) + return {"skill": skill_ref.model_dump(exclude_none=True), "manifest": manifest.model_dump()}, 201 + + +def _standardize_skill_for_app(*, current_user: Account, app_model: App): + query = query_params_from_request(AgentDriveMutationQuery) + agent_id = _resolve_agent_id(app_model, query.node_id) + if not agent_id: + return _agent_not_bound() + if "file" not in request.files: + return {"code": "no_file", "message": "no skill file uploaded"}, 400 + if len(request.files) > 1: + return {"code": "too_many_files", "message": "only one skill file is allowed"}, 400 + + upload = request.files["file"] + content = upload.stream.read() + try: + result = SkillStandardizeService().standardize( + content=content, + filename=upload.filename or "", + tenant_id=app_model.tenant_id, + user_id=current_user.id, + agent_id=agent_id, + ) + except (SkillPackageError, AgentDriveError) as exc: + return {"code": exc.code, "message": exc.message}, exc.status_code + return result, 201 + + +def _commit_drive_file_for_app(*, current_user: Account, app_model: App, allow_node_id: bool = True): + query = query_params_from_request(AgentDriveMutationQuery) + node_id = query.node_id if allow_node_id else None + agent_id = _resolve_agent_id(app_model, node_id) + if not agent_id: + return _agent_not_bound() + payload = AgentDriveFilePayload.model_validate(console_ns.payload or {}) + + upload_file = db.session.scalar( + select(UploadFile).where( + UploadFile.id == payload.upload_file_id, + UploadFile.tenant_id == app_model.tenant_id, + ) + ) + if upload_file is None: + return {"code": "upload_file_not_found", "message": "upload file not found in this workspace"}, 404 + + try: + key = normalize_drive_key(f"files/{upload_file.name}") + committed = AgentDriveService().commit( + tenant_id=app_model.tenant_id, + user_id=current_user.id, + agent_id=agent_id, + items=[ + DriveCommitItem( + key=key, + file_ref=DriveFileRef(kind="upload_file", id=upload_file.id), + # ADD FILE uploads exist solely to live in the drive, so the + # drive owns (and physically cleans) the value on delete. + value_owned_by_drive=True, + ) + ], + ) + except AgentDriveError as exc: + return {"code": exc.code, "message": exc.message}, exc.status_code + + row = committed[0] + file_ref = AgentFileRefConfig.model_validate( + { + "id": row["key"], + "name": upload_file.name, + "file_id": upload_file.id, + "drive_key": row["key"], + "type": row.get("mime_type"), + "size": row.get("size"), + } + ) + config_version_id = AgentComposerService.add_drive_file_ref( + tenant_id=app_model.tenant_id, + agent_id=agent_id, + account_id=current_user.id, + file_ref=file_ref, + app_id=app_model.id, + node_id=node_id, + ) + return { + "file": { + "name": upload_file.name, + "drive_key": row["key"], + "file_id": upload_file.id, + "size": row.get("size"), + "mime_type": row.get("mime_type"), + }, + "config_version_id": config_version_id, + }, 201 + + +def _delete_drive_file_for_app(*, current_user: Account, app_model: App, allow_node_id: bool = True): + query = query_params_from_request(AgentDriveDeleteFileQuery) + node_id = query.node_id if allow_node_id else None + agent_id = _resolve_agent_id(app_model, node_id) + if not agent_id: + return _agent_not_bound() + try: + key = normalize_drive_key(query.key) + except AgentDriveError as exc: + return {"code": exc.code, "message": exc.message}, exc.status_code + + config_version_id = AgentComposerService.remove_drive_refs( + tenant_id=app_model.tenant_id, + agent_id=agent_id, + account_id=current_user.id, + file_key=key, + app_id=app_model.id, + node_id=node_id, + ) + removed_keys: list[str] = [] + try: + removed_keys = AgentDriveService().delete(tenant_id=app_model.tenant_id, agent_id=agent_id, key=key) + except AgentDriveError as exc: + return {"code": exc.code, "message": exc.message}, exc.status_code + except Exception: + # Soul-first ordering: the ref is already gone; orphan KV rows are + # harmless and an idempotent DELETE retry cleans them. + logger.exception("agent drive delete failed for key %s (soul already updated)", key) + return {"result": "success", "removed_keys": removed_keys, "config_version_id": config_version_id} + + +def _delete_skill_for_app(*, current_user: Account, app_model: App, slug: str, allow_node_id: bool = True): + query = query_params_from_request(AgentDriveMutationQuery) + node_id = query.node_id if allow_node_id else None + agent_id = _resolve_agent_id(app_model, node_id) + if not agent_id: + return _agent_not_bound() + if "/" in slug or not slug.strip(): + return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400 + + config_version_id = AgentComposerService.remove_drive_refs( + tenant_id=app_model.tenant_id, + agent_id=agent_id, + account_id=current_user.id, + skill_slug=slug, + app_id=app_model.id, + node_id=node_id, + ) + removed_keys: list[str] = [] + try: + removed_keys = AgentDriveService().delete(tenant_id=app_model.tenant_id, agent_id=agent_id, prefix=f"{slug}/") + except AgentDriveError as exc: + return {"code": exc.code, "message": exc.message}, exc.status_code + except Exception: + logger.exception("agent drive delete failed for skill %s (soul already updated)", slug) + return {"result": "success", "removed_keys": removed_keys, "config_version_id": config_version_id} + + +def _infer_skill_tools_for_app(*, app_model: App, slug: str): + query = query_params_from_request(AgentDriveMutationQuery) + agent_id = _resolve_agent_id(app_model, query.node_id) + if not agent_id: + return _agent_not_bound() + if "/" in slug or not slug.strip(): + return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400 + try: + return SkillToolInferenceService().infer(tenant_id=app_model.tenant_id, agent_id=agent_id, slug=slug) + except SkillToolInferenceError as exc: + return {"code": exc.code, "message": exc.message}, exc.status_code @console_ns.route("/apps//agent/logs") @@ -36,10 +365,8 @@ class AgentLogApi(Resource): @console_ns.doc("get_agent_logs") @console_ns.doc(description="Get agent execution logs for an application") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.expect(console_ns.models[AgentLogQuery.__name__]) - @console_ns.response( - 200, "Agent logs retrieved successfully", fields.List(fields.Raw(description="Agent log entries")) - ) + @console_ns.doc(params=query_params_from_model(AgentLogQuery)) + @console_ns.response(200, "Agent logs retrieved successfully", console_ns.models[AgentLogResponse.__name__]) @console_ns.response(400, "Invalid request parameters") @setup_required @login_required @@ -52,17 +379,34 @@ class AgentLogApi(Resource): return AgentService.get_agent_logs(app_model, args.conversation_id, args.message_id) +@console_ns.route("/agent//skills/upload") +class AgentSkillUploadByAgentApi(Resource): + @console_ns.doc("upload_agent_skill_by_agent") + @console_ns.doc(description="Upload + validate a Skill package for an Agent App") + @console_ns.doc(params={"agent_id": "Agent ID"}) + @console_ns.response(201, "Skill validated", console_ns.models[AgentSkillUploadResponse.__name__]) + @console_ns.response(400, "Invalid skill package") + @setup_required + @login_required + @account_initialization_required + @with_current_user + @with_current_tenant_id + def post(self, tenant_id: str, current_user: Account, agent_id: UUID): + resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + return _upload_skill_for_app(current_user=current_user) + + @console_ns.route("/apps//agent/skills/upload") class AgentSkillUploadApi(Resource): @console_ns.doc("upload_agent_skill") @console_ns.doc(description="Upload + validate a Skill package (.zip/.skill) and extract its manifest") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.response(201, "Skill validated") + @console_ns.response(201, "Skill validated", console_ns.models[AgentSkillUploadResponse.__name__]) @console_ns.response(400, "Invalid skill package") @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.AGENT]) + @get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES) @with_current_user def post(self, current_user: Account, app_model: App): """Validate an uploaded Skill package and persist the archive. @@ -70,60 +414,194 @@ class AgentSkillUploadApi(Resource): Returns a validated skill ref (to bind into the Agent soul config on save) plus its manifest. Standardizing into the agent drive is ENG-594. """ - if "file" not in request.files: - return {"code": "no_file", "message": "no skill file uploaded"}, 400 - if len(request.files) > 1: - return {"code": "too_many_files", "message": "only one skill file is allowed"}, 400 + return _upload_skill_for_app(current_user=current_user) - upload = request.files["file"] - content = upload.stream.read() - try: - manifest = SkillPackageService().validate_and_extract(content=content, filename=upload.filename or "") - except SkillPackageError as exc: - return {"code": exc.code, "message": exc.message}, exc.status_code - upload_file = FileService(db.engine).upload_file( - filename=upload.filename or "skill.zip", - content=content, - mimetype=upload.mimetype or "application/zip", - user=current_user, - ) - skill_ref = manifest.to_skill_ref(file_id=upload_file.id) - return {"skill": skill_ref.model_dump(exclude_none=True), "manifest": manifest.model_dump()}, 201 +@console_ns.route("/agent//skills/standardize") +class AgentSkillStandardizeByAgentApi(Resource): + @console_ns.doc("standardize_agent_skill_by_agent") + @console_ns.doc(description="Validate + standardize a Skill into an Agent App drive") + @console_ns.doc(params={"agent_id": "Agent ID"}) + @console_ns.response( + 201, + "Skill standardized into drive", + console_ns.models[AgentSkillStandardizeResponse.__name__], + ) + @console_ns.response(400, "Invalid skill package or no bound agent") + @setup_required + @login_required + @account_initialization_required + @with_current_user + @with_current_tenant_id + def post(self, tenant_id: str, current_user: Account, agent_id: UUID): + app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + return _standardize_skill_for_app(current_user=current_user, app_model=app_model) @console_ns.route("/apps//agent/skills/standardize") class AgentSkillStandardizeApi(Resource): @console_ns.doc("standardize_agent_skill") @console_ns.doc(description="Validate + standardize a Skill into the agent drive (ENG-594)") - @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.response(201, "Skill standardized into drive") + @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveMutationQuery)}) + @console_ns.response( + 201, + "Skill standardized into drive", + console_ns.models[AgentSkillStandardizeResponse.__name__], + ) @console_ns.response(400, "Invalid skill package or no bound agent") @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.AGENT]) + @get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES) @with_current_user def post(self, current_user: Account, app_model: App): """Upload a Skill, validate it, and standardize it into the app agent's drive.""" - agent_id = app_model.bound_agent_id - if not agent_id: - return {"code": "no_bound_agent", "message": "app has no bound agent"}, 400 - if "file" not in request.files: - return {"code": "no_file", "message": "no skill file uploaded"}, 400 - if len(request.files) > 1: - return {"code": "too_many_files", "message": "only one skill file is allowed"}, 400 + return _standardize_skill_for_app(current_user=current_user, app_model=app_model) - upload = request.files["file"] - content = upload.stream.read() - try: - result = SkillStandardizeService().standardize( - content=content, - filename=upload.filename or "", - tenant_id=app_model.tenant_id, - user_id=current_user.id, - agent_id=agent_id, - ) - except (SkillPackageError, AgentDriveError) as exc: - return {"code": exc.code, "message": exc.message}, exc.status_code - return result, 201 + +@console_ns.route("/agent//files") +class AgentDriveFilesByAgentApi(Resource): + @console_ns.doc("commit_agent_drive_file_by_agent") + @console_ns.doc(description="Commit an uploaded file into the Agent App drive under files/") + @console_ns.doc(params={"agent_id": "Agent ID"}) + @console_ns.expect(console_ns.models[AgentDriveFilePayload.__name__]) + @console_ns.response( + 201, "File committed into the agent drive", console_ns.models[AgentDriveFileCommitResponse.__name__] + ) + @setup_required + @login_required + @account_initialization_required + @with_current_user + @with_current_tenant_id + def post(self, tenant_id: str, current_user: Account, agent_id: UUID): + app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + return _commit_drive_file_for_app(current_user=current_user, app_model=app_model, allow_node_id=False) + + @console_ns.doc("delete_agent_drive_file_by_agent") + @console_ns.doc(description="Delete one Agent App drive file by key") + @console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveDeleteFileByAgentQuery)}) + @console_ns.response(200, "File removed", console_ns.models[AgentDriveDeleteResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_user + @with_current_tenant_id + def delete(self, tenant_id: str, current_user: Account, agent_id: UUID): + app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + return _delete_drive_file_for_app(current_user=current_user, app_model=app_model, allow_node_id=False) + + +@console_ns.route("/apps//agent/files") +class AgentDriveFilesApi(Resource): + @console_ns.doc("commit_agent_drive_file") + @console_ns.doc(description="Commit an uploaded file into the agent drive under files/ (ENG-625 D3)") + @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveMutationQuery)}) + @console_ns.expect(console_ns.models[AgentDriveFilePayload.__name__]) + @console_ns.response( + 201, "File committed into the agent drive", console_ns.models[AgentDriveFileCommitResponse.__name__] + ) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES) + @with_current_user + def post(self, current_user: Account, app_model: App): + """ADD FILE: commit one uploaded file into the bound agent's drive.""" + return _commit_drive_file_for_app(current_user=current_user, app_model=app_model) + + @console_ns.doc("delete_agent_drive_file") + @console_ns.doc(description="Delete one drive file by key; soul ref first, then the KV row (ENG-625 D5)") + @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveDeleteFileQuery)}) + @console_ns.response(200, "File removed", console_ns.models[AgentDriveDeleteResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES) + @with_current_user + def delete(self, current_user: Account, app_model: App): + return _delete_drive_file_for_app(current_user=current_user, app_model=app_model) + + +@console_ns.route("/agent//skills/") +class AgentSkillByAgentApi(Resource): + @console_ns.doc("delete_agent_skill_by_agent") + @console_ns.doc(description="Delete a standardized skill from an Agent App drive") + @console_ns.doc(params={"agent_id": "Agent ID", "slug": "Skill slug (single path segment)"}) + @console_ns.response(200, "Skill removed", console_ns.models[AgentDriveDeleteResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_user + @with_current_tenant_id + def delete(self, tenant_id: str, current_user: Account, agent_id: UUID, slug: str): + app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + return _delete_skill_for_app(current_user=current_user, app_model=app_model, slug=slug, allow_node_id=False) + + +@console_ns.route("/apps//agent/skills/") +class AgentSkillApi(Resource): + @console_ns.doc("delete_agent_skill") + @console_ns.doc( + description="Delete a standardized skill: soul ref first, then the / drive prefix (ENG-625 D5)" + ) + @console_ns.doc( + params={ + "app_id": "Application ID", + "slug": "Skill slug (single path segment)", + **query_params_from_model(AgentDriveMutationQuery), + } + ) + @console_ns.response(200, "Skill removed", console_ns.models[AgentDriveDeleteResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES) + @with_current_user + def delete(self, current_user: Account, app_model: App, slug: str): + return _delete_skill_for_app(current_user=current_user, app_model=app_model, slug=slug) + + +@console_ns.route("/agent//skills//infer-tools") +class AgentSkillInferToolsByAgentApi(Resource): + @console_ns.doc("infer_agent_skill_tools_by_agent") + @console_ns.doc(description="Infer CLI tool + ENV suggestions from a standardized Agent App skill") + @console_ns.doc(params={"agent_id": "Agent ID", "slug": "Skill slug (single path segment)"}) + @console_ns.response( + 200, + "Inference result (draft suggestions, nothing persisted)", + console_ns.models[SkillToolInferenceResult.__name__], + ) + @setup_required + @login_required + @account_initialization_required + @with_current_tenant_id + def post(self, tenant_id: str, agent_id: UUID, slug: str): + app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + return _infer_skill_tools_for_app(app_model=app_model, slug=slug) + + +@console_ns.route("/apps//agent/skills//infer-tools") +class AgentSkillInferToolsApi(Resource): + @console_ns.doc("infer_agent_skill_tools") + @console_ns.doc( + description="Infer CLI tool + ENV suggestions from a standardized skill's SKILL.md (draft only, ENG-371)" + ) + @console_ns.doc( + params={ + "app_id": "Application ID", + "slug": "Skill slug (single path segment)", + **query_params_from_model(AgentDriveMutationQuery), + } + ) + @console_ns.response( + 200, + "Inference result (draft suggestions, nothing persisted)", + console_ns.models[SkillToolInferenceResult.__name__], + ) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES) + def post(self, app_model: App, slug: str): + """Suggest CLI tools/env for a skill. Saving still goes through composer validation.""" + return _infer_skill_tools_for_app(app_model=app_model, slug=slug) diff --git a/api/controllers/console/app/agent_app_access.py b/api/controllers/console/app/agent_app_access.py index 97ff1344901..4e79beac594 100644 --- a/api/controllers/console/app/agent_app_access.py +++ b/api/controllers/console/app/agent_app_access.py @@ -5,25 +5,31 @@ reference. This exposes the read-only "Workflow access" surface from the PRD: which workflow apps use this Agent, without leaking the workflows' internals. """ +from uuid import UUID + from flask_restx import Resource from pydantic import Field from controllers.common.schema import register_response_schema_models from controllers.console import console_ns -from controllers.console.app.wraps import get_app_model +from controllers.console.agent.app_helpers import resolve_agent_app_model from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id from extensions.ext_database import db from fields.base import ResponseModel from libs.login import login_required -from models.model import App, AppMode from services.agent.roster_service import AgentRosterService class AgentReferencingWorkflowResponse(ResponseModel): app_id: str app_name: str + app_icon_type: str | None = None + app_icon: str | None = None + app_icon_background: str | None = None app_mode: str + app_updated_at: int | None = None workflow_id: str + workflow_version: str node_ids: list[str] = Field(default_factory=list) @@ -34,23 +40,23 @@ class AgentReferencingWorkflowsResponse(ResponseModel): register_response_schema_models(console_ns, AgentReferencingWorkflowsResponse) -@console_ns.route("/apps//agent-referencing-workflows") +@console_ns.route("/agent//referencing-workflows") class AgentAppReferencingWorkflowsResource(Resource): @console_ns.doc("list_agent_app_referencing_workflows") @console_ns.doc(description="List workflow apps that reference this Agent App's bound Agent (read-only)") - @console_ns.doc(params={"app_id": "Application ID"}) + @console_ns.doc(params={"agent_id": "Agent ID"}) @console_ns.response( 200, "Referencing workflows listed successfully", console_ns.models[AgentReferencingWorkflowsResponse.__name__], ) - @console_ns.response(404, "App not found") + @console_ns.response(404, "Agent not found") @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.AGENT]) @with_current_tenant_id - def get(self, tenant_id: str, app_model: App): + def get(self, tenant_id: str, agent_id: UUID): + app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) workflows = AgentRosterService(db.session).list_workflows_referencing_app_agent( tenant_id=tenant_id, app_id=app_model.id ) diff --git a/api/controllers/console/app/agent_app_feature.py b/api/controllers/console/app/agent_app_feature.py index 104fc391d51..79d7589873c 100644 --- a/api/controllers/console/app/agent_app_feature.py +++ b/api/controllers/console/app/agent_app_feature.py @@ -9,17 +9,20 @@ persists them onto the app's ``app_model_config`` without touching anything the Soul owns. """ +from uuid import UUID + from flask_restx import Resource from pydantic import BaseModel, Field 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.app.wraps import get_app_model +from controllers.console.agent.app_helpers import resolve_agent_app_model from controllers.console.wraps import ( account_initialization_required, edit_permission_required, setup_required, + with_current_tenant_id, with_current_user, ) from events.app_event import app_model_config_was_updated @@ -32,7 +35,6 @@ from models.agent_config_entities import ( AgentSuggestedQuestionsAfterAnswerFeatureConfig, AgentTextToSpeechFeatureConfig, ) -from models.model import App, AppMode from services.agent_app_feature_service import AgentAppFeatureConfigService @@ -64,22 +66,23 @@ register_schema_models(console_ns, AgentAppFeaturesPayload) register_response_schema_models(console_ns, SimpleResultResponse) -@console_ns.route("/apps//agent-features") +@console_ns.route("/agent//features") class AgentAppFeatureConfigResource(Resource): @console_ns.doc("update_agent_app_features") @console_ns.doc(description="Update an Agent App's presentation features (opener, follow-up, citations, ...)") - @console_ns.doc(params={"app_id": "Application ID"}) + @console_ns.doc(params={"agent_id": "Agent ID"}) @console_ns.expect(console_ns.models[AgentAppFeaturesPayload.__name__]) @console_ns.response(200, "Features updated successfully", console_ns.models[SimpleResultResponse.__name__]) @console_ns.response(400, "Invalid configuration") - @console_ns.response(404, "App not found") + @console_ns.response(404, "Agent not found") @setup_required @login_required @edit_permission_required @account_initialization_required - @get_app_model(mode=[AppMode.AGENT]) @with_current_user - def post(self, current_user: Account, app_model: App): + @with_current_tenant_id + def post(self, tenant_id: str, current_user: Account, agent_id: UUID): + app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) args = AgentAppFeaturesPayload.model_validate(console_ns.payload or {}) new_app_model_config = AgentAppFeatureConfigService.update_features( diff --git a/api/controllers/console/app/agent_app_sandbox.py b/api/controllers/console/app/agent_app_sandbox.py new file mode 100644 index 00000000000..f9bda13c63a --- /dev/null +++ b/api/controllers/console/app/agent_app_sandbox.py @@ -0,0 +1,307 @@ +"""Console routes for Agent App and workflow Agent sandbox file access. + +The API keeps product-facing locators (conversation or workflow node identity) +on this public boundary and proxies list/read/upload to the agent backend's new +``/sandbox`` contract. +""" + +from __future__ import annotations + +from typing import Literal +from uuid import UUID + +from dify_agent.client import DifyAgentClientError, DifyAgentHTTPError, DifyAgentTimeoutError +from flask import request +from flask_restx import Resource +from pydantic import BaseModel, Field + +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.agent.app_helpers import resolve_agent_app_model +from controllers.console.app.wraps import get_app_model +from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id +from fields.base import ResponseModel +from libs.login import login_required +from models.model import App, AppMode +from services.agent_app_sandbox_service import ( + AgentAppSandboxService, + AgentSandboxInspectorError, + WorkflowAgentSandboxService, +) + +_NODE_EXECUTION_ID_DESCRIPTION = ( + "Optional workflow node execution ID. When omitted, the latest active session for the node is used." +) + + +class AgentSandboxListQuery(BaseModel): + conversation_id: str = Field(min_length=1, description="Agent App conversation ID") + path: str = Field(default=".", description="Directory path relative to the sandbox workspace") + + +class AgentSandboxFileQuery(BaseModel): + conversation_id: str = Field(min_length=1, description="Agent App conversation ID") + path: str = Field(min_length=1, description="File path relative to the sandbox workspace") + + +class AgentSandboxUploadPayload(BaseModel): + conversation_id: str = Field(min_length=1, description="Agent App conversation ID") + path: str = Field(min_length=1, description="File path relative to the sandbox workspace") + + +class WorkflowAgentSandboxListQuery(BaseModel): + path: str = Field(default=".", description="Directory path relative to the sandbox workspace") + node_execution_id: str | None = Field( + default=None, + description=_NODE_EXECUTION_ID_DESCRIPTION, + ) + + +class WorkflowAgentSandboxFileQuery(BaseModel): + path: str = Field(min_length=1, description="File path relative to the sandbox workspace") + node_execution_id: str | None = Field( + default=None, + description=_NODE_EXECUTION_ID_DESCRIPTION, + ) + + +class WorkflowAgentSandboxUploadPayload(BaseModel): + path: str = Field(min_length=1, description="File path relative to the sandbox workspace") + node_execution_id: str | None = Field( + default=None, + description=_NODE_EXECUTION_ID_DESCRIPTION, + ) + + +class SandboxFileEntryResponse(ResponseModel): + name: str + type: Literal["file", "dir", "symlink", "other"] + size: int | None = None + mtime: int | None = None + + +class SandboxListResponse(ResponseModel): + path: str + entries: list[SandboxFileEntryResponse] = Field(default_factory=list) + truncated: bool = False + + +class SandboxReadResponse(ResponseModel): + path: str + size: int | None = None + truncated: bool + binary: bool + text: str | None = None + + +class SandboxToolFileResponse(ResponseModel): + transfer_method: Literal["tool_file"] = "tool_file" + reference: str + + +class SandboxUploadResponse(ResponseModel): + path: str + file: SandboxToolFileResponse + + +register_schema_models( + console_ns, + AgentSandboxUploadPayload, + WorkflowAgentSandboxUploadPayload, +) +register_response_schema_models(console_ns, SandboxListResponse, SandboxReadResponse, SandboxUploadResponse) + + +def _handle(exc: Exception) -> tuple[dict[str, object], int]: + if isinstance(exc, AgentSandboxInspectorError): + return {"code": exc.code, "message": exc.message}, exc.status_code + if isinstance(exc, DifyAgentHTTPError): + detail = exc.detail + if isinstance(detail, dict): + return { + "code": detail.get("code", "agent_backend_error"), + "message": detail.get("message", str(exc)), + }, exc.status_code + return {"code": "agent_backend_error", "message": str(detail)}, exc.status_code + if isinstance(exc, DifyAgentTimeoutError | DifyAgentClientError): + return {"code": "agent_backend_unreachable", "message": str(exc)}, 502 + raise exc + + +@console_ns.route("/agent//sandbox/files") +class AgentAppSandboxListResource(Resource): + @console_ns.doc("list_agent_app_sandbox_files") + @console_ns.doc(description="List a directory in an Agent App conversation sandbox") + @console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentSandboxListQuery)}) + @console_ns.response(200, "Listing returned", console_ns.models[SandboxListResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_tenant_id + def get(self, tenant_id: str, agent_id: UUID): + app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + query = query_params_from_request(AgentSandboxListQuery) + try: + result = AgentAppSandboxService().list_files( + tenant_id=tenant_id, + app_id=app_model.id, + conversation_id=query.conversation_id, + path=query.path, + ) + except Exception as exc: + return _handle(exc) + return result.model_dump() + + +@console_ns.route("/agent//sandbox/files/read") +class AgentAppSandboxReadResource(Resource): + @console_ns.doc("read_agent_app_sandbox_file") + @console_ns.doc(description="Read a text/binary preview file in an Agent App conversation sandbox") + @console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentSandboxFileQuery)}) + @console_ns.response(200, "Preview returned", console_ns.models[SandboxReadResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_tenant_id + def get(self, tenant_id: str, agent_id: UUID): + app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + query = query_params_from_request(AgentSandboxFileQuery) + try: + result = AgentAppSandboxService().read_file( + tenant_id=tenant_id, + app_id=app_model.id, + conversation_id=query.conversation_id, + path=query.path, + ) + except Exception as exc: + return _handle(exc) + return result.model_dump() + + +@console_ns.route("/agent//sandbox/files/upload") +class AgentAppSandboxUploadResource(Resource): + @console_ns.doc("upload_agent_app_sandbox_file") + @console_ns.doc(description="Upload one Agent App sandbox file as a Dify ToolFile mapping") + @console_ns.expect(console_ns.models[AgentSandboxUploadPayload.__name__]) + @console_ns.response(200, "Uploaded", console_ns.models[SandboxUploadResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_tenant_id + def post(self, tenant_id: str, agent_id: UUID): + app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + payload = AgentSandboxUploadPayload.model_validate(request.get_json(silent=True) or {}) + try: + result = AgentAppSandboxService().upload_file( + tenant_id=tenant_id, + app_id=app_model.id, + conversation_id=payload.conversation_id, + path=payload.path, + ) + except Exception as exc: + return _handle(exc) + return result.model_dump() + + +@console_ns.route("/apps//workflow-runs//agent-nodes//sandbox/files") +class WorkflowAgentSandboxListResource(Resource): + @console_ns.doc("list_workflow_agent_sandbox_files") + @console_ns.doc(description="List a directory in a workflow Agent node sandbox") + @console_ns.doc( + params={ + "app_id": "Application ID", + "workflow_run_id": "Workflow run ID", + "node_id": "Workflow Agent node ID", + **query_params_from_model(WorkflowAgentSandboxListQuery), + } + ) + @console_ns.response(200, "Listing returned", console_ns.models[SandboxListResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) + @with_current_tenant_id + def get(self, tenant_id: str, app_model: App, workflow_run_id: UUID, node_id: str): + query = query_params_from_request(WorkflowAgentSandboxListQuery) + try: + result = WorkflowAgentSandboxService().list_files( + tenant_id=tenant_id, + app_id=app_model.id, + workflow_run_id=str(workflow_run_id), + node_id=node_id, + node_execution_id=query.node_execution_id, + path=query.path, + ) + except Exception as exc: + return _handle(exc) + return result.model_dump() + + +@console_ns.route( + "/apps//workflow-runs//agent-nodes//sandbox/files/read" +) +class WorkflowAgentSandboxReadResource(Resource): + @console_ns.doc("read_workflow_agent_sandbox_file") + @console_ns.doc(description="Read a text/binary preview file in a workflow Agent node sandbox") + @console_ns.doc( + params={ + "app_id": "Application ID", + "workflow_run_id": "Workflow run ID", + "node_id": "Workflow Agent node ID", + **query_params_from_model(WorkflowAgentSandboxFileQuery), + } + ) + @console_ns.response(200, "Preview returned", console_ns.models[SandboxReadResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) + @with_current_tenant_id + def get(self, tenant_id: str, app_model: App, workflow_run_id: UUID, node_id: str): + query = query_params_from_request(WorkflowAgentSandboxFileQuery) + try: + result = WorkflowAgentSandboxService().read_file( + tenant_id=tenant_id, + app_id=app_model.id, + workflow_run_id=str(workflow_run_id), + node_id=node_id, + node_execution_id=query.node_execution_id, + path=query.path, + ) + except Exception as exc: + return _handle(exc) + return result.model_dump() + + +@console_ns.route( + "/apps//workflow-runs//agent-nodes//sandbox/files/upload" +) +class WorkflowAgentSandboxUploadResource(Resource): + @console_ns.doc("upload_workflow_agent_sandbox_file") + @console_ns.doc(description="Upload one workflow Agent sandbox file as a Dify ToolFile mapping") + @console_ns.expect(console_ns.models[WorkflowAgentSandboxUploadPayload.__name__]) + @console_ns.response(200, "Uploaded", console_ns.models[SandboxUploadResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) + @with_current_tenant_id + def post(self, tenant_id: str, app_model: App, workflow_run_id: UUID, node_id: str): + payload = WorkflowAgentSandboxUploadPayload.model_validate(request.get_json(silent=True) or {}) + try: + result = WorkflowAgentSandboxService().upload_file( + tenant_id=tenant_id, + app_id=app_model.id, + workflow_run_id=str(workflow_run_id), + node_id=node_id, + node_execution_id=payload.node_execution_id, + path=payload.path, + ) + except Exception as exc: + return _handle(exc) + return result.model_dump() diff --git a/api/controllers/console/app/agent_app_workspace.py b/api/controllers/console/app/agent_app_workspace.py deleted file mode 100644 index 0699d08bf79..00000000000 --- a/api/controllers/console/app/agent_app_workspace.py +++ /dev/null @@ -1,319 +0,0 @@ -"""Agent App sandbox file-system inspector (read-only). - -Exposes the PRD "rc1-like sandbox file system, downloadable not editable" view -for an Agent App conversation: list a directory, preview a file, or download a -file from the conversation's shell-layer workspace. The API never touches -shellctl directly — it resolves the conversation's sandbox ``session_id`` from -the stored session snapshot and proxies to the agent backend's read-only -workspace endpoints. -""" - -from typing import Literal -from uuid import UUID - -from flask import Response -from flask_restx import Resource, fields -from pydantic import BaseModel, Field - -from clients.agent_backend.errors import AgentBackendHTTPError, AgentBackendTransportError -from clients.agent_backend.workspace_files_client import WorkspaceDownloadResult -from controllers.common.schema import ( - query_params_from_model, - query_params_from_request, - register_response_schema_models, -) -from controllers.console import console_ns -from controllers.console.app.wraps import get_app_model -from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id -from fields.base import ResponseModel -from libs.login import login_required -from models.model import App, AppMode -from services.agent_app_workspace_service import ( - AgentAppWorkspaceService, - AgentWorkspaceInspectorError, - WorkflowAgentWorkspaceService, -) - - -class _WorkspaceFileDownloadField(fields.Raw): - __schema_type__ = "string" - __schema_format__ = "binary" - - -class AgentWorkspaceListQuery(BaseModel): - conversation_id: str = Field(min_length=1, description="Agent App conversation ID") - path: str = Field(default=".", description="Directory path relative to the sandbox workspace") - - -class AgentWorkspaceFileQuery(BaseModel): - conversation_id: str = Field(min_length=1, description="Agent App conversation ID") - path: str = Field(min_length=1, description="File path relative to the sandbox workspace") - - -class WorkflowAgentWorkspaceListQuery(BaseModel): - path: str = Field(default=".", description="Directory path relative to the sandbox workspace") - node_execution_id: str | None = Field( - default=None, - description=( - "Optional workflow node execution ID. When omitted, the latest active session for the node is used." - ), - ) - - -class WorkflowAgentWorkspaceFileQuery(BaseModel): - path: str = Field(min_length=1, description="File path relative to the sandbox workspace") - node_execution_id: str | None = Field( - default=None, - description=( - "Optional workflow node execution ID. When omitted, the latest active session for the node is used." - ), - ) - - -class WorkspaceFileEntryResponse(ResponseModel): - name: str - type: Literal["file", "dir", "symlink"] - size: int - mtime: int - - -class WorkspaceListResponse(ResponseModel): - path: str - entries: list[WorkspaceFileEntryResponse] = Field(default_factory=list) - truncated: bool = False - - -class WorkspacePreviewResponse(ResponseModel): - path: str - size: int - truncated: bool - binary: bool - text: str | None = None - - -register_response_schema_models(console_ns, WorkspaceListResponse) -register_response_schema_models(console_ns, WorkspacePreviewResponse) - - -def _handle(exc: Exception) -> tuple[dict[str, object], int]: - if isinstance(exc, AgentWorkspaceInspectorError): - return {"code": exc.code, "message": exc.message}, exc.status_code - if isinstance(exc, AgentBackendHTTPError): - detail = exc.detail - if isinstance(detail, dict): - return { - "code": detail.get("code", "agent_backend_error"), - "message": detail.get("message", str(exc)), - }, exc.status_code - return {"code": "agent_backend_error", "message": str(detail)}, exc.status_code - if isinstance(exc, AgentBackendTransportError): - return {"code": "agent_backend_unreachable", "message": str(exc)}, 502 - raise exc - - -def _download_response(result: WorkspaceDownloadResult) -> Response | tuple[dict[str, object], int]: - if result.truncated: - return { - "code": "workspace_file_too_large", - "message": ( - "file exceeds the workspace download limit; use preview for partial text or download a smaller file" - ), - "size": result.size, - }, 413 - filename = result.path.rsplit("/", 1)[-1] or "download" - return Response( - result.content, - mimetype="application/octet-stream", - headers={ - "Content-Disposition": f'attachment; filename="{filename}"', - "Content-Length": str(len(result.content)), - "X-Workspace-File-Size": str(result.size), - }, - ) - - -@console_ns.route("/apps//agent-workspace/files") -class AgentAppWorkspaceListResource(Resource): - @console_ns.doc("list_agent_app_workspace_files") - @console_ns.doc(description="List a directory in an Agent App conversation's sandbox workspace (read-only)") - @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentWorkspaceListQuery)}) - @console_ns.response(200, "Listing returned", console_ns.models[WorkspaceListResponse.__name__]) - @setup_required - @login_required - @account_initialization_required - @get_app_model(mode=[AppMode.AGENT]) - @with_current_tenant_id - def get(self, tenant_id: str, app_model: App): - query = query_params_from_request(AgentWorkspaceListQuery) - try: - result = AgentAppWorkspaceService().list_files( - tenant_id=tenant_id, - app_id=app_model.id, - conversation_id=query.conversation_id, - path=query.path, - ) - except Exception as exc: # normalized to an HTTP response below - return _handle(exc) - return result.model_dump() - - -@console_ns.route("/apps//agent-workspace/files/preview") -class AgentAppWorkspacePreviewResource(Resource): - @console_ns.doc("preview_agent_app_workspace_file") - @console_ns.doc(description="Preview a text/binary file in an Agent App conversation's sandbox workspace") - @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentWorkspaceFileQuery)}) - @console_ns.response(200, "Preview returned", console_ns.models[WorkspacePreviewResponse.__name__]) - @setup_required - @login_required - @account_initialization_required - @get_app_model(mode=[AppMode.AGENT]) - @with_current_tenant_id - def get(self, tenant_id: str, app_model: App): - query = query_params_from_request(AgentWorkspaceFileQuery) - try: - result = AgentAppWorkspaceService().preview( - tenant_id=tenant_id, - app_id=app_model.id, - conversation_id=query.conversation_id, - path=query.path, - ) - except Exception as exc: # normalized to an HTTP response below - return _handle(exc) - return result.model_dump() - - -@console_ns.route("/apps//agent-workspace/files/download") -class AgentAppWorkspaceDownloadResource(Resource): - @console_ns.doc("download_agent_app_workspace_file") - @console_ns.doc(description="Download a file from an Agent App conversation's sandbox workspace (read-only)") - @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentWorkspaceFileQuery)}) - @console_ns.doc(produces=["application/octet-stream"]) - @console_ns.response(200, "File bytes", _WorkspaceFileDownloadField) - @console_ns.response(413, "File exceeds the workspace download limit") - @setup_required - @login_required - @account_initialization_required - @get_app_model(mode=[AppMode.AGENT]) - @with_current_tenant_id - def get(self, tenant_id: str, app_model: App): - query = query_params_from_request(AgentWorkspaceFileQuery) - try: - result = AgentAppWorkspaceService().download( - tenant_id=tenant_id, - app_id=app_model.id, - conversation_id=query.conversation_id, - path=query.path, - ) - except Exception as exc: # normalized to an HTTP response below - return _handle(exc) - return _download_response(result) - - -@console_ns.route( - "/apps//workflow-runs//agent-nodes//workspace/files" -) -class WorkflowAgentWorkspaceListResource(Resource): - @console_ns.doc("list_workflow_agent_workspace_files") - @console_ns.doc(description="List a directory in a Workflow Agent node's sandbox workspace (read-only)") - @console_ns.doc( - params={ - "app_id": "Application ID", - "workflow_run_id": "Workflow run ID", - "node_id": "Workflow Agent node ID", - **query_params_from_model(WorkflowAgentWorkspaceListQuery), - } - ) - @console_ns.response(200, "Listing returned", console_ns.models[WorkspaceListResponse.__name__]) - @setup_required - @login_required - @account_initialization_required - @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) - @with_current_tenant_id - def get(self, tenant_id: str, app_model: App, workflow_run_id: UUID, node_id: str): - query = query_params_from_request(WorkflowAgentWorkspaceListQuery) - try: - result = WorkflowAgentWorkspaceService().list_files( - tenant_id=tenant_id, - app_id=app_model.id, - workflow_run_id=str(workflow_run_id), - node_id=node_id, - node_execution_id=query.node_execution_id, - path=query.path, - ) - except Exception as exc: # normalized to an HTTP response below - return _handle(exc) - return result.model_dump() - - -@console_ns.route( - "/apps//workflow-runs//agent-nodes//workspace/files/preview" -) -class WorkflowAgentWorkspacePreviewResource(Resource): - @console_ns.doc("preview_workflow_agent_workspace_file") - @console_ns.doc(description="Preview a text/binary file in a Workflow Agent node's sandbox workspace") - @console_ns.doc( - params={ - "app_id": "Application ID", - "workflow_run_id": "Workflow run ID", - "node_id": "Workflow Agent node ID", - **query_params_from_model(WorkflowAgentWorkspaceFileQuery), - } - ) - @console_ns.response(200, "Preview returned", console_ns.models[WorkspacePreviewResponse.__name__]) - @setup_required - @login_required - @account_initialization_required - @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) - @with_current_tenant_id - def get(self, tenant_id: str, app_model: App, workflow_run_id: UUID, node_id: str): - query = query_params_from_request(WorkflowAgentWorkspaceFileQuery) - try: - result = WorkflowAgentWorkspaceService().preview( - tenant_id=tenant_id, - app_id=app_model.id, - workflow_run_id=str(workflow_run_id), - node_id=node_id, - node_execution_id=query.node_execution_id, - path=query.path, - ) - except Exception as exc: # normalized to an HTTP response below - return _handle(exc) - return result.model_dump() - - -@console_ns.route( - "/apps//workflow-runs//agent-nodes//workspace/files/download" -) -class WorkflowAgentWorkspaceDownloadResource(Resource): - @console_ns.doc("download_workflow_agent_workspace_file") - @console_ns.doc(description="Download a file from a Workflow Agent node's sandbox workspace (read-only)") - @console_ns.doc( - params={ - "app_id": "Application ID", - "workflow_run_id": "Workflow run ID", - "node_id": "Workflow Agent node ID", - **query_params_from_model(WorkflowAgentWorkspaceFileQuery), - } - ) - @console_ns.doc(produces=["application/octet-stream"]) - @console_ns.response(200, "File bytes", _WorkspaceFileDownloadField) - @console_ns.response(413, "File exceeds the workspace download limit") - @setup_required - @login_required - @account_initialization_required - @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) - @with_current_tenant_id - def get(self, tenant_id: str, app_model: App, workflow_run_id: UUID, node_id: str): - query = query_params_from_request(WorkflowAgentWorkspaceFileQuery) - try: - result = WorkflowAgentWorkspaceService().download( - tenant_id=tenant_id, - app_id=app_model.id, - workflow_run_id=str(workflow_run_id), - node_id=node_id, - node_execution_id=query.node_execution_id, - path=query.path, - ) - except Exception as exc: # normalized to an HTTP response below - return _handle(exc) - return _download_response(result) diff --git a/api/controllers/console/app/agent_drive_inspector.py b/api/controllers/console/app/agent_drive_inspector.py new file mode 100644 index 00000000000..b8d1d487808 --- /dev/null +++ b/api/controllers/console/app/agent_drive_inspector.py @@ -0,0 +1,235 @@ +"""Console read-only inspector for the agent drive (ENG-624). + +``agent-drive`` looks at the *static* drive assets (standardized skills and +committed files); the sibling ``agent-sandbox`` routes look at a *runtime* +sandbox workspace. Unlike the sandbox routes this never proxies to the agent +backend — drive data lives in the API's own DB/storage, served straight from +``AgentDriveService``. Download hands the browser an **external** signed URL +(the inner manifest hands agents internal ones — the two must never mix). +""" + +from __future__ import annotations + +from uuid import UUID + +from flask_restx import Resource +from pydantic import BaseModel, Field + +from controllers.common.schema import ( + query_params_from_model, + query_params_from_request, + register_response_schema_models, +) +from controllers.console import console_ns +from controllers.console.agent.app_helpers import resolve_agent_app_model +from controllers.console.app.wraps import get_app_model +from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id +from fields.base import ResponseModel +from libs.login import login_required +from models.model import App, AppMode +from services.agent.composer_service import AgentComposerService +from services.agent_drive_service import AgentDriveError, AgentDriveService + + +class AgentDriveListQuery(BaseModel): + prefix: str = Field(default="", description="Key prefix filter: '/' for one skill, 'files/' for files") + node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)") + + +class AgentDriveListByAgentQuery(BaseModel): + prefix: str = Field(default="", description="Key prefix filter: '/' for one skill, 'files/' for files") + + +class AgentDriveFileQuery(BaseModel): + key: str = Field(min_length=1, description="Drive key, e.g. tender-analyzer/SKILL.md") + node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)") + + +class AgentDriveFileByAgentQuery(BaseModel): + key: str = Field(min_length=1, description="Drive key, e.g. tender-analyzer/SKILL.md") + + +class AgentDriveItemResponse(ResponseModel): + key: str + size: int | None = None + mime_type: str | None = None + hash: str | None = None + file_kind: str + created_at: int | None = None + + +class AgentDriveListResponse(ResponseModel): + items: list[AgentDriveItemResponse] = Field(default_factory=list) + + +class AgentDrivePreviewResponse(ResponseModel): + key: str + size: int | None = None + truncated: bool + binary: bool + text: str | None = None + + +class AgentDriveDownloadResponse(ResponseModel): + url: str + + +register_response_schema_models( + console_ns, AgentDriveListResponse, AgentDrivePreviewResponse, AgentDriveDownloadResponse +) + + +def _resolve_agent_id(app_model: App, node_id: str | None) -> str | None: + """Agent identity for the drive: app-bound agent, or the workflow node binding.""" + if node_id: + return AgentComposerService.resolve_workflow_node_agent_id( + tenant_id=app_model.tenant_id, app_id=app_model.id, node_id=node_id + ) + return app_model.bound_agent_id + + +def _agent_not_bound() -> tuple[dict[str, object], int]: + return {"code": "agent_not_bound", "message": "no agent is bound for this app/node"}, 400 + + +def _handle(exc: AgentDriveError) -> tuple[dict[str, object], int]: + return {"code": exc.code, "message": exc.message}, exc.status_code + + +_WORKFLOW_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT] + + +@console_ns.route("/agent//drive/files") +class AgentDriveListByAgentApi(Resource): + @console_ns.doc("list_agent_drive_files_by_agent") + @console_ns.doc(description="List agent drive entries for an Agent App") + @console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveListByAgentQuery)}) + @console_ns.response(200, "Drive entries", console_ns.models[AgentDriveListResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_tenant_id + def get(self, tenant_id: str, agent_id: UUID): + query = query_params_from_request(AgentDriveListByAgentQuery) + resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + try: + items = AgentDriveService().manifest(tenant_id=tenant_id, agent_id=str(agent_id), prefix=query.prefix) + except AgentDriveError as exc: + return _handle(exc) + return {"items": [{k: v for k, v in item.items() if k != "file_id"} for item in items]} + + +@console_ns.route("/agent//drive/files/preview") +class AgentDrivePreviewByAgentApi(Resource): + @console_ns.doc("preview_agent_drive_file_by_agent") + @console_ns.doc(description="Truncated text preview of one Agent App drive value") + @console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveFileByAgentQuery)}) + @console_ns.response(200, "Preview", console_ns.models[AgentDrivePreviewResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_tenant_id + def get(self, tenant_id: str, agent_id: UUID): + query = query_params_from_request(AgentDriveFileByAgentQuery) + resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + try: + return AgentDriveService().preview(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key) + except AgentDriveError as exc: + return _handle(exc) + + +@console_ns.route("/agent//drive/files/download") +class AgentDriveDownloadByAgentApi(Resource): + @console_ns.doc("download_agent_drive_file_by_agent") + @console_ns.doc(description="Time-limited external signed URL for one Agent App drive value") + @console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveFileByAgentQuery)}) + @console_ns.response(200, "Signed URL", console_ns.models[AgentDriveDownloadResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_tenant_id + def get(self, tenant_id: str, agent_id: UUID): + query = query_params_from_request(AgentDriveFileByAgentQuery) + resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + try: + url = AgentDriveService().download_url(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key) + except AgentDriveError as exc: + return _handle(exc) + return {"url": url} + + +@console_ns.route("/apps//agent/drive/files") +class AgentDriveListApi(Resource): + @console_ns.doc("list_agent_drive_files") + @console_ns.doc(description="List agent drive entries (read-only inspector; one endpoint for both tabs)") + @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveListQuery)}) + @console_ns.response(200, "Drive entries", console_ns.models[AgentDriveListResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=_WORKFLOW_APP_MODES) + def get(self, app_model: App): + query = query_params_from_request(AgentDriveListQuery) + agent_id = _resolve_agent_id(app_model, query.node_id) + if not agent_id: + return _agent_not_bound() + try: + items = AgentDriveService().manifest(tenant_id=app_model.tenant_id, agent_id=agent_id, prefix=query.prefix) + except AgentDriveError as exc: + return _handle(exc) + # the inner manifest exposes file_id for agent-side pulls; the console + # inspector is a pure read surface and does not need value pointers + return {"items": [{k: v for k, v in item.items() if k != "file_id"} for item in items]} + + +@console_ns.route("/apps//agent/drive/files/preview") +class AgentDrivePreviewApi(Resource): + @console_ns.doc("preview_agent_drive_file") + @console_ns.doc(description="Truncated text preview of one drive value (binary-safe; SKILL.md is the main case)") + @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveFileQuery)}) + @console_ns.response(200, "Preview", console_ns.models[AgentDrivePreviewResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=_WORKFLOW_APP_MODES) + def get(self, app_model: App): + query = query_params_from_request(AgentDriveFileQuery) + agent_id = _resolve_agent_id(app_model, query.node_id) + if not agent_id: + return _agent_not_bound() + try: + return AgentDriveService().preview(tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key) + except AgentDriveError as exc: + return _handle(exc) + + +@console_ns.route("/apps//agent/drive/files/download") +class AgentDriveDownloadApi(Resource): + @console_ns.doc("download_agent_drive_file") + @console_ns.doc(description="Time-limited external signed URL for one drive value (no streaming proxy)") + @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveFileQuery)}) + @console_ns.response(200, "Signed URL", console_ns.models[AgentDriveDownloadResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=_WORKFLOW_APP_MODES) + def get(self, app_model: App): + query = query_params_from_request(AgentDriveFileQuery) + agent_id = _resolve_agent_id(app_model, query.node_id) + if not agent_id: + return _agent_not_bound() + try: + url = AgentDriveService().download_url(tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key) + except AgentDriveError as exc: + return _handle(exc) + return {"url": url} + + +__all__ = [ + "AgentDriveDownloadApi", + "AgentDriveDownloadByAgentApi", + "AgentDriveListApi", + "AgentDriveListByAgentApi", + "AgentDrivePreviewApi", + "AgentDrivePreviewByAgentApi", +] diff --git a/api/controllers/console/app/annotation.py b/api/controllers/console/app/annotation.py index d066177df3c..dd0b7e9ef5f 100644 --- a/api/controllers/console/app/annotation.py +++ b/api/controllers/console/app/annotation.py @@ -6,7 +6,7 @@ from flask_restx import Resource from pydantic import BaseModel, Field, TypeAdapter, field_validator from controllers.common.errors import NoFileUploadedError, TooManyFilesError -from controllers.common.schema import 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.wraps import ( account_initialization_required, @@ -24,6 +24,7 @@ from fields.annotation_fields import ( AnnotationHitHistoryList, AnnotationList, ) +from fields.base import ResponseModel from libs.helper import uuid_value from libs.login import login_required from services.annotation_service import ( @@ -56,7 +57,10 @@ class CreateAnnotationPayload(BaseModel): question: str | None = Field(default=None, description="Question text") answer: str | None = Field(default=None, description="Answer text") content: str | None = Field(default=None, description="Content text") - annotation_reply: dict[str, Any] | None = Field(default=None, description="Annotation reply data") + annotation_reply: dict[str, Any] | None = Field( + default=None, + description="Annotation reply data", + ) @field_validator("message_id") @classmethod @@ -70,13 +74,18 @@ class UpdateAnnotationPayload(BaseModel): question: str | None = None answer: str | None = None content: str | None = None - annotation_reply: dict[str, Any] | None = None + annotation_reply: dict[str, Any] | None = Field(default=None) class AnnotationReplyStatusQuery(BaseModel): action: Literal["enable", "disable"] +class AnnotationHitHistoryListQuery(BaseModel): + page: int = Field(default=1, ge=1, description="Page number") + limit: int = Field(default=20, ge=1, description="Page size") + + class AnnotationFilePayload(BaseModel): message_id: str = Field(..., description="Message ID") @@ -86,6 +95,25 @@ class AnnotationFilePayload(BaseModel): 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): + embedding_provider_name: str | None = None + embedding_model_name: str | None = None + + +class AnnotationSettingResponse(ResponseModel): + id: str | None = None + enabled: bool + score_threshold: float | None = None + embedding_model: AnnotationEmbeddingModelResponse | None = None + + register_schema_models( console_ns, Annotation, @@ -99,8 +127,19 @@ register_schema_models( CreateAnnotationPayload, UpdateAnnotationPayload, AnnotationReplyStatusQuery, + AnnotationHitHistoryListQuery, AnnotationFilePayload, ) +register_response_schema_models( + console_ns, + Annotation, + AnnotationList, + AnnotationExportList, + AnnotationHitHistory, + AnnotationHitHistoryList, + AnnotationJobStatusResponse, + AnnotationSettingResponse, +) @console_ns.route("/apps//annotation-reply/") @@ -109,7 +148,7 @@ class AnnotationReplyActionApi(Resource): @console_ns.doc(description="Enable or disable annotation reply for an app") @console_ns.doc(params={"app_id": "Application ID", "action": "Action to perform (enable/disable)"}) @console_ns.expect(console_ns.models[AnnotationReplyPayload.__name__]) - @console_ns.response(200, "Action completed successfully") + @console_ns.response(200, "Action completed successfully", console_ns.models[AnnotationJobStatusResponse.__name__]) @console_ns.response(403, "Insufficient permissions") @setup_required @login_required @@ -136,7 +175,11 @@ class AppAnnotationSettingDetailApi(Resource): @console_ns.doc("get_annotation_setting") @console_ns.doc(description="Get annotation settings for an app") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.response(200, "Annotation settings retrieved successfully") + @console_ns.response( + 200, + "Annotation settings retrieved successfully", + console_ns.models[AnnotationSettingResponse.__name__], + ) @console_ns.response(403, "Insufficient permissions") @setup_required @login_required @@ -153,7 +196,7 @@ class AppAnnotationSettingUpdateApi(Resource): @console_ns.doc(description="Update annotation settings for an app") @console_ns.doc(params={"app_id": "Application ID", "annotation_setting_id": "Annotation setting ID"}) @console_ns.expect(console_ns.models[AnnotationSettingUpdatePayload.__name__]) - @console_ns.response(200, "Settings updated successfully") + @console_ns.response(200, "Settings updated successfully", console_ns.models[AnnotationSettingResponse.__name__]) @console_ns.response(403, "Insufficient permissions") @setup_required @login_required @@ -176,7 +219,11 @@ class AnnotationReplyActionStatusApi(Resource): @console_ns.doc("get_annotation_reply_action_status") @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.response( + 200, + "Job status retrieved successfully", + console_ns.models[AnnotationJobStatusResponse.__name__], + ) @console_ns.response(403, "Insufficient permissions") @setup_required @login_required @@ -204,8 +251,8 @@ class AnnotationApi(Resource): @console_ns.doc("list_annotations") @console_ns.doc(description="Get annotations for an app with pagination") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.expect(console_ns.models[AnnotationListQuery.__name__]) - @console_ns.response(200, "Annotations retrieved successfully") + @console_ns.doc(params=query_params_from_model(AnnotationListQuery)) + @console_ns.response(200, "Annotations retrieved successfully", console_ns.models[AnnotationList.__name__]) @console_ns.response(403, "Insufficient permissions") @setup_required @login_required @@ -257,6 +304,7 @@ class AnnotationApi(Resource): @login_required @account_initialization_required @edit_permission_required + @console_ns.response(204, "Annotations deleted successfully") def delete(self, app_id: UUID): # Use request.args.getlist to get annotation_ids array directly @@ -335,6 +383,7 @@ class AnnotationUpdateDeleteApi(Resource): @login_required @account_initialization_required @edit_permission_required + @console_ns.response(204, "Annotation deleted successfully") def delete(self, app_id: UUID, annotation_id: UUID): AppAnnotationService.delete_app_annotation(str(app_id), str(annotation_id)) return "", 204 @@ -345,7 +394,11 @@ class AnnotationBatchImportApi(Resource): @console_ns.doc("batch_import_annotations") @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.response( + 200, + "Batch import started successfully", + console_ns.models[AnnotationJobStatusResponse.__name__], + ) @console_ns.response(403, "Insufficient permissions") @console_ns.response(400, "No file uploaded or too many files") @console_ns.response(413, "File too large") @@ -398,7 +451,11 @@ class AnnotationBatchImportStatusApi(Resource): @console_ns.doc("get_batch_import_status") @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.response( + 200, + "Job status retrieved successfully", + console_ns.models[AnnotationJobStatusResponse.__name__], + ) @console_ns.response(403, "Insufficient permissions") @setup_required @login_required @@ -424,11 +481,7 @@ class AnnotationHitHistoryListApi(Resource): @console_ns.doc("list_annotation_hit_histories") @console_ns.doc(description="Get hit histories for an annotation") @console_ns.doc(params={"app_id": "Application ID", "annotation_id": "Annotation ID"}) - @console_ns.expect( - console_ns.parser() - .add_argument("page", type=int, location="args", default=1, help="Page number") - .add_argument("limit", type=int, location="args", default=20, help="Page size") - ) + @console_ns.doc(params=query_params_from_model(AnnotationHitHistoryListQuery)) @console_ns.response( 200, "Hit histories retrieved successfully", diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index 818da5553d5..2fb3a402962 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -1,6 +1,7 @@ import logging import re import uuid +from collections.abc import Sequence from datetime import datetime from typing import Any, Literal, cast @@ -14,7 +15,12 @@ from werkzeug.exceptions import BadRequest from controllers.common.fields import RedirectUrlResponse, SimpleResultResponse from controllers.common.helpers import FileInfo -from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models +from controllers.common.schema import ( + query_params_from_model, + register_enum_models, + register_response_schema_models, + register_schema_models, +) from controllers.console import console_ns from controllers.console.app.wraps import get_app_model, with_session from controllers.console.workspace.models import LoadBalancingPayload @@ -36,12 +42,12 @@ from core.trigger.constants import TRIGGER_NODE_TYPES from extensions.ext_database import db from fields.base import ResponseModel from graphon.enums import WorkflowExecutionStatus -from libs.helper import build_icon_url, to_timestamp +from libs.helper import build_icon_url, dump_response, to_timestamp from libs.login import login_required from models import Account, App, DatasetPermissionEnum, Workflow from models.model import IconType from services.app_dsl_service import AppDslService -from services.app_service import AppListParams, AppService, CreateAppParams +from services.app_service import AppListParams, AppListSortBy, AppService, CreateAppParams, StarredAppListParams from services.enterprise.enterprise_service import EnterpriseService from services.entities.dsl_entities import ImportMode, ImportStatus from services.entities.knowledge_entities.knowledge_entities import ( @@ -58,7 +64,7 @@ from services.entities.knowledge_entities.knowledge_entities import ( ) from services.feature_service import FeatureService -ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "agent", "advanced-chat", "workflow", "completion"] +ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "advanced-chat", "workflow", "completion"] register_enum_models(console_ns, IconType) @@ -68,10 +74,14 @@ _CREATOR_IDS_BRACKET_PATTERN = re.compile(r"^creator_ids\[(\d+)\]$") AppListMode = Literal["completion", "chat", "advanced-chat", "workflow", "agent-chat", "agent", "channel", "all"] -class AppListQuery(BaseModel): +class AppListBaseQuery(BaseModel): page: int = Field(default=1, ge=1, le=99999, description="Page number (1-99999)") limit: int = Field(default=20, ge=1, le=100, description="Page size (1-100)") mode: AppListMode = Field(default=cast(AppListMode, "all"), description="App mode filter") + sort_by: AppListSortBy = Field( + default="last_modified", + description="Sort apps by last modified, recently created, or earliest created", + ) name: str | None = Field(default=None, description="Filter by app name") tag_ids: list[str] | None = Field(default=None, description="Filter by tag IDs") creator_ids: list[str] | None = Field(default=None, description="Filter by creator account IDs") @@ -114,6 +124,14 @@ class AppListQuery(BaseModel): raise ValueError("Invalid UUID format in creator_ids.") from exc +class AppListQuery(AppListBaseQuery): + pass + + +class StarredAppListQuery(AppListBaseQuery): + pass + + def _normalize_app_list_query_args(query_args: MultiDict[str, str]) -> dict[str, str | list[str]]: normalized: dict[str, str | list[str]] = {} indexed_tag_ids: list[tuple[int, str]] = [] @@ -145,9 +163,7 @@ def _normalize_app_list_query_args(query_args: MultiDict[str, str]) -> dict[str, class CreateAppPayload(BaseModel): name: str = Field(..., min_length=1, description="App name") description: str | None = Field(default=None, description="App description (max 400 chars)", max_length=400) - mode: Literal["chat", "agent-chat", "agent", "advanced-chat", "workflow", "completion"] = Field( - ..., description="App mode" - ) + mode: Literal["chat", "agent-chat", "advanced-chat", "workflow", "completion"] = Field(..., description="App mode") icon_type: IconType | None = Field(default=None, description="Icon type") icon: str | None = Field(default=None, description="Icon") icon_background: str | None = Field(default=None, description="Icon background color") @@ -206,6 +222,11 @@ class AppTracePayload(BaseModel): return value +class AppTraceResponse(ResponseModel): + enabled: bool + tracing_provider: str | None = None + + type JSONValue = Any @@ -377,6 +398,13 @@ class AppPartial(ResponseModel): create_user_name: str | None = None author_name: str | None = None has_draft_trigger: bool | None = None + # For Agent App type: the roster Agent backing this app (None otherwise). + bound_agent_id: str | None = None + # For Agent App responses exposed through /agent. + app_id: str | None = None + role: str | None = None + active_config_is_published: bool = False + is_starred: bool = False @computed_field(return_type=str | None) # type: ignore @property @@ -427,6 +455,10 @@ class AppDetailWithSite(AppDetail): site: Site | None = None # For Agent App type: the roster Agent backing this app (None otherwise). bound_agent_id: str | None = None + # For Agent App responses exposed through /agent. + app_id: str | None = None + role: str | None = None + active_config_is_published: bool = False @computed_field(return_type=str | None) # type: ignore @property @@ -446,12 +478,54 @@ class AppExportResponse(ResponseModel): data: 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] + res = EnterpriseService.WebAppAuth.batch_get_app_access_mode_by_id(app_ids=app_ids) + if len(res) != len(app_ids): + raise BadRequest("Invalid app id in webapp auth") + + for app in apps: + if str(app.id) in res: + app.access_mode = res[str(app.id)].access_mode + + workflow_capable_app_ids = [str(app.id) for app in apps if app.mode in {"workflow", "advanced-chat"}] + draft_trigger_app_ids: set[str] = set() + if workflow_capable_app_ids: + draft_workflows = ( + session.execute( + select(Workflow).where( + Workflow.version == Workflow.VERSION_DRAFT, + Workflow.app_id.in_(workflow_capable_app_ids), + Workflow.tenant_id == tenant_id, + ) + ) + .scalars() + .all() + ) + trigger_node_types = TRIGGER_NODE_TYPES + for workflow in draft_workflows: + node_id = None + try: + for node_id, node_data in workflow.walk_nodes(): + if node_data.get("type") in trigger_node_types: + draft_trigger_app_ids.add(str(workflow.app_id)) + break + except Exception: + _logger.exception("error while walking nodes, workflow_id=%s, node_id=%s", workflow.id, node_id) + continue + + for app in apps: + app.has_draft_trigger = str(app.id) in draft_trigger_app_ids + + register_enum_models(console_ns, RetrievalMethod, WorkflowExecutionStatus, DatasetPermissionEnum) -register_response_schema_models(console_ns, RedirectUrlResponse, SimpleResultResponse) +register_response_schema_models(console_ns, AppTraceResponse, RedirectUrlResponse, SimpleResultResponse) register_schema_models( console_ns, AppListQuery, + StarredAppListQuery, CreateAppPayload, UpdateAppPayload, CopyAppPayload, @@ -495,7 +569,7 @@ register_schema_models( class AppListApi(Resource): @console_ns.doc("list_apps") @console_ns.doc(description="Get list of applications with pagination and filtering") - @console_ns.expect(console_ns.models[AppListQuery.__name__]) + @console_ns.doc(params=query_params_from_model(AppListQuery)) @console_ns.response(200, "Success", console_ns.models[AppPagination.__name__]) @setup_required @login_required @@ -511,6 +585,7 @@ class AppListApi(Resource): page=args.page, limit=args.limit, mode=args.mode, + sort_by=args.sort_by, name=args.name, tag_ids=args.tag_ids, creator_ids=args.creator_ids, @@ -524,46 +599,7 @@ class AppListApi(Resource): empty = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[]) return empty.model_dump(mode="json"), 200 - if FeatureService.get_system_features().webapp_auth.enabled: - app_ids = [str(app.id) for app in app_pagination.items] - res = EnterpriseService.WebAppAuth.batch_get_app_access_mode_by_id(app_ids=app_ids) - if len(res) != len(app_ids): - raise BadRequest("Invalid app id in webapp auth") - - for app in app_pagination.items: - if str(app.id) in res: - app.access_mode = res[str(app.id)].access_mode - - workflow_capable_app_ids = [ - str(app.id) for app in app_pagination.items if app.mode in {"workflow", "advanced-chat"} - ] - draft_trigger_app_ids: set[str] = set() - if workflow_capable_app_ids: - draft_workflows = ( - session.execute( - select(Workflow).where( - Workflow.version == Workflow.VERSION_DRAFT, - Workflow.app_id.in_(workflow_capable_app_ids), - Workflow.tenant_id == current_tenant_id, - ) - ) - .scalars() - .all() - ) - trigger_node_types = TRIGGER_NODE_TYPES - for workflow in draft_workflows: - node_id = None - try: - for node_id, node_data in workflow.walk_nodes(): - if node_data.get("type") in trigger_node_types: - draft_trigger_app_ids.add(str(workflow.app_id)) - break - except Exception: - _logger.exception("error while walking nodes, workflow_id=%s, node_id=%s", workflow.id, node_id) - continue - - for app in app_pagination.items: - app.has_draft_trigger = str(app.id) in draft_trigger_app_ids + _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 @@ -571,7 +607,7 @@ class AppListApi(Resource): @console_ns.doc("create_app") @console_ns.doc(description="Create a new application") @console_ns.expect(console_ns.models[CreateAppPayload.__name__]) - @console_ns.response(201, "App created successfully", console_ns.models[AppDetail.__name__]) + @console_ns.response(201, "App created successfully", console_ns.models[AppDetailWithSite.__name__]) @console_ns.response(403, "Insufficient permissions") @console_ns.response(400, "Invalid request parameters") @setup_required @@ -595,10 +631,82 @@ class AppListApi(Resource): app_service = AppService() app = app_service.create_app(current_tenant_id, params, current_user) - app_detail = AppDetail.model_validate(app, from_attributes=True) + app_detail = AppDetailWithSite.model_validate(app, from_attributes=True) return app_detail.model_dump(mode="json"), 201 +@console_ns.route("/apps/starred") +class StarredAppListApi(Resource): + @console_ns.doc("list_starred_apps") + @console_ns.doc(description="Get applications starred by the current account") + @console_ns.doc(params=query_params_from_model(StarredAppListQuery)) + @console_ns.response(200, "Success", console_ns.models[AppPagination.__name__]) + @setup_required + @login_required + @account_initialization_required + @enterprise_license_required + @with_session(write=False) + @with_current_user_id + @with_current_tenant_id + def get(self, current_tenant_id: str, current_user_id: str, session: Session): + args = StarredAppListQuery.model_validate(_normalize_app_list_query_args(request.args)) + params = StarredAppListParams( + page=args.page, + limit=args.limit, + mode=args.mode, + sort_by=args.sort_by, + name=args.name, + tag_ids=args.tag_ids, + creator_ids=args.creator_ids, + is_created_by_me=args.is_created_by_me, + ) + + app_pagination = AppService().get_paginate_starred_apps(current_user_id, current_tenant_id, params) + 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 + + _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 + + +@console_ns.route("/apps//star") +class AppStarApi(Resource): + @console_ns.doc("star_app") + @console_ns.doc(description="Star an application for the current account") + @console_ns.doc(params={"app_id": "Application ID"}) + @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) + @console_ns.response(404, "App not found") + @setup_required + @login_required + @account_initialization_required + @enterprise_license_required + @with_current_user_id + @with_session + @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"}) + + @console_ns.doc("unstar_app") + @console_ns.doc(description="Remove the current account's star from an application") + @console_ns.doc(params={"app_id": "Application ID"}) + @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) + @console_ns.response(404, "App not found") + @setup_required + @login_required + @account_initialization_required + @enterprise_license_required + @with_current_user_id + @with_session + @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"}) + + @console_ns.route("/apps/") class AppApi(Resource): @console_ns.doc("get_app_detail") @@ -618,7 +726,7 @@ class AppApi(Resource): if FeatureService.get_system_features().webapp_auth.enabled: app_setting = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=str(app_model.id)) - app_model.access_mode = app_setting.access_mode # type: ignore[attr-defined] + app_model.access_mode = app_setting.access_mode response_model = AppDetailWithSite.model_validate(app_model, from_attributes=True) return response_model.model_dump(mode="json") @@ -737,7 +845,7 @@ class AppExportApi(Resource): @console_ns.doc("export_app") @console_ns.doc(description="Export application configuration as DSL") @console_ns.doc(params={"app_id": "Application ID to export"}) - @console_ns.expect(console_ns.models[AppExportQuery.__name__]) + @console_ns.doc(params=query_params_from_model(AppExportQuery)) @console_ns.response(200, "App exported successfully", console_ns.models[AppExportResponse.__name__]) @console_ns.response(403, "Insufficient permissions") @get_app_model @@ -812,7 +920,7 @@ class AppIconApi(Resource): @console_ns.doc(description="Update application icon") @console_ns.doc(params={"app_id": "Application ID"}) @console_ns.expect(console_ns.models[AppIconPayload.__name__]) - @console_ns.response(200, "Icon updated successfully") + @console_ns.response(200, "Icon updated successfully", console_ns.models[AppDetail.__name__]) @console_ns.response(403, "Insufficient permissions") @setup_required @login_required @@ -882,7 +990,11 @@ class AppTraceApi(Resource): @console_ns.doc("get_app_trace") @console_ns.doc(description="Get app tracing configuration") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.response(200, "Trace configuration retrieved successfully") + @console_ns.response( + 200, + "Trace configuration retrieved successfully", + console_ns.models[AppTraceResponse.__name__], + ) @setup_required @login_required @account_initialization_required diff --git a/api/controllers/console/app/audio.py b/api/controllers/console/app/audio.py index acf2215e45b..7ef43570c2f 100644 --- a/api/controllers/console/app/audio.py +++ b/api/controllers/console/app/audio.py @@ -1,12 +1,14 @@ import logging +from typing import Any from flask import request -from flask_restx import Resource, fields -from pydantic import BaseModel, Field +from flask_restx import Resource +from pydantic import BaseModel, Field, RootModel from werkzeug.exceptions import InternalServerError import services -from controllers.common.schema import register_schema_models +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 ( AppUnavailableError, @@ -51,7 +53,12 @@ class AudioTranscriptResponse(BaseModel): text: str = Field(description="Transcribed text from audio") +class TextToSpeechVoiceListResponse(RootModel[list[dict[str, Any]]]): + root: list[dict[str, Any]] + + register_schema_models(console_ns, AudioTranscriptResponse, TextToSpeechPayload, TextToSpeechVoiceQuery) +register_response_schema_models(console_ns, AudioBinaryResponse, TextToSpeechVoiceListResponse) @console_ns.route("/apps//audio-to-text") @@ -113,7 +120,11 @@ 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.response( + 200, + "Text to speech conversion successful", + console_ns.models[AudioBinaryResponse.__name__], + ) @console_ns.response(400, "Bad request - Invalid parameters") @get_app_model @setup_required @@ -162,9 +173,11 @@ 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.expect(console_ns.models[TextToSpeechVoiceQuery.__name__]) + @console_ns.doc(params=query_params_from_model(TextToSpeechVoiceQuery)) @console_ns.response( - 200, "TTS voices retrieved successfully", fields.List(fields.Raw(description="Available voices")) + 200, + "TTS voices retrieved successfully", + console_ns.models[TextToSpeechVoiceListResponse.__name__], ) @console_ns.response(400, "Invalid language parameter") @get_app_model diff --git a/api/controllers/console/app/completion.py b/api/controllers/console/app/completion.py index e5eabf6c493..853f7b023ff 100644 --- a/api/controllers/console/app/completion.py +++ b/api/controllers/console/app/completion.py @@ -1,5 +1,6 @@ import logging from typing import Any, Literal +from uuid import UUID from flask import request from flask_restx import Resource @@ -7,9 +8,10 @@ from pydantic import BaseModel, Field, field_validator from werkzeug.exceptions import BadRequest, InternalServerError, NotFound import services -from controllers.common.fields import SimpleResultResponse +from controllers.common.fields import GeneratedAppResponse, 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 from controllers.console.app.error import ( AppUnavailableError, CompletionRequestError, @@ -23,6 +25,7 @@ from controllers.console.wraps import ( account_initialization_required, edit_permission_required, setup_required, + with_current_tenant_id, with_current_user, with_current_user_id, ) @@ -64,8 +67,14 @@ class BaseMessagePayload(BaseModel): # Soul, so no override ``model_config`` is sent; chat / agent-chat / completion # debugging still pass it. Optional here, required in practice by those modes # downstream when their config is built from args. - model_config_data: dict[str, Any] = Field(default_factory=dict, alias="model_config") - files: list[Any] | None = Field(default=None, description="Uploaded files") + model_config_data: dict[str, Any] = Field( + default_factory=dict, + alias="model_config", + ) + files: list[Any] | None = Field( + default=None, + description="Uploaded files", + ) response_mode: Literal["blocking", "streaming"] = Field(default="blocking", description="Response mode") retriever_from: str = Field(default="dev", description="Retriever source") @@ -88,7 +97,7 @@ class ChatMessagePayload(BaseMessagePayload): register_schema_models(console_ns, CompletionMessagePayload, ChatMessagePayload) -register_response_schema_models(console_ns, SimpleResultResponse) +register_response_schema_models(console_ns, GeneratedAppResponse, SimpleResultResponse) # define completion message api for user @@ -98,7 +107,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.response(200, "Completion generated successfully", console_ns.models[GeneratedAppResponse.__name__]) @console_ns.response(400, "Invalid request parameters") @console_ns.response(404, "App not found") @setup_required @@ -170,7 +179,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.response(200, "Chat message generated successfully", console_ns.models[GeneratedAppResponse.__name__]) @console_ns.response(400, "Invalid request parameters") @console_ns.response(404, "App or conversation not found") @setup_required @@ -180,51 +189,27 @@ class ChatMessageApi(Resource): @edit_permission_required @with_current_user def post(self, current_user: Account, app_model: App): - raw_payload = console_ns.payload or {} - args_model = ChatMessagePayload.model_validate(raw_payload) - args = args_model.model_dump(exclude_none=True, by_alias=True) + return _create_chat_message(current_user=current_user, app_model=app_model) - streaming = _resolve_debugger_chat_streaming( - app_mode=AppMode.value_of(app_model.mode), - response_mode=args_model.response_mode, - response_mode_provided=isinstance(raw_payload, dict) and "response_mode" in raw_payload, - ) - if AppMode.value_of(app_model.mode) == AppMode.AGENT: - args["response_mode"] = "streaming" - args["auto_generate_name"] = False - external_trace_id = get_external_trace_id(request) - if external_trace_id: - args["external_trace_id"] = external_trace_id - - try: - response = AppGenerateService.generate( - app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.DEBUGGER, streaming=streaming - ) - - return helper.compact_generate_response(response) - except services.errors.conversation.ConversationNotExistsError: - raise NotFound("Conversation Not Exists.") - except services.errors.conversation.ConversationCompletedError: - raise ConversationCompletedError() - except services.errors.app_model_config.AppModelConfigBrokenError: - logger.exception("App model config broken.") - raise AppUnavailableError() - except ProviderTokenNotInitError as ex: - raise ProviderNotInitializeError(ex.description) - except QuotaExceededError: - raise ProviderQuotaExceededError() - except ModelCurrentlyNotSupportError: - raise ProviderModelCurrentlyNotSupportError() - except InvokeRateLimitError as ex: - raise InvokeRateLimitHttpError(ex.description) - except InvokeError as e: - raise CompletionRequestError(e.description) - except ValueError as e: - raise e - except Exception as e: - logger.exception("internal server error.") - raise InternalServerError() +@console_ns.route("/agent//chat-messages") +class AgentChatMessageApi(Resource): + @console_ns.doc("create_agent_chat_message") + @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(400, "Invalid request parameters") + @console_ns.response(404, "Agent or conversation not found") + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @with_current_user + @with_current_tenant_id + def post(self, current_tenant_id: str, current_user: Account, agent_id: UUID): + app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id) + return _create_chat_message(current_user=current_user, app_model=app_model) @console_ns.route("/apps//chat-messages//stop") @@ -239,12 +224,79 @@ class ChatMessageStopApi(Resource): @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT]) @with_current_user_id def post(self, current_user_id: str, app_model: App, task_id: str): + return _stop_chat_message(current_user_id=current_user_id, app_model=app_model, task_id=task_id) - AppTaskService.stop_task( - task_id=task_id, - invoke_from=InvokeFrom.DEBUGGER, - user_id=current_user_id, - app_mode=AppMode.value_of(app_model.mode), + +@console_ns.route("/agent//chat-messages//stop") +class AgentChatMessageStopApi(Resource): + @console_ns.doc("stop_agent_chat_message") + @console_ns.doc(description="Stop a running Agent App chat message generation") + @console_ns.doc(params={"agent_id": "Agent ID", "task_id": "Task ID to stop"}) + @console_ns.response(200, "Task stopped successfully", console_ns.models[SimpleResultResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_user_id + @with_current_tenant_id + def post(self, current_tenant_id: str, current_user_id: str, agent_id: UUID, task_id: str): + app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id) + return _stop_chat_message(current_user_id=current_user_id, app_model=app_model, task_id=task_id) + + +def _create_chat_message(*, current_user: Account, app_model: App): + raw_payload = console_ns.payload or {} + args_model = ChatMessagePayload.model_validate(raw_payload) + args = args_model.model_dump(exclude_none=True, by_alias=True) + + streaming = _resolve_debugger_chat_streaming( + app_mode=AppMode.value_of(app_model.mode), + response_mode=args_model.response_mode, + response_mode_provided=isinstance(raw_payload, dict) and "response_mode" in raw_payload, + ) + if AppMode.value_of(app_model.mode) == AppMode.AGENT: + args["response_mode"] = "streaming" + args["auto_generate_name"] = False + + external_trace_id = get_external_trace_id(request) + if external_trace_id: + args["external_trace_id"] = external_trace_id + + try: + response = AppGenerateService.generate( + app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.DEBUGGER, streaming=streaming ) - return {"result": "success"}, 200 + return helper.compact_generate_response(response) + except services.errors.conversation.ConversationNotExistsError: + raise NotFound("Conversation Not Exists.") + except services.errors.conversation.ConversationCompletedError: + raise ConversationCompletedError() + except services.errors.app_model_config.AppModelConfigBrokenError: + logger.exception("App model config broken.") + raise AppUnavailableError() + except ProviderTokenNotInitError as ex: + raise ProviderNotInitializeError(ex.description) + except QuotaExceededError: + raise ProviderQuotaExceededError() + except ModelCurrentlyNotSupportError: + raise ProviderModelCurrentlyNotSupportError() + except InvokeRateLimitError as ex: + raise InvokeRateLimitHttpError(ex.description) + except InvokeError as e: + raise CompletionRequestError(e.description) + except ValueError as e: + raise e + except Exception as e: + logger.exception("internal server error.") + raise InternalServerError() + + +def _stop_chat_message(*, current_user_id: str, app_model: App, task_id: str): + AppTaskService.stop_task( + task_id=task_id, + invoke_from=InvokeFrom.DEBUGGER, + user_id=current_user_id, + app_mode=AppMode.value_of(app_model.mode), + ) + + return {"result": "success"}, 200 diff --git a/api/controllers/console/app/conversation.py b/api/controllers/console/app/conversation.py index 7c20f025680..b9fcf2073d7 100644 --- a/api/controllers/console/app/conversation.py +++ b/api/controllers/console/app/conversation.py @@ -9,7 +9,7 @@ from sqlalchemy import func, or_ from sqlalchemy.orm import selectinload from werkzeug.exceptions import NotFound -from controllers.common.schema import register_schema_models +from controllers.common.schema import query_params_from_model, register_schema_models from controllers.console import console_ns from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( @@ -91,7 +91,7 @@ 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.expect(console_ns.models[CompletionConversationQuery.__name__]) + @console_ns.doc(params=query_params_from_model(CompletionConversationQuery)) @console_ns.response(200, "Success", console_ns.models[ConversationPaginationResponse.__name__]) @console_ns.response(403, "Insufficient permissions") @setup_required @@ -206,7 +206,7 @@ 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.expect(console_ns.models[ChatConversationQuery.__name__]) + @console_ns.doc(params=query_params_from_model(ChatConversationQuery)) @console_ns.response(200, "Success", console_ns.models[ConversationWithSummaryPaginationResponse.__name__]) @console_ns.response(403, "Insufficient permissions") @setup_required diff --git a/api/controllers/console/app/conversation_variables.py b/api/controllers/console/app/conversation_variables.py index beaef482756..9cf3f278eac 100644 --- a/api/controllers/console/app/conversation_variables.py +++ b/api/controllers/console/app/conversation_variables.py @@ -9,7 +9,7 @@ from pydantic import BaseModel, Field, field_validator from sqlalchemy import select from sqlalchemy.orm import sessionmaker -from controllers.common.schema import register_schema_models +from controllers.common.schema import query_params_from_model, register_schema_models from controllers.console import console_ns from controllers.console.app.wraps import get_app_model from controllers.console.wraps import account_initialization_required, setup_required @@ -84,7 +84,7 @@ class ConversationVariablesApi(Resource): @console_ns.doc("get_conversation_variables") @console_ns.doc(description="Get conversation variables for an application") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.expect(console_ns.models[ConversationVariablesQuery.__name__]) + @console_ns.doc(params=query_params_from_model(ConversationVariablesQuery)) @console_ns.response( 200, "Conversation variables retrieved successfully", diff --git a/api/controllers/console/app/generator.py b/api/controllers/console/app/generator.py index e471d0ed88d..1cf0ec82eb7 100644 --- a/api/controllers/console/app/generator.py +++ b/api/controllers/console/app/generator.py @@ -1,11 +1,12 @@ from collections.abc import Sequence -from typing import Literal +from typing import Any, Literal from flask_restx import Resource -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, RootModel from sqlalchemy.orm import Session -from controllers.common.schema import register_enum_models, register_schema_models +from controllers.common.fields import SimpleDataResponse +from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.app.error import ( CompletionRequestError, @@ -36,7 +37,11 @@ class InstructionGeneratePayload(BaseModel): current: str = Field(default="", description="Current instruction text") language: str = Field(default="javascript", description="Programming language (javascript/python)") instruction: str = Field(..., description="Instruction for generation") - model_config_data: ModelConfig = Field(..., alias="model_config", description="Model configuration") + model_config_data: ModelConfig = Field( + ..., + alias="model_config", + description="Model configuration", + ) ideal_output: str = Field(default="", description="Expected ideal output") @@ -44,6 +49,13 @@ class InstructionTemplatePayload(BaseModel): type: str = Field(..., description="Instruction template type") +# Upper bound for the generator's free-text inputs. Generous for prose (a +# detailed instruction rarely passes 2k chars) while keeping the +# planner+builder prompts well inside every mainstream context window. +# Mirrored by the ``maxLength`` on the frontend generator textarea. +_MAX_INSTRUCTION_LENGTH = 10_000 + + class WorkflowGeneratePayload(BaseModel): """Payload for the cmd+k `/create` and `/refine` workflow generator endpoint. @@ -55,13 +67,21 @@ class WorkflowGeneratePayload(BaseModel): mode: Literal["workflow", "advanced-chat"] = Field(..., description="Target app mode for the generated graph") instruction: str = Field(..., description="Natural-language workflow description") ideal_output: str = Field(default="", description="Optional sample output for grounding") - model_config_data: ModelConfig = Field(..., alias="model_config", description="Model configuration") + model_config_data: ModelConfig = Field( + ..., + alias="model_config", + description="Model configuration", + ) current_graph: dict | None = Field( default=None, description="Existing draft graph to refine (cmd+k `/refine`); omit for create-from-scratch", ) +class GeneratorResponse(RootModel[Any]): + root: Any + + register_enum_models(console_ns, LLMMode) register_schema_models( console_ns, @@ -73,6 +93,7 @@ register_schema_models( WorkflowGeneratePayload, ModelConfig, ) +register_response_schema_models(console_ns, GeneratorResponse, SimpleDataResponse) @console_ns.route("/rule-generate") @@ -80,7 +101,11 @@ class RuleGenerateApi(Resource): @console_ns.doc("generate_rule_config") @console_ns.doc(description="Generate rule configuration using LLM") @console_ns.expect(console_ns.models[RuleGeneratePayload.__name__]) - @console_ns.response(200, "Rule configuration generated successfully") + @console_ns.response( + 200, + "Rule configuration generated successfully", + console_ns.models[GeneratorResponse.__name__], + ) @console_ns.response(400, "Invalid request parameters") @console_ns.response(402, "Provider quota exceeded") @setup_required @@ -109,7 +134,7 @@ class RuleCodeGenerateApi(Resource): @console_ns.doc("generate_rule_code") @console_ns.doc(description="Generate code rules using LLM") @console_ns.expect(console_ns.models[RuleCodeGeneratePayload.__name__]) - @console_ns.response(200, "Code rules generated successfully") + @console_ns.response(200, "Code rules generated successfully", console_ns.models[GeneratorResponse.__name__]) @console_ns.response(400, "Invalid request parameters") @console_ns.response(402, "Provider quota exceeded") @setup_required @@ -141,7 +166,7 @@ class RuleStructuredOutputGenerateApi(Resource): @console_ns.doc("generate_structured_output") @console_ns.doc(description="Generate structured output rules using LLM") @console_ns.expect(console_ns.models[RuleStructuredOutputPayload.__name__]) - @console_ns.response(200, "Structured output generated successfully") + @console_ns.response(200, "Structured output generated successfully", console_ns.models[GeneratorResponse.__name__]) @console_ns.response(400, "Invalid request parameters") @console_ns.response(402, "Provider quota exceeded") @setup_required @@ -173,7 +198,7 @@ class InstructionGenerateApi(Resource): @console_ns.doc("generate_instruction") @console_ns.doc(description="Generate instruction for workflow nodes or general use") @console_ns.expect(console_ns.models[InstructionGeneratePayload.__name__]) - @console_ns.response(200, "Instruction generated successfully") + @console_ns.response(200, "Instruction generated successfully", console_ns.models[GeneratorResponse.__name__]) @console_ns.response(400, "Invalid request parameters or flow/workflow not found") @console_ns.response(402, "Provider quota exceeded") @setup_required @@ -268,7 +293,7 @@ class InstructionGenerationTemplateApi(Resource): @console_ns.doc("get_instruction_template") @console_ns.doc(description="Get instruction generation template") @console_ns.expect(console_ns.models[InstructionTemplatePayload.__name__]) - @console_ns.response(200, "Template retrieved successfully") + @console_ns.response(200, "Template retrieved successfully", console_ns.models[SimpleDataResponse.__name__]) @console_ns.response(400, "Invalid request parameters") @setup_required @login_required @@ -300,7 +325,7 @@ class WorkflowGenerateApi(Resource): @console_ns.doc("generate_workflow_graph") @console_ns.doc(description="Generate a Dify workflow graph from natural language") @console_ns.expect(console_ns.models[WorkflowGeneratePayload.__name__]) - @console_ns.response(200, "Workflow graph generated successfully") + @console_ns.response(200, "Workflow graph generated successfully", console_ns.models[GeneratorResponse.__name__]) @console_ns.response(400, "Invalid request parameters") @console_ns.response(402, "Provider quota exceeded") @setup_required @@ -320,6 +345,22 @@ class WorkflowGenerateApi(Resource): "errors": [{"code": "EMPTY_INSTRUCTION", "detail": "Instruction is required"}], }, 400 + # Bound the prompt at the boundary too: an arbitrarily long + # instruction (or pasted document) blows the planner/builder context + # window and fails with an opaque provider error after two slow LLM + # calls. The cap matches the frontend textarea's maxLength. + if len(args.instruction) > _MAX_INSTRUCTION_LENGTH or len(args.ideal_output) > _MAX_INSTRUCTION_LENGTH: + return { + "error": "Instruction is too long", + "errors": [ + { + "code": "INSTRUCTION_TOO_LONG", + "detail": f"Instruction and ideal output must each be at most " + f"{_MAX_INSTRUCTION_LENGTH} characters", + } + ], + }, 400 + try: result = WorkflowGeneratorService.generate_workflow_graph( tenant_id=current_tenant_id, diff --git a/api/controllers/console/app/mcp_server.py b/api/controllers/console/app/mcp_server.py index 157bec4fbcd..8d1eb700739 100644 --- a/api/controllers/console/app/mcp_server.py +++ b/api/controllers/console/app/mcp_server.py @@ -27,13 +27,19 @@ from models.model import App, AppMCPServer class MCPServerCreatePayload(BaseModel): description: str | None = Field(default=None, description="Server description") - parameters: dict[str, Any] = Field(..., description="Server parameters configuration") + parameters: dict[str, Any] = Field( + ..., + description="Server parameters configuration", + ) class MCPServerUpdatePayload(BaseModel): id: str = Field(..., description="Server ID") description: str | None = Field(default=None, description="Server description") - parameters: dict[str, Any] = Field(..., description="Server parameters configuration") + parameters: dict[str, Any] = Field( + ..., + description="Server parameters configuration", + ) status: str | None = Field(default=None, description="Server status") diff --git a/api/controllers/console/app/message.py b/api/controllers/console/app/message.py index 90f30126f43..ef112b1b1e4 100644 --- a/api/controllers/console/app/message.py +++ b/api/controllers/console/app/message.py @@ -10,9 +10,10 @@ from sqlalchemy import exists, func, select from werkzeug.exceptions import InternalServerError, NotFound from controllers.common.controller_schemas import MessageFeedbackPayload as _MessageFeedbackPayloadBase -from controllers.common.fields import SimpleResultResponse -from controllers.common.schema import register_response_schema_models, register_schema_models +from controllers.common.fields import SimpleResultResponse, 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.agent.app_helpers import resolve_agent_app_model from controllers.console.app.error import ( CompletionRequestError, ProviderModelCurrentlyNotSupportError, @@ -25,6 +26,7 @@ from controllers.console.wraps import ( account_initialization_required, edit_permission_required, setup_required, + with_current_tenant_id, with_current_user, ) from core.app.entities.app_invoke_entities import InvokeFrom @@ -166,7 +168,7 @@ register_schema_models( MessageDetailResponse, MessageInfiniteScrollPaginationResponse, ) -register_response_schema_models(console_ns, SimpleResultResponse) +register_response_schema_models(console_ns, SimpleResultResponse, TextFileResponse) @console_ns.route("/apps//chat-messages") @@ -174,7 +176,7 @@ 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.expect(console_ns.models[ChatMessagesQuery.__name__]) + @console_ns.doc(params=query_params_from_model(ChatMessagesQuery)) @console_ns.response(200, "Success", console_ns.models[MessageInfiniteScrollPaginationResponse.__name__]) @console_ns.response(404, "Conversation not found") @login_required @@ -183,67 +185,25 @@ class ChatMessageListApi(Resource): @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT]) @edit_permission_required def get(self, app_model: App): - args = ChatMessagesQuery.model_validate(request.args.to_dict()) + return _list_chat_messages(app_model=app_model) - conversation = db.session.scalar( - select(Conversation) - .where(Conversation.id == args.conversation_id, Conversation.app_id == app_model.id) - .limit(1) - ) - if not conversation: - raise NotFound("Conversation Not Exists.") - - if args.first_id: - first_message = db.session.scalar( - select(Message).where(Message.conversation_id == conversation.id, Message.id == args.first_id).limit(1) - ) - - if not first_message: - raise NotFound("First message not found") - - history_messages = db.session.scalars( - select(Message) - .where( - Message.conversation_id == conversation.id, - Message.created_at < first_message.created_at, - Message.id != first_message.id, - ) - .order_by(Message.created_at.desc()) - .limit(args.limit) - ).all() - else: - history_messages = db.session.scalars( - select(Message) - .where(Message.conversation_id == conversation.id) - .order_by(Message.created_at.desc()) - .limit(args.limit) - ).all() - - # Initialize has_more based on whether we have a full page - if len(history_messages) == args.limit: - current_page_first_message = history_messages[-1] - # Check if there are more messages before the current page - has_more = db.session.scalar( - select( - exists().where( - Message.conversation_id == conversation.id, - Message.created_at < current_page_first_message.created_at, - Message.id != current_page_first_message.id, - ) - ) - ) - else: - # If we don't have a full page, there are no more messages - has_more = False - - history_messages = list(reversed(history_messages)) - attach_message_extra_contents(history_messages) - - return MessageInfiniteScrollPaginationResponse.model_validate( - InfiniteScrollPagination(data=history_messages, limit=args.limit, has_more=has_more), - from_attributes=True, - ).model_dump(mode="json") +@console_ns.route("/agent//chat-messages") +class AgentChatMessageListApi(Resource): + @console_ns.doc("list_agent_chat_messages") + @console_ns.doc(description="Get Agent App chat messages for a conversation with pagination") + @console_ns.doc(params={"agent_id": "Agent ID"}) + @console_ns.doc(params=query_params_from_model(ChatMessagesQuery)) + @console_ns.response(200, "Success", console_ns.models[MessageInfiniteScrollPaginationResponse.__name__]) + @console_ns.response(404, "Agent or conversation not found") + @login_required + @account_initialization_required + @setup_required + @edit_permission_required + @with_current_tenant_id + def get(self, current_tenant_id: str, agent_id: UUID): + app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id) + return _list_chat_messages(app_model=app_model) @console_ns.route("/apps//feedbacks") @@ -261,44 +221,25 @@ class MessageFeedbackApi(Resource): @account_initialization_required @with_current_user def post(self, current_user: Account, app_model: App): - args = MessageFeedbackPayload.model_validate(console_ns.payload) + return _update_message_feedback(current_user=current_user, app_model=app_model) - message_id = str(args.message_id) - message = db.session.scalar( - select(Message).where(Message.id == message_id, Message.app_id == app_model.id).limit(1) - ) - - if not message: - raise NotFound("Message Not Exists.") - - feedback = message.admin_feedback - - if not args.rating and feedback: - db.session.delete(feedback) - elif args.rating and feedback: - feedback.rating = FeedbackRating(args.rating) - feedback.content = args.content - elif not args.rating and not feedback: - raise ValueError("rating cannot be None when feedback not exists") - else: - rating_value = args.rating - if rating_value is None: - raise ValueError("rating is required to create feedback") - feedback = MessageFeedback( - app_id=app_model.id, - conversation_id=message.conversation_id, - message_id=message.id, - rating=FeedbackRating(rating_value), - content=args.content, - from_source=FeedbackFromSource.ADMIN, - from_account_id=current_user.id, - ) - db.session.add(feedback) - - db.session.commit() - - return {"result": "success"} +@console_ns.route("/agent//feedbacks") +class AgentMessageFeedbackApi(Resource): + @console_ns.doc("create_agent_message_feedback") + @console_ns.doc(description="Create or update Agent App message feedback") + @console_ns.doc(params={"agent_id": "Agent ID"}) + @console_ns.expect(console_ns.models[MessageFeedbackPayload.__name__]) + @console_ns.response(200, "Feedback updated successfully", console_ns.models[SimpleResultResponse.__name__]) + @console_ns.response(404, "Agent or message not found") + @setup_required + @login_required + @account_initialization_required + @with_current_user + @with_current_tenant_id + def post(self, current_tenant_id: str, current_user: Account, agent_id: UUID): + app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id) + return _update_message_feedback(current_user=current_user, app_model=app_model) @console_ns.route("/apps//annotations/count") @@ -340,31 +281,28 @@ class MessageSuggestedQuestionApi(Resource): @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT]) @with_current_user def get(self, current_user: Account, app_model: App, message_id: UUID): - message_id_str = str(message_id) + return _get_message_suggested_questions(current_user=current_user, app_model=app_model, message_id=message_id) - try: - questions = MessageService.get_suggested_questions_after_answer( - app_model=app_model, message_id=message_id_str, user=current_user, invoke_from=InvokeFrom.DEBUGGER - ) - except MessageNotExistsError: - raise NotFound("Message not found") - except ConversationNotExistsError: - raise NotFound("Conversation not found") - except ProviderTokenNotInitError as ex: - raise ProviderNotInitializeError(ex.description) - except QuotaExceededError: - raise ProviderQuotaExceededError() - except ModelCurrentlyNotSupportError: - raise ProviderModelCurrentlyNotSupportError() - except InvokeError as e: - raise CompletionRequestError(e.description) - except SuggestedQuestionsAfterAnswerDisabledError: - raise AppSuggestedQuestionsAfterAnswerDisabledError() - except Exception: - logger.exception("internal server error.") - raise InternalServerError() - return {"data": questions} +@console_ns.route("/agent//chat-messages//suggested-questions") +class AgentMessageSuggestedQuestionApi(Resource): + @console_ns.doc("get_agent_message_suggested_questions") + @console_ns.doc(description="Get suggested questions for an Agent App message") + @console_ns.doc(params={"agent_id": "Agent ID", "message_id": "Message ID"}) + @console_ns.response( + 200, + "Suggested questions retrieved successfully", + console_ns.models[SuggestedQuestionsResponse.__name__], + ) + @console_ns.response(404, "Agent, message, or conversation not found") + @setup_required + @login_required + @account_initialization_required + @with_current_user + @with_current_tenant_id + def get(self, current_tenant_id: str, current_user: Account, agent_id: UUID, message_id: UUID): + app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id) + return _get_message_suggested_questions(current_user=current_user, app_model=app_model, message_id=message_id) @console_ns.route("/apps//feedbacks/export") @@ -372,8 +310,12 @@ 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.expect(console_ns.models[FeedbackExportQuery.__name__]) - @console_ns.response(200, "Feedback data exported successfully") + @console_ns.doc(params=query_params_from_model(FeedbackExportQuery)) + @console_ns.response( + 200, + "Feedback data exported successfully", + console_ns.models[TextFileResponse.__name__], + ) @console_ns.response(400, "Invalid parameters") @console_ns.response(500, "Internal server error") @get_app_model @@ -419,14 +361,167 @@ class MessageApi(Resource): @login_required @account_initialization_required def get(self, app_model: App, message_id: UUID): - message_id_str = str(message_id) + return _get_message_detail(app_model=app_model, message_id=message_id) - message = db.session.scalar( - select(Message).where(Message.id == message_id_str, Message.app_id == app_model.id).limit(1) + +@console_ns.route("/agent//messages/") +class AgentMessageApi(Resource): + @console_ns.doc("get_agent_message") + @console_ns.doc(description="Get Agent App message details by ID") + @console_ns.doc(params={"agent_id": "Agent ID", "message_id": "Message ID"}) + @console_ns.response(200, "Message retrieved successfully", console_ns.models[MessageDetailResponse.__name__]) + @console_ns.response(404, "Agent or message not found") + @setup_required + @login_required + @account_initialization_required + @with_current_tenant_id + def get(self, current_tenant_id: str, agent_id: UUID, message_id: UUID): + app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id) + return _get_message_detail(app_model=app_model, message_id=message_id) + + +def _list_chat_messages(*, app_model: App): + args = ChatMessagesQuery.model_validate(request.args.to_dict()) + + conversation = db.session.scalar( + select(Conversation) + .where(Conversation.id == args.conversation_id, Conversation.app_id == app_model.id) + .limit(1) + ) + + if not conversation: + raise NotFound("Conversation Not Exists.") + + if args.first_id: + first_message = db.session.scalar( + select(Message).where(Message.conversation_id == conversation.id, Message.id == args.first_id).limit(1) ) - if not message: - raise NotFound("Message Not Exists.") + if not first_message: + raise NotFound("First message not found") - attach_message_extra_contents([message]) - return MessageDetailResponse.model_validate(message, from_attributes=True).model_dump(mode="json") + history_messages = db.session.scalars( + select(Message) + .where( + Message.conversation_id == conversation.id, + Message.created_at < first_message.created_at, + Message.id != first_message.id, + ) + .order_by(Message.created_at.desc()) + .limit(args.limit) + ).all() + else: + history_messages = db.session.scalars( + select(Message) + .where(Message.conversation_id == conversation.id) + .order_by(Message.created_at.desc()) + .limit(args.limit) + ).all() + + # Initialize has_more based on whether we have a full page + if len(history_messages) == args.limit: + current_page_first_message = history_messages[-1] + # Check if there are more messages before the current page + has_more = db.session.scalar( + select( + exists().where( + Message.conversation_id == conversation.id, + Message.created_at < current_page_first_message.created_at, + Message.id != current_page_first_message.id, + ) + ) + ) + else: + # If we don't have a full page, there are no more messages + has_more = False + + history_messages = list(reversed(history_messages)) + attach_message_extra_contents(history_messages) + + return MessageInfiniteScrollPaginationResponse.model_validate( + 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 = db.session.scalar( + select(Message).where(Message.id == message_id, Message.app_id == app_model.id).limit(1) + ) + + if not message: + raise NotFound("Message Not Exists.") + + feedback = message.admin_feedback + + if not args.rating and feedback: + db.session.delete(feedback) + elif args.rating and feedback: + feedback.rating = FeedbackRating(args.rating) + feedback.content = args.content + elif not args.rating and not feedback: + raise ValueError("rating cannot be None when feedback not exists") + else: + rating_value = args.rating + if rating_value is None: + raise ValueError("rating is required to create feedback") + feedback = MessageFeedback( + app_id=app_model.id, + conversation_id=message.conversation_id, + message_id=message.id, + rating=FeedbackRating(rating_value), + content=args.content, + from_source=FeedbackFromSource.ADMIN, + from_account_id=current_user.id, + ) + db.session.add(feedback) + + db.session.commit() + + return {"result": "success"} + + +def _get_message_suggested_questions(*, current_user: Account, app_model: App, message_id: UUID): + message_id_str = str(message_id) + + try: + questions = MessageService.get_suggested_questions_after_answer( + app_model=app_model, message_id=message_id_str, user=current_user, invoke_from=InvokeFrom.DEBUGGER + ) + except MessageNotExistsError: + raise NotFound("Message not found") + except ConversationNotExistsError: + raise NotFound("Conversation not found") + except ProviderTokenNotInitError as ex: + raise ProviderNotInitializeError(ex.description) + except QuotaExceededError: + raise ProviderQuotaExceededError() + except ModelCurrentlyNotSupportError: + raise ProviderModelCurrentlyNotSupportError() + except InvokeError as e: + raise CompletionRequestError(e.description) + except SuggestedQuestionsAfterAnswerDisabledError: + raise AppSuggestedQuestionsAfterAnswerDisabledError() + except Exception: + logger.exception("internal server error.") + raise InternalServerError() + + return {"data": questions} + + +def _get_message_detail(*, app_model: App, message_id: UUID): + message_id_str = str(message_id) + + message = db.session.scalar( + select(Message).where(Message.id == message_id_str, Message.app_id == app_model.id).limit(1) + ) + + if not message: + raise NotFound("Message Not Exists.") + + attach_message_extra_contents([message]) + return MessageDetailResponse.model_validate(message, from_attributes=True).model_dump(mode="json") diff --git a/api/controllers/console/app/model_config.py b/api/controllers/console/app/model_config.py index 8951a715102..6e2e20c0a35 100644 --- a/api/controllers/console/app/model_config.py +++ b/api/controllers/console/app/model_config.py @@ -5,7 +5,8 @@ from flask import request from flask_restx import Resource from pydantic import BaseModel, Field -from controllers.common.schema import register_schema_models +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.app.wraps import get_app_model from controllers.console.wraps import ( @@ -29,19 +30,44 @@ from services.app_model_config_service import AppModelConfigService class ModelConfigRequest(BaseModel): provider: str | None = Field(default=None, description="Model provider") model: str | None = Field(default=None, description="Model name") - configs: dict[str, Any] | None = Field(default=None, description="Model configuration parameters") + configs: dict[str, Any] | None = Field( + default=None, + description="Model configuration parameters", + ) opening_statement: str | None = Field(default=None, description="Opening statement") suggested_questions: list[str] | None = Field(default=None, description="Suggested questions") - more_like_this: dict[str, Any] | None = Field(default=None, description="More like this configuration") - speech_to_text: dict[str, Any] | None = Field(default=None, description="Speech to text configuration") - text_to_speech: dict[str, Any] | None = Field(default=None, description="Text to speech configuration") - retrieval_model: dict[str, Any] | None = Field(default=None, description="Retrieval model configuration") - tools: list[dict[str, Any]] | None = Field(default=None, description="Available tools") - dataset_configs: dict[str, Any] | None = Field(default=None, description="Dataset configurations") - agent_mode: dict[str, Any] | None = Field(default=None, description="Agent mode configuration") + more_like_this: dict[str, Any] | None = Field( + default=None, + description="More like this configuration", + ) + speech_to_text: dict[str, Any] | None = Field( + default=None, + description="Speech to text configuration", + ) + text_to_speech: dict[str, Any] | None = Field( + default=None, + description="Text to speech configuration", + ) + retrieval_model: dict[str, Any] | None = Field( + default=None, + description="Retrieval model configuration", + ) + tools: list[dict[str, Any]] | None = Field( + default=None, + description="Available tools", + ) + dataset_configs: dict[str, Any] | None = Field( + default=None, + description="Dataset configurations", + ) + agent_mode: dict[str, Any] | None = Field( + default=None, + description="Agent mode configuration", + ) register_schema_models(console_ns, ModelConfigRequest) +register_response_schema_models(console_ns, SimpleResultResponse) @console_ns.route("/apps//model-config") @@ -50,7 +76,11 @@ class ModelConfigResource(Resource): @console_ns.doc(description="Update application model configuration") @console_ns.doc(params={"app_id": "Application ID"}) @console_ns.expect(console_ns.models[ModelConfigRequest.__name__]) - @console_ns.response(200, "Model configuration updated successfully") + @console_ns.response( + 200, + "Model configuration updated successfully", + console_ns.models[SimpleResultResponse.__name__], + ) @console_ns.response(400, "Invalid configuration") @console_ns.response(404, "App not found") @setup_required diff --git a/api/controllers/console/app/ops_trace.py b/api/controllers/console/app/ops_trace.py index a1123a580ea..c9f9308c2ea 100644 --- a/api/controllers/console/app/ops_trace.py +++ b/api/controllers/console/app/ops_trace.py @@ -1,15 +1,16 @@ from typing import Any from flask import request -from flask_restx import Resource, fields +from flask_restx import Resource from pydantic import BaseModel, Field from werkzeug.exceptions import BadRequest -from controllers.common.schema import 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.error import TracingConfigCheckError, TracingConfigIsExist, TracingConfigNotExist from controllers.console.app.wraps import get_app_model from controllers.console.wraps import account_initialization_required, setup_required +from fields.base import ResponseModel from libs.login import login_required from models import App from services.ops_service import OpsService @@ -21,10 +22,27 @@ class TraceProviderQuery(BaseModel): class TraceConfigPayload(BaseModel): tracing_provider: str = Field(..., description="Tracing provider name") - tracing_config: dict[str, Any] = Field(..., description="Tracing configuration data") + tracing_config: dict[str, Any] = Field( + ..., + description="Tracing configuration data", + ) + + +class TraceAppConfigResponse(ResponseModel): + result: str | None = None + error: str | None = None + has_not_configured: bool | None = None + id: str | None = None + app_id: str | None = None + tracing_provider: str | None = None + tracing_config: dict[str, Any] | None = Field(default=None) + is_active: bool | None = None + created_at: str | None = None + updated_at: str | None = None register_schema_models(console_ns, TraceProviderQuery, TraceConfigPayload) +register_response_schema_models(console_ns, TraceAppConfigResponse) @console_ns.route("/apps//trace-config") @@ -36,9 +54,11 @@ class TraceAppConfigApi(Resource): @console_ns.doc("get_trace_app_config") @console_ns.doc(description="Get tracing configuration for an application") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.expect(console_ns.models[TraceProviderQuery.__name__]) + @console_ns.doc(params=query_params_from_model(TraceProviderQuery)) @console_ns.response( - 200, "Tracing configuration retrieved successfully", fields.Raw(description="Tracing configuration data") + 200, + "Tracing configuration retrieved successfully", + console_ns.models[TraceAppConfigResponse.__name__], ) @console_ns.response(400, "Invalid request parameters") @setup_required @@ -63,7 +83,9 @@ class TraceAppConfigApi(Resource): @console_ns.doc(params={"app_id": "Application ID"}) @console_ns.expect(console_ns.models[TraceConfigPayload.__name__]) @console_ns.response( - 201, "Tracing configuration created successfully", fields.Raw(description="Created configuration data") + 201, + "Tracing configuration created successfully", + console_ns.models[TraceAppConfigResponse.__name__], ) @console_ns.response(400, "Invalid request parameters or configuration already exists") @setup_required @@ -90,7 +112,11 @@ class TraceAppConfigApi(Resource): @console_ns.doc(description="Update an existing tracing configuration for an application") @console_ns.doc(params={"app_id": "Application ID"}) @console_ns.expect(console_ns.models[TraceConfigPayload.__name__]) - @console_ns.response(200, "Tracing configuration updated successfully", fields.Raw(description="Success response")) + @console_ns.response( + 200, + "Tracing configuration updated successfully", + console_ns.models[TraceAppConfigResponse.__name__], + ) @console_ns.response(400, "Invalid request parameters or configuration not found") @setup_required @login_required @@ -113,7 +139,7 @@ class TraceAppConfigApi(Resource): @console_ns.doc("delete_trace_app_config") @console_ns.doc(description="Delete an existing tracing configuration for an application") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.expect(console_ns.models[TraceProviderQuery.__name__]) + @console_ns.doc(params=query_params_from_model(TraceProviderQuery)) @console_ns.response(204, "Tracing configuration deleted successfully") @console_ns.response(400, "Invalid request parameters or configuration not found") @setup_required diff --git a/api/controllers/console/app/statistic.py b/api/controllers/console/app/statistic.py index 2595ed00813..bc0120fe4f8 100644 --- a/api/controllers/console/app/statistic.py +++ b/api/controllers/console/app/statistic.py @@ -2,15 +2,16 @@ from decimal import Decimal import sqlalchemy as sa from flask import abort, jsonify, request -from flask_restx import Resource, fields +from flask_restx import Resource from pydantic import BaseModel, Field, field_validator -from controllers.common.schema import 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 account_initialization_required, setup_required, with_current_user from core.app.entities.app_invoke_entities import InvokeFrom 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.login import login_required @@ -31,7 +32,92 @@ class StatisticTimeRangeQuery(BaseModel): return value +class DailyMessageStatisticItem(ResponseModel): + date: str + message_count: int + + +class DailyMessageStatisticResponse(ResponseModel): + data: list[DailyMessageStatisticItem] + + +class DailyConversationStatisticItem(ResponseModel): + date: str + conversation_count: int + + +class DailyConversationStatisticResponse(ResponseModel): + data: list[DailyConversationStatisticItem] + + +class DailyTerminalStatisticItem(ResponseModel): + date: str + terminal_count: int + + +class DailyTerminalStatisticResponse(ResponseModel): + data: list[DailyTerminalStatisticItem] + + +class DailyTokenCostStatisticItem(ResponseModel): + date: str + token_count: int + total_price: str | float + currency: str + + +class DailyTokenCostStatisticResponse(ResponseModel): + data: list[DailyTokenCostStatisticItem] + + +class AverageSessionInteractionStatisticItem(ResponseModel): + date: str + interactions: float + + +class AverageSessionInteractionStatisticResponse(ResponseModel): + data: list[AverageSessionInteractionStatisticItem] + + +class UserSatisfactionRateStatisticItem(ResponseModel): + date: str + rate: float + + +class UserSatisfactionRateStatisticResponse(ResponseModel): + data: list[UserSatisfactionRateStatisticItem] + + +class AverageResponseTimeStatisticItem(ResponseModel): + date: str + latency: float + + +class AverageResponseTimeStatisticResponse(ResponseModel): + data: list[AverageResponseTimeStatisticItem] + + +class TokensPerSecondStatisticItem(ResponseModel): + date: str + tps: float + + +class TokensPerSecondStatisticResponse(ResponseModel): + data: list[TokensPerSecondStatisticItem] + + register_schema_models(console_ns, StatisticTimeRangeQuery) +register_response_schema_models( + console_ns, + DailyMessageStatisticResponse, + DailyConversationStatisticResponse, + DailyTerminalStatisticResponse, + DailyTokenCostStatisticResponse, + AverageSessionInteractionStatisticResponse, + UserSatisfactionRateStatisticResponse, + AverageResponseTimeStatisticResponse, + TokensPerSecondStatisticResponse, +) @console_ns.route("/apps//statistics/daily-messages") @@ -39,11 +125,11 @@ 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.expect(console_ns.models[StatisticTimeRangeQuery.__name__]) + @console_ns.doc(params=query_params_from_model(StatisticTimeRangeQuery)) @console_ns.response( 200, "Daily message statistics retrieved successfully", - fields.List(fields.Raw(description="Daily message count data")), + console_ns.models[DailyMessageStatisticResponse.__name__], ) @get_app_model @setup_required @@ -99,11 +185,11 @@ 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.expect(console_ns.models[StatisticTimeRangeQuery.__name__]) + @console_ns.doc(params=query_params_from_model(StatisticTimeRangeQuery)) @console_ns.response( 200, "Daily conversation statistics retrieved successfully", - fields.List(fields.Raw(description="Daily conversation count data")), + console_ns.models[DailyConversationStatisticResponse.__name__], ) @get_app_model @setup_required @@ -158,11 +244,11 @@ 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.expect(console_ns.models[StatisticTimeRangeQuery.__name__]) + @console_ns.doc(params=query_params_from_model(StatisticTimeRangeQuery)) @console_ns.response( 200, "Daily terminal statistics retrieved successfully", - fields.List(fields.Raw(description="Daily terminal count data")), + console_ns.models[DailyTerminalStatisticResponse.__name__], ) @get_app_model @setup_required @@ -218,11 +304,11 @@ 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.expect(console_ns.models[StatisticTimeRangeQuery.__name__]) + @console_ns.doc(params=query_params_from_model(StatisticTimeRangeQuery)) @console_ns.response( 200, "Daily token cost statistics retrieved successfully", - fields.List(fields.Raw(description="Daily token cost data")), + console_ns.models[DailyTokenCostStatisticResponse.__name__], ) @get_app_model @setup_required @@ -281,11 +367,11 @@ 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.expect(console_ns.models[StatisticTimeRangeQuery.__name__]) + @console_ns.doc(params=query_params_from_model(StatisticTimeRangeQuery)) @console_ns.response( 200, "Average session interaction statistics retrieved successfully", - fields.List(fields.Raw(description="Average session interaction data")), + console_ns.models[AverageSessionInteractionStatisticResponse.__name__], ) @setup_required @login_required @@ -360,11 +446,11 @@ 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.expect(console_ns.models[StatisticTimeRangeQuery.__name__]) + @console_ns.doc(params=query_params_from_model(StatisticTimeRangeQuery)) @console_ns.response( 200, "User satisfaction rate statistics retrieved successfully", - fields.List(fields.Raw(description="User satisfaction rate data")), + console_ns.models[UserSatisfactionRateStatisticResponse.__name__], ) @get_app_model @setup_required @@ -429,11 +515,11 @@ 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.expect(console_ns.models[StatisticTimeRangeQuery.__name__]) + @console_ns.doc(params=query_params_from_model(StatisticTimeRangeQuery)) @console_ns.response( 200, "Average response time statistics retrieved successfully", - fields.List(fields.Raw(description="Average response time data")), + console_ns.models[AverageResponseTimeStatisticResponse.__name__], ) @setup_required @login_required @@ -489,11 +575,11 @@ 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.expect(console_ns.models[StatisticTimeRangeQuery.__name__]) + @console_ns.doc(params=query_params_from_model(StatisticTimeRangeQuery)) @console_ns.response( 200, "Tokens per second statistics retrieved successfully", - fields.List(fields.Raw(description="Tokens per second data")), + console_ns.models[TokensPerSecondStatisticResponse.__name__], ) @get_app_model @setup_required diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index 6a14937ffa0..a8969f4d5ec 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -2,19 +2,20 @@ import json import logging from collections.abc import Sequence from datetime import datetime -from typing import Any, NotRequired, TypedDict +from typing import Any, NotRequired, TypedDict, cast from flask import abort, request from flask_restx import Resource, fields -from pydantic import AliasChoices, BaseModel, Field, ValidationError, field_validator -from sqlalchemy.orm import sessionmaker +from pydantic import AliasChoices, BaseModel, Field, RootModel, 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 NewAppResponse, SimpleResultResponse +from controllers.common.fields import GeneratedAppResponse, NewAppResponse, SimpleResultResponse from controllers.common.schema import ( + query_params_from_model, register_response_schema_model, register_response_schema_models, register_schema_models, @@ -98,16 +99,20 @@ 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 = None + files: list[dict[str, Any]] | None = Field(default=None) class AdvancedChatWorkflowRunPayload(BaseWorkflowRunPayload): - inputs: dict[str, Any] | None = None + inputs: dict[str, Any] | None = Field(default=None) query: str = "" conversation_id: str | None = None parent_message_id: str | None = None @@ -121,11 +126,11 @@ class AdvancedChatWorkflowRunPayload(BaseWorkflowRunPayload): class IterationNodeRunPayload(BaseModel): - inputs: dict[str, Any] | None = None + inputs: dict[str, Any] | None = Field(default=None) class LoopNodeRunPayload(BaseModel): - inputs: dict[str, Any] | None = None + inputs: dict[str, Any] | None = Field(default=None) class DraftWorkflowRunPayload(BaseWorkflowRunPayload): @@ -150,7 +155,10 @@ 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): @@ -166,7 +174,7 @@ class WorkflowConversationVariableResponse(ResponseModel): id: str name: str value_type: str - value: Any = Field(json_schema_extra={"type": "object"}) + value: Any description: str @field_validator("value_type", mode="before") @@ -185,7 +193,7 @@ class PipelineVariableResponse(ResponseModel): max_length: int | None = None required: bool unit: str | None = None - default_value: Any = Field(default=None, json_schema_extra={"type": "object"}) + default_value: Any = Field(default=None) options: list[str] | None = None placeholder: str | None = None tooltips: str | None = None @@ -202,14 +210,18 @@ class WorkflowEnvironmentVariableResponse(ResponseModel): value_type: str id: str name: str - value: Any = Field(json_schema_extra={"type": "object"}) + value: Any description: str 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 @@ -266,6 +278,46 @@ class WorkflowOnlineUsersResponse(ResponseModel): data: list[WorkflowOnlineUsersByApp] +class WorkflowPublishResponse(ResponseModel): + result: str + created_at: int + + +class WorkflowRestoreResponse(ResponseModel): + result: str + hash: str + updated_at: int + + +class DefaultBlockConfigsResponse(RootModel[list[dict[str, Any]]]): + root: list[dict[str, Any]] + + +class DefaultBlockConfigResponse(RootModel[dict[str, Any]]): + root: dict[str, Any] + + +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 HumanInputFormSubmitResponse(RootModel[dict[str, Any]]): + root: dict[str, Any] + + +class EmptyObjectResponse(RootModel[dict[str, Any]]): + root: dict[str, Any] + + class DraftWorkflowTriggerRunPayload(BaseModel): node_id: str @@ -303,6 +355,14 @@ register_response_schema_models( WorkflowOnlineUser, WorkflowOnlineUsersByApp, WorkflowOnlineUsersResponse, + WorkflowPublishResponse, + WorkflowRestoreResponse, + DefaultBlockConfigsResponse, + DefaultBlockConfigResponse, + HumanInputFormPreviewResponse, + HumanInputFormSubmitResponse, + EmptyObjectResponse, + GeneratedAppResponse, NewAppResponse, SimpleResultResponse, ) @@ -389,8 +449,16 @@ class DraftWorkflowApi(Resource): if not workflow: raise DraftWorkflowNotExist() - # return workflow, if not found, return 404 - return dump_response(WorkflowResponse, workflow) + from services.agent.workflow_publish_service import WorkflowAgentPublishService + + # Return workflow with response-only Agent node job projection so the + # front-end can treat draft graph node data as the editing source. + response = WorkflowResponse.model_validate(workflow, from_attributes=True).model_dump(mode="json") + response["graph"] = WorkflowAgentPublishService.project_draft_bindings_to_graph( + session=cast(Session, db.session), + draft_workflow=workflow, + ) + return response @setup_required @login_required @@ -474,7 +542,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.response(200, "Workflow run started successfully", console_ns.models[GeneratedAppResponse.__name__]) @console_ns.response(400, "Invalid request parameters") @console_ns.response(403, "Permission denied") @setup_required @@ -519,7 +587,11 @@ 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.response( + 200, + "Iteration node run started successfully", + console_ns.models[GeneratedAppResponse.__name__], + ) @console_ns.response(403, "Permission denied") @console_ns.response(404, "Node not found") @setup_required @@ -557,7 +629,11 @@ 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.response( + 200, + "Workflow iteration node run started successfully", + console_ns.models[GeneratedAppResponse.__name__], + ) @console_ns.response(403, "Permission denied") @console_ns.response(404, "Node not found") @setup_required @@ -595,7 +671,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.response(200, "Loop node run started successfully", console_ns.models[GeneratedAppResponse.__name__]) @console_ns.response(403, "Permission denied") @console_ns.response(404, "Node not found") @setup_required @@ -633,7 +709,11 @@ 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.response( + 200, + "Workflow loop node run started successfully", + console_ns.models[GeneratedAppResponse.__name__], + ) @console_ns.response(403, "Permission denied") @console_ns.response(404, "Node not found") @setup_required @@ -673,7 +753,10 @@ 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", @@ -703,6 +786,7 @@ 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__]) @setup_required @login_required @account_initialization_required @@ -732,6 +816,11 @@ class AdvancedChatDraftHumanInputFormRunApi(Resource): @console_ns.doc(description="Submit 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[HumanInputFormSubmitPayload.__name__]) + @console_ns.response( + 200, + "Human input form submission result", + console_ns.models[HumanInputFormSubmitResponse.__name__], + ) @setup_required @login_required @account_initialization_required @@ -761,6 +850,7 @@ 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__]) @setup_required @login_required @account_initialization_required @@ -790,6 +880,11 @@ class WorkflowDraftHumanInputFormRunApi(Resource): @console_ns.doc(description="Submit human input form preview for workflow") @console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"}) @console_ns.expect(console_ns.models[HumanInputFormSubmitPayload.__name__]) + @console_ns.response( + 200, + "Human input form submission result", + console_ns.models[HumanInputFormSubmitResponse.__name__], + ) @setup_required @login_required @account_initialization_required @@ -819,6 +914,7 @@ 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__]) @setup_required @login_required @account_initialization_required @@ -847,7 +943,11 @@ 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.response( + 200, + "Draft workflow run started successfully", + console_ns.models[GeneratedAppResponse.__name__], + ) @console_ns.response(403, "Permission denied") @setup_required @login_required @@ -989,6 +1089,7 @@ class PublishedWorkflowApi(Resource): 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__]) @setup_required @login_required @account_initialization_required @@ -1032,7 +1133,11 @@ class DefaultBlockConfigsApi(Resource): @console_ns.doc("get_default_block_configs") @console_ns.doc(description="Get default block configurations for workflow") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.response(200, "Default block configurations retrieved successfully") + @console_ns.response( + 200, + "Default block configurations retrieved successfully", + console_ns.models[DefaultBlockConfigsResponse.__name__], + ) @setup_required @login_required @account_initialization_required @@ -1052,9 +1157,13 @@ 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.response(200, "Default block configuration retrieved successfully") + @console_ns.response( + 200, + "Default block configuration retrieved successfully", + console_ns.models[DefaultBlockConfigResponse.__name__], + ) @console_ns.response(404, "Block type not found") - @console_ns.expect(console_ns.models[DefaultBlockConfigQuery.__name__]) + @console_ns.doc(params=query_params_from_model(DefaultBlockConfigQuery)) @setup_required @login_required @account_initialization_required @@ -1149,7 +1258,7 @@ class WorkflowFeaturesApi(Resource): @console_ns.route("/apps//workflows") class PublishedAllWorkflowApi(Resource): - @console_ns.expect(console_ns.models[WorkflowListQuery.__name__]) + @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"}) @@ -1204,7 +1313,7 @@ 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.response(200, "Workflow restored successfully", console_ns.models[WorkflowRestoreResponse.__name__]) @console_ns.response(400, "Source workflow must be published") @console_ns.response(404, "Workflow not found") @setup_required @@ -1289,6 +1398,7 @@ class WorkflowByIdApi(Resource): @account_initialization_required @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @edit_permission_required + @console_ns.response(204, "Workflow deleted successfully") def delete(self, app_model: App, workflow_id: str): """ Delete workflow @@ -1360,7 +1470,11 @@ class DraftWorkflowTriggerRunApi(Resource): }, ) ) - @console_ns.response(200, "Trigger event received and workflow executed successfully") + @console_ns.response( + 200, + "Trigger event received and workflow executed successfully", + console_ns.models[GeneratedAppResponse.__name__], + ) @console_ns.response(403, "Permission denied") @console_ns.response(500, "Internal server error") @setup_required @@ -1424,7 +1538,11 @@ class DraftWorkflowTriggerNodeApi(Resource): @console_ns.doc("poll_draft_workflow_trigger_node") @console_ns.doc(description="Poll for trigger events and execute single node when event arrives") @console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"}) - @console_ns.response(200, "Trigger event received and node executed successfully") + @console_ns.response( + 200, + "Trigger event received and node executed successfully", + console_ns.models[GeneratedAppResponse.__name__], + ) @console_ns.response(403, "Permission denied") @console_ns.response(500, "Internal server error") @setup_required @@ -1504,7 +1622,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.response(200, "Workflow executed successfully", console_ns.models[GeneratedAppResponse.__name__]) @console_ns.response(403, "Permission denied") @console_ns.response(500, "Internal server error") @setup_required diff --git a/api/controllers/console/app/workflow_app_log.py b/api/controllers/console/app/workflow_app_log.py index dec183a3004..72bececd999 100644 --- a/api/controllers/console/app/workflow_app_log.py +++ b/api/controllers/console/app/workflow_app_log.py @@ -7,7 +7,7 @@ from flask_restx import Resource from pydantic import BaseModel, Field, field_validator from sqlalchemy.orm import sessionmaker -from controllers.common.schema import register_schema_models +from controllers.common.schema import query_params_from_model, register_schema_models from controllers.console import console_ns from controllers.console.app.wraps import get_app_model from controllers.console.wraps import account_initialization_required, setup_required @@ -166,7 +166,7 @@ class WorkflowAppLogApi(Resource): @console_ns.doc("get_workflow_app_logs") @console_ns.doc(description="Get workflow application execution logs") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.expect(console_ns.models[WorkflowAppLogQuery.__name__]) + @console_ns.doc(params=query_params_from_model(WorkflowAppLogQuery)) @console_ns.response( 200, "Workflow app logs retrieved successfully", @@ -209,7 +209,7 @@ class WorkflowArchivedLogApi(Resource): @console_ns.doc("get_workflow_archived_logs") @console_ns.doc(description="Get workflow archived execution logs") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.expect(console_ns.models[WorkflowAppLogQuery.__name__]) + @console_ns.doc(params=query_params_from_model(WorkflowAppLogQuery)) @console_ns.response( 200, "Workflow archived logs retrieved successfully", diff --git a/api/controllers/console/app/workflow_draft_variable.py b/api/controllers/console/app/workflow_draft_variable.py index 8ebd65eccf2..11411115c1b 100644 --- a/api/controllers/console/app/workflow_draft_variable.py +++ b/api/controllers/console/app/workflow_draft_variable.py @@ -1,7 +1,7 @@ import logging from collections.abc import Callable from functools import wraps -from typing import Any, Concatenate, TypedDict +from typing import Any, Concatenate, TypedDict, override from uuid import UUID from flask import Response, request @@ -10,7 +10,8 @@ from pydantic import BaseModel, Field from sqlalchemy.orm import sessionmaker from controllers.common.errors import InvalidArgumentError, NotFoundError -from controllers.common.schema import register_schema_models +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.app.error import ( DraftWorkflowNotExist, @@ -28,6 +29,7 @@ from extensions.ext_database import db from factories import variable_factory 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.file import helpers as file_helpers from graphon.variables.segment_group import SegmentGroup from graphon.variables.segments import ArrayFileSegment, FileSegment, Segment @@ -42,6 +44,28 @@ logger = logging.getLogger(__name__) _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") @@ -54,12 +78,33 @@ class WorkflowDraftVariableUpdatePayload(BaseModel): 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 EnvironmentVariableItemResponse(ResponseModel): + id: str + type: str + name: str + description: str | None = None + selector: list[str] + value_type: str + value: Any + edited: bool + visible: bool + editable: bool + + +class EnvironmentVariableListResponse(ResponseModel): + items: list[EnvironmentVariableItemResponse] register_schema_models( @@ -69,6 +114,7 @@ register_schema_models( ConversationVariableUpdatePayload, EnvironmentVariableUpdatePayload, ) +register_response_schema_models(console_ns, SimpleResultResponse, EnvironmentVariableListResponse) def _convert_values_to_json_serializable_object(value: Segment): @@ -155,8 +201,8 @@ _WORKFLOW_DRAFT_VARIABLE_WITHOUT_VALUE_FIELDS = { _WORKFLOW_DRAFT_VARIABLE_FIELDS = { **_WORKFLOW_DRAFT_VARIABLE_WITHOUT_VALUE_FIELDS, - "value": fields.Raw(attribute=_serialize_var_value), - "full_content": fields.Raw(attribute=_serialize_full_content), + "value": JsonValueRawField(attribute=_serialize_var_value), + "full_content": OpaqueRawField(attribute=_serialize_full_content), } _WORKFLOW_DRAFT_ENV_VARIABLE_FIELDS = { @@ -181,7 +227,7 @@ def _get_items(var_list: WorkflowDraftVariableList) -> list[WorkflowDraftVariabl _WORKFLOW_DRAFT_VARIABLE_LIST_WITHOUT_VALUE_FIELDS = { "items": fields.List(fields.Nested(_WORKFLOW_DRAFT_VARIABLE_WITHOUT_VALUE_FIELDS), attribute=_get_items), - "total": fields.Raw(), + "total": fields.Integer, } _WORKFLOW_DRAFT_VARIABLE_LIST_FIELDS = { @@ -248,7 +294,7 @@ def _api_prerequisite[T, **P, R]( @console_ns.route("/apps//workflows/draft/variables") class WorkflowVariableCollectionApi(Resource): - @console_ns.expect(console_ns.models[WorkflowDraftVariableListQuery.__name__]) + @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"}) @@ -544,7 +590,11 @@ class ConversationVariableCollectionApi(Resource): @console_ns.doc("update_conversation_variables") @console_ns.doc(description="Update conversation variables for workflow draft") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.response(200, "Conversation variables updated successfully") + @console_ns.response( + 200, + "Conversation variables updated successfully", + console_ns.models[SimpleResultResponse.__name__], + ) @setup_required @login_required @account_initialization_required @@ -587,7 +637,11 @@ class EnvironmentVariableCollectionApi(Resource): @console_ns.doc("get_environment_variables") @console_ns.doc(description="Get environment variables for workflow") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.response(200, "Environment variables retrieved successfully") + @console_ns.response( + 200, + "Environment variables retrieved successfully", + console_ns.models[EnvironmentVariableListResponse.__name__], + ) @console_ns.response(404, "Draft workflow not found") @_api_prerequisite def get(self, _current_user: Account, app_model: App): @@ -625,7 +679,11 @@ class EnvironmentVariableCollectionApi(Resource): @console_ns.doc("update_environment_variables") @console_ns.doc(description="Update environment variables for workflow draft") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.response(200, "Environment variables updated successfully") + @console_ns.response( + 200, + "Environment variables updated successfully", + console_ns.models[SimpleResultResponse.__name__], + ) @setup_required @login_required @account_initialization_required diff --git a/api/controllers/console/app/workflow_node_output_inspector.py b/api/controllers/console/app/workflow_node_output_inspector.py index ed4dbe9b3ea..98f86ad7cb6 100644 --- a/api/controllers/console/app/workflow_node_output_inspector.py +++ b/api/controllers/console/app/workflow_node_output_inspector.py @@ -30,6 +30,7 @@ from uuid import UUID from flask import Response from flask_restx import Resource +from controllers.common.fields import EventStreamResponse from controllers.common.schema import register_response_schema_models from controllers.console import console_ns from controllers.console.app.wraps import get_app_model @@ -62,6 +63,7 @@ _STREAM_HARD_TIMEOUT_TICKS = 1800 # 30 min register_response_schema_models( console_ns, + EventStreamResponse, CheckResultView, NodeOutputView, NodeOutputsView, @@ -327,7 +329,11 @@ class WorkflowDraftRunNodeOutputEventsApi(Resource): @console_ns.doc("stream_workflow_draft_run_node_output_events") @console_ns.doc(description="Server-Sent Events stream of inspector deltas for a draft workflow run.") @console_ns.doc(params={"app_id": "Application ID", "run_id": "Workflow run ID"}) - @console_ns.response(200, "Workflow run node output event stream") + @console_ns.response( + 200, + "Workflow run node output event stream", + console_ns.models[EventStreamResponse.__name__], + ) @console_ns.response(404, "Workflow run not found") @setup_required @login_required @@ -424,7 +430,11 @@ class WorkflowPublishedRunNodeOutputEventsApi(Resource): @console_ns.doc("stream_workflow_published_run_node_output_events") @console_ns.doc(description="Server-Sent Events stream of inspector deltas for a published workflow run.") @console_ns.doc(params={"app_id": "Application ID", "run_id": "Workflow run ID"}) - @console_ns.response(200, "Workflow run node output event stream") + @console_ns.response( + 200, + "Workflow run node output event stream", + console_ns.models[EventStreamResponse.__name__], + ) @console_ns.response(404, "Workflow run not found") @setup_required @login_required diff --git a/api/controllers/console/app/workflow_statistic.py b/api/controllers/console/app/workflow_statistic.py index 05d579527e7..ec2a5ffce11 100644 --- a/api/controllers/console/app/workflow_statistic.py +++ b/api/controllers/console/app/workflow_statistic.py @@ -3,11 +3,12 @@ from flask_restx import Resource from pydantic import BaseModel, Field, field_validator from sqlalchemy.orm import sessionmaker -from controllers.common.schema import 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 account_initialization_required, setup_required, with_current_user from extensions.ext_database import db +from fields.base import ResponseModel from libs.datetime_utils import parse_time_range from libs.login import login_required from models.account import Account @@ -28,7 +29,50 @@ class WorkflowStatisticQuery(BaseModel): return value +class WorkflowDailyRunsStatisticItem(ResponseModel): + date: str + runs: int + + +class WorkflowDailyRunsStatisticResponse(ResponseModel): + data: list[WorkflowDailyRunsStatisticItem] + + +class WorkflowDailyTerminalsStatisticItem(ResponseModel): + date: str + terminal_count: int + + +class WorkflowDailyTerminalsStatisticResponse(ResponseModel): + data: list[WorkflowDailyTerminalsStatisticItem] + + +class WorkflowDailyTokenCostStatisticItem(ResponseModel): + date: str + token_count: int + + +class WorkflowDailyTokenCostStatisticResponse(ResponseModel): + data: list[WorkflowDailyTokenCostStatisticItem] + + +class WorkflowAverageAppInteractionStatisticItem(ResponseModel): + date: str + interactions: float + + +class WorkflowAverageAppInteractionStatisticResponse(ResponseModel): + data: list[WorkflowAverageAppInteractionStatisticItem] + + register_schema_models(console_ns, WorkflowStatisticQuery) +register_response_schema_models( + console_ns, + WorkflowDailyRunsStatisticResponse, + WorkflowDailyTerminalsStatisticResponse, + WorkflowDailyTokenCostStatisticResponse, + WorkflowAverageAppInteractionStatisticResponse, +) @console_ns.route("/apps//workflow/statistics/daily-conversations") @@ -41,8 +85,12 @@ class WorkflowDailyRunsStatistic(Resource): @console_ns.doc("get_workflow_daily_runs_statistic") @console_ns.doc(description="Get workflow daily runs statistics") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.expect(console_ns.models[WorkflowStatisticQuery.__name__]) - @console_ns.response(200, "Daily runs statistics retrieved successfully") + @console_ns.doc(params=query_params_from_model(WorkflowStatisticQuery)) + @console_ns.response( + 200, + "Daily runs statistics retrieved successfully", + console_ns.models[WorkflowDailyRunsStatisticResponse.__name__], + ) @get_app_model @setup_required @login_required @@ -80,8 +128,12 @@ class WorkflowDailyTerminalsStatistic(Resource): @console_ns.doc("get_workflow_daily_terminals_statistic") @console_ns.doc(description="Get workflow daily terminals statistics") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.expect(console_ns.models[WorkflowStatisticQuery.__name__]) - @console_ns.response(200, "Daily terminals statistics retrieved successfully") + @console_ns.doc(params=query_params_from_model(WorkflowStatisticQuery)) + @console_ns.response( + 200, + "Daily terminals statistics retrieved successfully", + console_ns.models[WorkflowDailyTerminalsStatisticResponse.__name__], + ) @get_app_model @setup_required @login_required @@ -119,8 +171,12 @@ class WorkflowDailyTokenCostStatistic(Resource): @console_ns.doc("get_workflow_daily_token_cost_statistic") @console_ns.doc(description="Get workflow daily token cost statistics") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.expect(console_ns.models[WorkflowStatisticQuery.__name__]) - @console_ns.response(200, "Daily token cost statistics retrieved successfully") + @console_ns.doc(params=query_params_from_model(WorkflowStatisticQuery)) + @console_ns.response( + 200, + "Daily token cost statistics retrieved successfully", + console_ns.models[WorkflowDailyTokenCostStatisticResponse.__name__], + ) @get_app_model @setup_required @login_required @@ -158,8 +214,12 @@ class WorkflowAverageAppInteractionStatistic(Resource): @console_ns.doc("get_workflow_average_app_interaction_statistic") @console_ns.doc(description="Get workflow average app interaction statistics") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.expect(console_ns.models[WorkflowStatisticQuery.__name__]) - @console_ns.response(200, "Average app interaction statistics retrieved successfully") + @console_ns.doc(params=query_params_from_model(WorkflowStatisticQuery)) + @console_ns.response( + 200, + "Average app interaction statistics retrieved successfully", + console_ns.models[WorkflowAverageAppInteractionStatisticResponse.__name__], + ) @setup_required @login_required @account_initialization_required diff --git a/api/controllers/console/app/workflow_trigger.py b/api/controllers/console/app/workflow_trigger.py index 11c8b2ee553..6a1bd843ee2 100644 --- a/api/controllers/console/app/workflow_trigger.py +++ b/api/controllers/console/app/workflow_trigger.py @@ -9,7 +9,7 @@ from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import NotFound from configs import dify_config -from controllers.common.schema import register_schema_models +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.login import login_required @@ -86,7 +86,7 @@ register_schema_models( class WebhookTriggerApi(Resource): """Webhook Trigger API""" - @console_ns.expect(console_ns.models[Parser.__name__]) + @console_ns.doc(params=query_params_from_model(Parser)) @setup_required @login_required @account_initialization_required diff --git a/api/controllers/console/auth/activate.py b/api/controllers/console/auth/activate.py index 65e278edb54..7e7810d86da 100644 --- a/api/controllers/console/auth/activate.py +++ b/api/controllers/console/auth/activate.py @@ -4,7 +4,7 @@ from pydantic import BaseModel, Field, field_validator from configs import dify_config from constants.languages import supported_language -from controllers.common.schema import register_schema_models +from controllers.common.schema import query_params_from_model, register_schema_models from controllers.console import console_ns from controllers.console.error import AccountInFreezeError, AlreadyActivateError from extensions.ext_database import db @@ -69,7 +69,7 @@ register_schema_models( class ActivateCheckApi(Resource): @console_ns.doc("check_activation_token") @console_ns.doc(description="Check if activation token is valid") - @console_ns.expect(console_ns.models[ActivateCheckQuery.__name__]) + @console_ns.doc(params=query_params_from_model(ActivateCheckQuery)) @console_ns.response( 200, "Success", diff --git a/api/controllers/console/auth/data_source_bearer_auth.py b/api/controllers/console/auth/data_source_bearer_auth.py index 33f6fb14aed..f06db799498 100644 --- a/api/controllers/console/auth/data_source_bearer_auth.py +++ b/api/controllers/console/auth/data_source_bearer_auth.py @@ -3,6 +3,7 @@ from uuid import UUID from flask_restx import Resource from pydantic import BaseModel, Field +from controllers.common.fields import SimpleResultResponse from controllers.common.schema import register_response_schema_models, register_schema_models from fields.base import ResponseModel from libs.login import login_required @@ -33,7 +34,12 @@ class ApiKeyAuthDataSourceListResponse(ResponseModel): register_schema_models(console_ns, ApiKeyAuthBindingPayload) -register_response_schema_models(console_ns, ApiKeyAuthDataSourceItem, ApiKeyAuthDataSourceListResponse) +register_response_schema_models( + console_ns, + SimpleResultResponse, + ApiKeyAuthDataSourceItem, + ApiKeyAuthDataSourceListResponse, +) @console_ns.route("/api-key-auth/data-source") @@ -64,6 +70,7 @@ class ApiKeyAuthDataSource(Resource): @console_ns.route("/api-key-auth/data-source/binding") class ApiKeyAuthDataSourceBinding(Resource): + @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) @setup_required @login_required @account_initialization_required diff --git a/api/controllers/console/auth/data_source_oauth.py b/api/controllers/console/auth/data_source_oauth.py index 997dac92103..f1493b5e6f4 100644 --- a/api/controllers/console/auth/data_source_oauth.py +++ b/api/controllers/console/auth/data_source_oauth.py @@ -7,7 +7,8 @@ from flask_restx import Resource from pydantic import BaseModel, Field from configs import dify_config -from controllers.common.schema import register_schema_models +from controllers.common.fields import RedirectResponse +from controllers.common.schema import query_params_from_model, register_response_schema_model, register_schema_models from libs.login import login_required from libs.oauth_data_source import NotionOAuth @@ -29,12 +30,24 @@ class OAuthDataSourceSyncResponse(BaseModel): result: str = Field(description="Operation result") +class OAuthDataSourceCallbackQuery(BaseModel): + code: str | None = Field(default=None, description="Authorization code from OAuth provider") + error: str | None = Field(default=None, description="Error message from OAuth provider") + + +class OAuthDataSourceBindingQuery(BaseModel): + code: str = Field(description="Authorization code from OAuth provider") + + register_schema_models( console_ns, OAuthDataSourceResponse, OAuthDataSourceBindingResponse, OAuthDataSourceSyncResponse, + OAuthDataSourceCallbackQuery, + OAuthDataSourceBindingQuery, ) +register_response_schema_model(console_ns, RedirectResponse) def get_oauth_providers(): @@ -84,14 +97,9 @@ class OAuthDataSource(Resource): class OAuthDataSourceCallback(Resource): @console_ns.doc("oauth_data_source_callback") @console_ns.doc(description="Handle OAuth callback from data source provider") - @console_ns.doc( - params={ - "provider": "Data source provider name (notion)", - "code": "Authorization code from OAuth provider", - "error": "Error message from OAuth provider", - } - ) - @console_ns.response(302, "Redirect to console with result") + @console_ns.doc(params={"provider": "Data source provider name (notion)"}) + @console_ns.doc(params=query_params_from_model(OAuthDataSourceCallbackQuery)) + @console_ns.response(302, "Redirect to console with result", console_ns.models[RedirectResponse.__name__]) @console_ns.response(400, "Invalid provider") def get(self, provider: str): OAUTH_DATASOURCE_PROVIDERS = get_oauth_providers() @@ -115,9 +123,8 @@ class OAuthDataSourceCallback(Resource): class OAuthDataSourceBinding(Resource): @console_ns.doc("oauth_data_source_binding") @console_ns.doc(description="Bind OAuth data source with authorization code") - @console_ns.doc( - params={"provider": "Data source provider name (notion)", "code": "Authorization code from OAuth provider"} - ) + @console_ns.doc(params={"provider": "Data source provider name (notion)"}) + @console_ns.doc(params=query_params_from_model(OAuthDataSourceBindingQuery)) @console_ns.response( 200, "Data source binding success", diff --git a/api/controllers/console/auth/email_register.py b/api/controllers/console/auth/email_register.py index c22b14159c4..912eb26574c 100644 --- a/api/controllers/console/auth/email_register.py +++ b/api/controllers/console/auth/email_register.py @@ -15,6 +15,7 @@ from controllers.console.auth.error import ( InvalidTokenError, PasswordMismatchError, ) +from fields.base import ResponseModel from libs.helper import EmailStr, extract_remote_ip from libs.helper import timezone as validate_timezone_string from libs.password import valid_password @@ -58,8 +59,24 @@ class EmailRegisterResetPayload(BaseModel): return validate_timezone_string(value) +class EmailRegisterTokenPairResponse(ResponseModel): + access_token: str + refresh_token: str + csrf_token: str + + +class EmailRegisterResetResponse(ResponseModel): + result: str + data: EmailRegisterTokenPairResponse + + register_schema_models(console_ns, EmailRegisterSendPayload, EmailRegisterValidityPayload, EmailRegisterResetPayload) -register_response_schema_models(console_ns, SimpleResultDataResponse, VerificationTokenResponse) +register_response_schema_models( + console_ns, + SimpleResultDataResponse, + VerificationTokenResponse, + EmailRegisterResetResponse, +) @console_ns.route("/email-register/send-email") @@ -67,6 +84,7 @@ class EmailRegisterSendEmailApi(Resource): @setup_required @email_password_login_enabled @email_register_enabled + @console_ns.expect(console_ns.models[EmailRegisterSendPayload.__name__]) @console_ns.response(200, "Success", console_ns.models[SimpleResultDataResponse.__name__]) def post(self): args = EmailRegisterSendPayload.model_validate(console_ns.payload) @@ -92,6 +110,7 @@ class EmailRegisterCheckApi(Resource): @setup_required @email_password_login_enabled @email_register_enabled + @console_ns.expect(console_ns.models[EmailRegisterValidityPayload.__name__]) @console_ns.response(200, "Success", console_ns.models[VerificationTokenResponse.__name__]) def post(self): args = EmailRegisterValidityPayload.model_validate(console_ns.payload) @@ -133,6 +152,8 @@ class EmailRegisterResetApi(Resource): @setup_required @email_password_login_enabled @email_register_enabled + @console_ns.expect(console_ns.models[EmailRegisterResetPayload.__name__]) + @console_ns.response(200, "Success", console_ns.models[EmailRegisterResetResponse.__name__]) def post(self): args = EmailRegisterResetPayload.model_validate(console_ns.payload) diff --git a/api/controllers/console/auth/oauth.py b/api/controllers/console/auth/oauth.py index 2254fa4981e..31649812fe8 100644 --- a/api/controllers/console/auth/oauth.py +++ b/api/controllers/console/auth/oauth.py @@ -4,10 +4,13 @@ import urllib.parse import httpx from flask import current_app, redirect, request from flask_restx import Resource +from pydantic import BaseModel, Field from werkzeug.exceptions import Unauthorized from configs import dify_config from constants.languages import languages +from controllers.common.fields import RedirectResponse +from controllers.common.schema import query_params_from_model, register_response_schema_model, register_schema_models from events.tenant_event import tenant_was_created from extensions.ext_database import db from libs.datetime_utils import naive_utc_now @@ -31,6 +34,21 @@ from .. import console_ns logger = logging.getLogger(__name__) +class OAuthLoginQuery(BaseModel): + invite_token: str | None = Field(default=None, description="Optional invitation token") + timezone: str | None = Field(default=None, description="Preferred timezone") + language: str | None = Field(default=None, description="Preferred interface language") + + +class OAuthCallbackQuery(BaseModel): + code: str = Field(description="Authorization code from OAuth provider") + state: str | None = Field(default=None, description="OAuth state parameter") + + +register_schema_models(console_ns, OAuthLoginQuery, OAuthCallbackQuery) +register_response_schema_model(console_ns, RedirectResponse) + + def get_oauth_providers(): with current_app.app_context(): if not dify_config.GITHUB_CLIENT_ID or not dify_config.GITHUB_CLIENT_SECRET: @@ -83,10 +101,9 @@ def _preferred_interface_language(language: str | None = None) -> str: class OAuthLogin(Resource): @console_ns.doc("oauth_login") @console_ns.doc(description="Initiate OAuth login process") - @console_ns.doc( - params={"provider": "OAuth provider name (github/google)", "invite_token": "Optional invitation token"} - ) - @console_ns.response(302, "Redirect to OAuth authorization URL") + @console_ns.doc(params={"provider": "OAuth provider name (github/google)"}) + @console_ns.doc(params=query_params_from_model(OAuthLoginQuery)) + @console_ns.response(302, "Redirect to OAuth authorization URL", console_ns.models[RedirectResponse.__name__]) @console_ns.response(400, "Invalid provider") def get(self, provider: str): invite_token = request.args.get("invite_token") or None @@ -110,14 +127,9 @@ class OAuthLogin(Resource): class OAuthCallback(Resource): @console_ns.doc("oauth_callback") @console_ns.doc(description="Handle OAuth callback and complete login process") - @console_ns.doc( - params={ - "provider": "OAuth provider name (github/google)", - "code": "Authorization code from OAuth provider", - "state": "Optional state parameter (used for invite token)", - } - ) - @console_ns.response(302, "Redirect to console with access token") + @console_ns.doc(params={"provider": "OAuth provider name (github/google)"}) + @console_ns.doc(params=query_params_from_model(OAuthCallbackQuery)) + @console_ns.response(302, "Redirect to console with access token", console_ns.models[RedirectResponse.__name__]) @console_ns.response(400, "OAuth process failed") def get(self, provider: str): OAUTH_PROVIDERS = get_oauth_providers() diff --git a/api/controllers/console/auth/oauth_server.py b/api/controllers/console/auth/oauth_server.py index 7e485589779..46e2983c12b 100644 --- a/api/controllers/console/auth/oauth_server.py +++ b/api/controllers/console/auth/oauth_server.py @@ -1,6 +1,6 @@ from collections.abc import Callable from functools import wraps -from typing import Concatenate +from typing import Any, Concatenate from flask import jsonify, request from flask.typing import ResponseReturnValue @@ -8,6 +8,7 @@ from flask_restx import Resource from pydantic import BaseModel from werkzeug.exceptions import BadRequest, NotFound +from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.console.wraps import account_initialization_required, setup_required, with_current_user from graphon.model_runtime.utils.encoders import jsonable_encoder from libs.login import login_required @@ -36,6 +37,41 @@ class OAuthTokenRequest(BaseModel): refresh_token: str | None = None +class OAuthProviderAppResponse(BaseModel): + app_icon: str + app_label: dict[str, Any] + scope: str + + +class OAuthProviderAuthorizeResponse(BaseModel): + code: str + + +class OAuthProviderTokenResponse(BaseModel): + access_token: str + token_type: str + expires_in: int + refresh_token: str + + +class OAuthProviderAccountResponse(BaseModel): + name: str + email: str + avatar: str | None = None + interface_language: str + timezone: str + + +register_schema_models(console_ns, OAuthClientPayload, OAuthProviderRequest, OAuthTokenRequest) +register_response_schema_models( + console_ns, + OAuthProviderAccountResponse, + OAuthProviderAppResponse, + OAuthProviderAuthorizeResponse, + OAuthProviderTokenResponse, +) + + def oauth_server_client_id_required[T, **P, R]( view: Callable[Concatenate[T, OAuthProviderApp, P], R], ) -> Callable[Concatenate[T, P], R]: @@ -110,6 +146,8 @@ def oauth_server_access_token_required[T, **P, R]( @console_ns.route("/oauth/provider") class OAuthServerAppApi(Resource): @setup_required + @console_ns.expect(console_ns.models[OAuthProviderRequest.__name__]) + @console_ns.response(200, "Success", console_ns.models[OAuthProviderAppResponse.__name__]) @oauth_server_client_id_required def post(self, oauth_provider_app: OAuthProviderApp): payload = OAuthProviderRequest.model_validate(request.get_json()) @@ -134,6 +172,8 @@ class OAuthServerUserAuthorizeApi(Resource): @login_required @account_initialization_required @with_current_user + @console_ns.expect(console_ns.models[OAuthClientPayload.__name__]) + @console_ns.response(200, "Success", console_ns.models[OAuthProviderAuthorizeResponse.__name__]) @oauth_server_client_id_required def post(self, oauth_provider_app: OAuthProviderApp, current_user: Account): user_account_id = current_user.id @@ -148,6 +188,8 @@ class OAuthServerUserAuthorizeApi(Resource): @console_ns.route("/oauth/provider/token") class OAuthServerUserTokenApi(Resource): @setup_required + @console_ns.expect(console_ns.models[OAuthTokenRequest.__name__]) + @console_ns.response(200, "Success", console_ns.models[OAuthProviderTokenResponse.__name__]) @oauth_server_client_id_required def post(self, oauth_provider_app: OAuthProviderApp): payload = OAuthTokenRequest.model_validate(request.get_json()) @@ -198,6 +240,8 @@ class OAuthServerUserTokenApi(Resource): @console_ns.route("/oauth/provider/account") class OAuthServerUserAccountApi(Resource): @setup_required + @console_ns.expect(console_ns.models[OAuthClientPayload.__name__]) + @console_ns.response(200, "Success", console_ns.models[OAuthProviderAccountResponse.__name__]) @oauth_server_client_id_required @oauth_server_access_token_required def post(self, oauth_provider_app: OAuthProviderApp, account: Account): diff --git a/api/controllers/console/billing/billing.py b/api/controllers/console/billing/billing.py index fdd4c276529..1c2d129e6c3 100644 --- a/api/controllers/console/billing/billing.py +++ b/api/controllers/console/billing/billing.py @@ -1,12 +1,12 @@ import base64 -from typing import Literal +from typing import Any, Literal from flask import request from flask_restx import Resource -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, RootModel from werkzeug.exceptions import BadRequest -from controllers.common.schema import 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.wraps import ( account_initialization_required, @@ -30,11 +30,18 @@ class PartnerTenantsPayload(BaseModel): click_id: str = Field(..., description="Click Id from partner referral link") +class BillingResponse(RootModel[dict[str, Any]]): + root: dict[str, Any] + + register_schema_models(console_ns, SubscriptionQuery, PartnerTenantsPayload) +register_response_schema_models(console_ns, BillingResponse) @console_ns.route("/billing/subscription") class Subscription(Resource): + @console_ns.doc(params=query_params_from_model(SubscriptionQuery)) + @console_ns.response(200, "Success", console_ns.models[BillingResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -49,6 +56,7 @@ class Subscription(Resource): @console_ns.route("/billing/invoices") class Invoices(Resource): + @console_ns.response(200, "Success", console_ns.models[BillingResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -66,7 +74,7 @@ class PartnerTenants(Resource): @console_ns.doc(description="Sync partner tenants bindings") @console_ns.doc(params={"partner_key": "Partner key"}) @console_ns.expect(console_ns.models[PartnerTenantsPayload.__name__]) - @console_ns.response(200, "Tenants synced to partner successfully") + @console_ns.response(200, "Tenants synced to partner successfully", console_ns.models[BillingResponse.__name__]) @console_ns.response(400, "Invalid partner information") @setup_required @login_required diff --git a/api/controllers/console/billing/compliance.py b/api/controllers/console/billing/compliance.py index 3d528e1ddd3..ea5852586a9 100644 --- a/api/controllers/console/billing/compliance.py +++ b/api/controllers/console/billing/compliance.py @@ -1,12 +1,16 @@ +from typing import Any + from flask import request from flask_restx import Resource -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, RootModel +from controllers.common.schema import query_params_from_model, register_response_schema_models from libs.helper import extract_remote_ip from libs.login import login_required from models import Account from services.billing_service import BillingService +from ...common.schema import DEFAULT_REF_TEMPLATE_OPENAPI_3_0 from .. import console_ns from ..wraps import ( account_initialization_required, @@ -21,17 +25,23 @@ class ComplianceDownloadQuery(BaseModel): doc_name: str = Field(..., description="Compliance document name") +class ComplianceDownloadResponse(RootModel[dict[str, Any]]): + root: dict[str, Any] + + console_ns.schema_model( ComplianceDownloadQuery.__name__, - ComplianceDownloadQuery.model_json_schema(ref_template="#/definitions/{model}"), + ComplianceDownloadQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0), ) +register_response_schema_models(console_ns, ComplianceDownloadResponse) @console_ns.route("/compliance/download") class ComplianceApi(Resource): - @console_ns.expect(console_ns.models[ComplianceDownloadQuery.__name__]) + @console_ns.doc(params=query_params_from_model(ComplianceDownloadQuery)) @console_ns.doc("download_compliance_document") @console_ns.doc(description="Get compliance document download link") + @console_ns.response(200, "Success", console_ns.models[ComplianceDownloadResponse.__name__]) @setup_required @login_required @account_initialization_required diff --git a/api/controllers/console/datasets/datasets.py b/api/controllers/console/datasets/datasets.py index 4ee8ae176af..ca2ef5d6e2d 100644 --- a/api/controllers/console/datasets/datasets.py +++ b/api/controllers/console/datasets/datasets.py @@ -95,13 +95,13 @@ class DatasetUpdatePayload(BaseModel): indexing_technique: str | None = None embedding_model: str | None = None embedding_model_provider: str | None = None - retrieval_model: dict[str, Any] | None = None - summary_index_setting: dict[str, Any] | None = None + retrieval_model: dict[str, Any] | None = Field(default=None) + summary_index_setting: dict[str, Any] | None = Field(default=None) partial_member_list: list[dict[str, str]] | None = None - external_retrieval_model: dict[str, Any] | None = None + external_retrieval_model: dict[str, Any] | None = Field(default=None) external_knowledge_id: str | None = None external_knowledge_api_id: str | None = None - icon_info: dict[str, Any] | None = None + icon_info: dict[str, Any] | None = Field(default=None) is_multimodal: bool | None = False @field_validator("indexing_technique") diff --git a/api/controllers/console/datasets/datasets_document.py b/api/controllers/console/datasets/datasets_document.py index 401aa2454e3..d4d50600a09 100644 --- a/api/controllers/console/datasets/datasets_document.py +++ b/api/controllers/console/datasets/datasets_document.py @@ -10,13 +10,13 @@ from uuid import UUID import sqlalchemy as sa from flask import request, send_file from flask_restx import Resource -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, RootModel, 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 SimpleResultMessageResponse, SimpleResultResponse, UrlResponse +from controllers.common.fields import BinaryFileResponse, SimpleResultMessageResponse, SimpleResultResponse, UrlResponse from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.console import console_ns from core.errors.error import ( @@ -145,6 +145,10 @@ class DocumentWithSegmentsListResponse(ResponseModel): page: int +class OpaqueObjectResponse(RootModel[dict[str, Any]]): + root: dict[str, Any] + + register_schema_models( console_ns, KnowledgeConfig, @@ -158,6 +162,7 @@ register_schema_models( ) register_response_schema_models( console_ns, + BinaryFileResponse, SimpleResultMessageResponse, SimpleResultResponse, UrlResponse, @@ -167,6 +172,7 @@ register_response_schema_models( DocumentWithSegmentsResponse, DatasetAndDocumentResponse, DocumentWithSegmentsListResponse, + OpaqueObjectResponse, ) @@ -216,7 +222,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.response(200, "Process rules retrieved successfully", console_ns.models[OpaqueObjectResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -537,7 +543,11 @@ class DocumentIndexingEstimateApi(DocumentResource): @console_ns.doc("estimate_document_indexing") @console_ns.doc(description="Estimate document indexing cost") @console_ns.doc(params={"dataset_id": "Dataset ID", "document_id": "Document ID"}) - @console_ns.response(200, "Indexing estimate calculated successfully") + @console_ns.response( + 200, + "Indexing estimate calculated successfully", + console_ns.models[OpaqueObjectResponse.__name__], + ) @console_ns.response(404, "Document not found") @console_ns.response(400, "Document already finished") @setup_required @@ -606,6 +616,11 @@ class DocumentIndexingEstimateApi(DocumentResource): @console_ns.route("/datasets//batch//indexing-estimate") class DocumentBatchIndexingEstimateApi(DocumentResource): + @console_ns.response( + 200, + "Batch indexing estimate calculated successfully", + console_ns.models[OpaqueObjectResponse.__name__], + ) @setup_required @login_required @account_initialization_required @@ -824,7 +839,7 @@ class DocumentApi(DocumentResource): "metadata": "Metadata inclusion (all/only/without)", } ) - @console_ns.response(200, "Document retrieved successfully") + @console_ns.response(200, "Document retrieved successfully", console_ns.models[OpaqueObjectResponse.__name__]) @console_ns.response(404, "Document not found") @setup_required @login_required @@ -966,6 +981,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__]) @setup_required @login_required @account_initialization_required @@ -1324,6 +1340,11 @@ class WebsiteDocumentSyncApi(DocumentResource): @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__], + ) @setup_required @login_required @account_initialization_required @@ -1464,7 +1485,7 @@ 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.response(200, "Summary status retrieved successfully", console_ns.models[OpaqueObjectResponse.__name__]) @console_ns.response(404, "Document not found") @setup_required @login_required diff --git a/api/controllers/console/datasets/external.py b/api/controllers/console/datasets/external.py index 2bd9f12b296..fb19ad81c67 100644 --- a/api/controllers/console/datasets/external.py +++ b/api/controllers/console/datasets/external.py @@ -1,13 +1,19 @@ +from typing import Any from uuid import UUID from flask import request from flask_restx import Resource, fields, marshal -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, RootModel from werkzeug.exceptions import Forbidden, InternalServerError, NotFound import services from controllers.common.fields import UsageCountResponse -from controllers.common.schema import get_or_create_model, register_response_schema_models, register_schema_models +from controllers.common.schema import ( + get_or_create_model, + 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 ( @@ -17,6 +23,7 @@ from controllers.console.wraps import ( with_current_tenant_id, with_current_user, ) +from fields.base import ResponseModel from fields.dataset_fields import ( dataset_detail_fields, dataset_retrieval_model_fields, @@ -88,13 +95,15 @@ 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 = None + external_retrieval_model: dict[str, object] | None = Field(default=None) class ExternalHitTestingPayload(BaseModel): query: str - external_retrieval_model: dict[str, object] | None = None - metadata_filtering_conditions: dict[str, object] | None = None + external_retrieval_model: dict[str, object] | None = Field(default=None) + metadata_filtering_conditions: dict[str, object] | None = Field( + default=None, + ) class BedrockRetrievalPayload(BaseModel): @@ -109,6 +118,34 @@ class ExternalApiTemplateListQuery(BaseModel): keyword: str | None = Field(default=None, description="Search keyword") +class ExternalKnowledgeDatasetBindingResponse(ResponseModel): + id: str + name: str + + +class ExternalKnowledgeApiResponse(ResponseModel): + id: str + tenant_id: str + name: str + description: str + settings: dict[str, Any] | None = Field(default=None) + dataset_bindings: list[ExternalKnowledgeDatasetBindingResponse] = Field(default_factory=list) + created_by: str + created_at: str + + +class ExternalKnowledgeApiListResponse(ResponseModel): + data: list[ExternalKnowledgeApiResponse] + has_more: bool + limit: int + total: int + page: int + + +class ExternalRetrievalTestResponse(RootModel[dict[str, Any] | list[dict[str, Any]]]): + root: dict[str, Any] | list[dict[str, Any]] + + register_schema_models( console_ns, ExternalKnowledgeApiPayload, @@ -117,20 +154,24 @@ register_schema_models( BedrockRetrievalPayload, ExternalApiTemplateListQuery, ) +register_response_schema_models( + console_ns, + ExternalKnowledgeApiResponse, + ExternalKnowledgeApiListResponse, + ExternalRetrievalTestResponse, +) @console_ns.route("/datasets/external-knowledge-api") class ExternalApiTemplateListApi(Resource): @console_ns.doc("get_external_api_templates") @console_ns.doc(description="Get external knowledge API templates") - @console_ns.doc( - params={ - "page": "Page number (default: 1)", - "limit": "Number of items per page (default: 20)", - "keyword": "Search keyword", - } + @console_ns.doc(params=query_params_from_model(ExternalApiTemplateListQuery)) + @console_ns.response( + 200, + "External API templates retrieved successfully", + console_ns.models[ExternalKnowledgeApiListResponse.__name__], ) - @console_ns.response(200, "External API templates retrieved successfully") @setup_required @login_required @with_current_tenant_id @@ -154,6 +195,11 @@ class ExternalApiTemplateListApi(Resource): @login_required @account_initialization_required @console_ns.expect(console_ns.models[ExternalKnowledgeApiPayload.__name__]) + @console_ns.response( + 201, + "External API template created successfully", + console_ns.models[ExternalKnowledgeApiResponse.__name__], + ) @with_current_user @with_current_tenant_id def post(self, current_tenant_id: str, current_user: Account): @@ -180,7 +226,11 @@ class ExternalApiTemplateApi(Resource): @console_ns.doc("get_external_api_template") @console_ns.doc(description="Get external knowledge API template details") @console_ns.doc(params={"external_knowledge_api_id": "External knowledge API ID"}) - @console_ns.response(200, "External API template retrieved successfully") + @console_ns.response( + 200, + "External API template retrieved successfully", + console_ns.models[ExternalKnowledgeApiResponse.__name__], + ) @console_ns.response(404, "Template not found") @setup_required @login_required @@ -196,6 +246,11 @@ class ExternalApiTemplateApi(Resource): return external_knowledge_api.to_dict(), 200 + @console_ns.response( + 200, + "External API template updated successfully", + console_ns.models[ExternalKnowledgeApiResponse.__name__], + ) @setup_required @login_required @account_initialization_required @@ -293,7 +348,11 @@ class ExternalKnowledgeHitTestingApi(Resource): @console_ns.doc(description="Test external knowledge retrieval for dataset") @console_ns.doc(params={"dataset_id": "Dataset ID"}) @console_ns.expect(console_ns.models[ExternalHitTestingPayload.__name__]) - @console_ns.response(200, "External hit testing completed successfully") + @console_ns.response( + 200, + "External hit testing completed successfully", + console_ns.models[ExternalRetrievalTestResponse.__name__], + ) @console_ns.response(404, "Dataset not found") @console_ns.response(400, "Invalid parameters") @setup_required @@ -334,7 +393,11 @@ 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.response( + 200, + "Bedrock retrieval test completed", + console_ns.models[ExternalRetrievalTestResponse.__name__], + ) def post(self): payload = BedrockRetrievalPayload.model_validate(console_ns.payload or {}) diff --git a/api/controllers/console/datasets/hit_testing.py b/api/controllers/console/datasets/hit_testing.py index 37640138eb3..c08ed2fe9f0 100644 --- a/api/controllers/console/datasets/hit_testing.py +++ b/api/controllers/console/datasets/hit_testing.py @@ -8,6 +8,7 @@ from controllers.common.schema import register_response_schema_models, register_ from fields.hit_testing_fields import HitTestingResponse from libs.helper import dump_response from libs.login import login_required +from models import Account from .. import console_ns from ..datasets.hit_testing_base import DatasetsHitTestingBase, HitTestingPayload @@ -15,6 +16,8 @@ from ..wraps import ( account_initialization_required, cloud_edition_billing_rate_limit_check, setup_required, + with_current_tenant_id, + with_current_user, ) register_schema_models(console_ns, HitTestingPayload) @@ -38,11 +41,16 @@ class HitTestingApi(Resource, DatasetsHitTestingBase): @login_required @account_initialization_required @cloud_edition_billing_rate_limit_check("knowledge") - def post(self, dataset_id: UUID) -> dict[str, object]: + @with_current_tenant_id + @with_current_user + def post(self, current_user: Account, current_tenant_id: str, dataset_id: UUID) -> dict[str, object]: dataset_id_str = str(dataset_id) - dataset = self.get_and_validate_dataset(dataset_id_str) + dataset = self.get_and_validate_dataset(dataset_id_str, current_user, current_tenant_id) args = self.parse_args(console_ns.payload) self.hit_testing_args_check(args) - return dump_response(HitTestingResponse, self.perform_hit_testing(dataset, args)) + return dump_response( + HitTestingResponse, + self.perform_hit_testing(dataset, args, current_user, current_tenant_id), + ) diff --git a/api/controllers/console/datasets/hit_testing_base.py b/api/controllers/console/datasets/hit_testing_base.py index 4be91e0e54d..e3efe804872 100644 --- a/api/controllers/console/datasets/hit_testing_base.py +++ b/api/controllers/console/datasets/hit_testing_base.py @@ -19,7 +19,7 @@ from core.errors.error import ( QuotaExceededError, ) from graphon.model_runtime.errors.invoke import InvokeError -from libs.login import current_user +from libs.login import resolve_account_fallback from models.account import Account from models.dataset import Dataset from services.dataset_service import DatasetService @@ -32,7 +32,7 @@ logger = logging.getLogger(__name__) class HitTestingPayload(BaseModel): query: str = Field(max_length=250) retrieval_model: RetrievalModel | None = None - external_retrieval_model: dict[str, Any] | None = None + external_retrieval_model: dict[str, Any] | None = Field(default=None) attachment_ids: list[str] | None = None @@ -71,8 +71,10 @@ class DatasetsHitTestingBase: return normalized_records @staticmethod - def get_and_validate_dataset(dataset_id: str) -> Dataset: - assert isinstance(current_user, Account) + def get_and_validate_dataset( + dataset_id: str, current_user: Account | None = None, current_tenant_id: str | None = None + ) -> Dataset: + current_user, _ = resolve_account_fallback(current_user, current_tenant_id) dataset = DatasetService.get_dataset(dataset_id) if dataset is None: raise NotFound("Dataset not found.") @@ -95,9 +97,14 @@ class DatasetsHitTestingBase: return hit_testing_payload.model_dump(exclude_none=True) @staticmethod - def perform_hit_testing(dataset: Dataset, args: dict[str, Any]) -> dict[str, Any]: - assert isinstance(current_user, Account) + def perform_hit_testing( + dataset: Dataset, + args: dict[str, Any], + current_user: Account | None = None, + current_tenant_id: str | None = None, + ) -> dict[str, Any]: try: + current_user, _ = resolve_account_fallback(current_user, current_tenant_id) response = HitTestingService.retrieve( dataset=dataset, query=cast(str, args.get("query")), diff --git a/api/controllers/console/datasets/metadata.py b/api/controllers/console/datasets/metadata.py index 5b53c40ae97..ec4c5bedb61 100644 --- a/api/controllers/console/datasets/metadata.py +++ b/api/controllers/console/datasets/metadata.py @@ -11,6 +11,7 @@ from controllers.console.wraps import ( account_initialization_required, enterprise_license_required, setup_required, + with_current_tenant_id, with_current_user, ) from fields.dataset_fields import ( @@ -50,7 +51,8 @@ class DatasetMetadataCreateApi(Resource): @console_ns.response(201, "Metadata created successfully", console_ns.models[DatasetMetadataResponse.__name__]) @console_ns.expect(console_ns.models[MetadataArgs.__name__]) @with_current_user - def post(self, current_user: Account, dataset_id: UUID): + @with_current_tenant_id + def post(self, current_tenant_id: str, current_user: Account, dataset_id: UUID): metadata_args = MetadataArgs.model_validate(console_ns.payload or {}) dataset_id_str = str(dataset_id) @@ -59,7 +61,7 @@ class DatasetMetadataCreateApi(Resource): raise NotFound("Dataset not found.") DatasetService.check_dataset_permission(dataset, current_user) - metadata = MetadataService.create_metadata(dataset_id_str, metadata_args) + metadata = MetadataService.create_metadata(dataset_id_str, metadata_args, current_user, current_tenant_id) return dump_response(DatasetMetadataResponse, metadata), 201 @setup_required @@ -87,7 +89,8 @@ class DatasetMetadataApi(Resource): @console_ns.response(200, "Metadata updated successfully", console_ns.models[DatasetMetadataResponse.__name__]) @console_ns.expect(console_ns.models[MetadataUpdatePayload.__name__]) @with_current_user - def patch(self, current_user: Account, dataset_id: UUID, metadata_id: UUID): + @with_current_tenant_id + def patch(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, metadata_id: UUID): payload = MetadataUpdatePayload.model_validate(console_ns.payload or {}) name = payload.name @@ -98,7 +101,9 @@ class DatasetMetadataApi(Resource): raise NotFound("Dataset not found.") DatasetService.check_dataset_permission(dataset, current_user) - metadata = MetadataService.update_metadata_name(dataset_id_str, metadata_id_str, name) + metadata = MetadataService.update_metadata_name( + dataset_id_str, metadata_id_str, name, current_user, current_tenant_id + ) return dump_response(DatasetMetadataResponse, metadata), 200 @setup_required @@ -181,7 +186,7 @@ class DocumentMetadataEditApi(Resource): metadata_args = MetadataOperationData.model_validate(console_ns.payload or {}) - MetadataService.update_documents_metadata(dataset, metadata_args) + MetadataService.update_documents_metadata(dataset, metadata_args, current_user) # Frontend callers only await success and invalidate caches; no response body is consumed. return "", 204 diff --git a/api/controllers/console/datasets/rag_pipeline/datasource_auth.py b/api/controllers/console/datasets/rag_pipeline/datasource_auth.py index c7cae85d37b..980f116e216 100644 --- a/api/controllers/console/datasets/rag_pipeline/datasource_auth.py +++ b/api/controllers/console/datasets/rag_pipeline/datasource_auth.py @@ -6,8 +6,8 @@ from pydantic import BaseModel, Field from werkzeug.exceptions import Forbidden, NotFound from configs import dify_config -from controllers.common.fields import SimpleResultResponse -from controllers.common.schema import register_response_schema_models, register_schema_models +from controllers.common.fields import RedirectResponse, 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 ( account_initialization_required, @@ -16,7 +16,9 @@ from controllers.console.wraps import ( with_current_tenant_id, with_current_user, ) +from core.plugin.entities.plugin_daemon import PluginOAuthAuthorizationUrlResponse from core.plugin.impl.oauth import OAuthHandler +from fields.base import ResponseModel from graphon.model_runtime.errors.validate import CredentialsValidateFailedError from graphon.model_runtime.utils.encoders import jsonable_encoder from libs.login import login_required @@ -38,11 +40,11 @@ class DatasourceCredentialDeletePayload(BaseModel): class DatasourceCredentialUpdatePayload(BaseModel): credential_id: str name: str | None = Field(default=None, max_length=100) - credentials: dict[str, Any] | None = None + credentials: dict[str, Any] | None = Field(default=None) class DatasourceCustomClientPayload(BaseModel): - client_params: dict[str, Any] | None = None + client_params: dict[str, Any] | None = Field(default=None) enable_oauth_custom_client: bool | None = None @@ -55,8 +57,25 @@ class DatasourceUpdateNamePayload(BaseModel): name: str = Field(max_length=100) +class DatasourceOAuthAuthorizationQuery(BaseModel): + credential_id: str | None = Field(default=None, description="Credential ID to reauthorize") + + +class DatasourceOAuthCallbackQuery(BaseModel): + code: str | None = Field(default=None, description="Authorization code from OAuth provider") + state: str | None = Field(default=None, description="OAuth state parameter") + error: str | None = Field(default=None, description="Error message from OAuth provider") + context_id: str | None = Field(default=None, description="OAuth proxy context ID") + + +class DatasourceCredentialsResponse(ResponseModel): + result: Any + + register_schema_models( console_ns, + DatasourceOAuthAuthorizationQuery, + DatasourceOAuthCallbackQuery, DatasourceCredentialPayload, DatasourceCredentialDeletePayload, DatasourceCredentialUpdatePayload, @@ -64,11 +83,23 @@ register_schema_models( DatasourceDefaultPayload, DatasourceUpdateNamePayload, ) -register_response_schema_models(console_ns, SimpleResultResponse) +register_response_schema_models( + console_ns, + DatasourceCredentialsResponse, + PluginOAuthAuthorizationUrlResponse, + RedirectResponse, + SimpleResultResponse, +) @console_ns.route("/oauth/plugin//datasource/get-authorization-url") class DatasourcePluginOAuthAuthorizationUrl(Resource): + @console_ns.doc(params=query_params_from_model(DatasourceOAuthAuthorizationQuery)) + @console_ns.response( + 200, + "Authorization URL retrieved successfully", + console_ns.models[PluginOAuthAuthorizationUrlResponse.__name__], + ) @setup_required @login_required @account_initialization_required @@ -118,6 +149,12 @@ class DatasourcePluginOAuthAuthorizationUrl(Resource): @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__], + ) @setup_required def get(self, provider_id: str): context_id = request.cookies.get("context_id") or request.args.get("context_id") @@ -176,6 +213,7 @@ class DatasourceOAuthCallback(Resource): @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__]) @setup_required @login_required @account_initialization_required @@ -200,6 +238,7 @@ class DatasourceAuth(Resource): @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): @@ -243,6 +282,7 @@ class DatasourceAuthDeleteApi(Resource): @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__]) @setup_required @login_required @account_initialization_required @@ -266,6 +306,7 @@ class DatasourceAuthUpdateApi(Resource): @console_ns.route("/auth/plugin/datasource/list") class DatasourceAuthListApi(Resource): + @console_ns.response(200, "Success", console_ns.models[DatasourceCredentialsResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -278,6 +319,7 @@ class DatasourceAuthListApi(Resource): @console_ns.route("/auth/plugin/datasource/default-list") class DatasourceHardCodeAuthListApi(Resource): + @console_ns.response(200, "Success", console_ns.models[DatasourceCredentialsResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -291,6 +333,7 @@ class DatasourceHardCodeAuthListApi(Resource): @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__]) @setup_required @login_required @account_initialization_required 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 a872eb88616..b0af108444c 100644 --- a/api/controllers/console/datasets/rag_pipeline/datasource_content_preview.py +++ b/api/controllers/console/datasets/rag_pipeline/datasource_content_preview.py @@ -1,9 +1,11 @@ +from typing import Any + from flask_restx import ( # type: ignore Resource, # type: ignore ) -from pydantic import BaseModel +from pydantic import BaseModel, RootModel -from controllers.common.schema import register_schema_models +from controllers.common.schema import register_response_schema_models, 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 @@ -14,17 +16,23 @@ from services.rag_pipeline.rag_pipeline import RagPipelineService class Parser(BaseModel): - inputs: dict + inputs: dict[str, Any] datasource_type: str 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__]) @setup_required @login_required @account_initialization_required diff --git a/api/controllers/console/datasets/rag_pipeline/rag_pipeline.py b/api/controllers/console/datasets/rag_pipeline/rag_pipeline.py index d21800d53c1..3187124f121 100644 --- a/api/controllers/console/datasets/rag_pipeline/rag_pipeline.py +++ b/api/controllers/console/datasets/rag_pipeline/rag_pipeline.py @@ -21,11 +21,14 @@ from controllers.console.wraps import ( enterprise_license_required, knowledge_pipeline_publish_enabled, setup_required, + with_current_tenant_id, + with_current_user, ) 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.account import Account from models.dataset import PipelineCustomizedTemplate from services.entities.knowledge_entities.rag_pipeline_entities import IconInfo, PipelineTemplateInfoEntity from services.rag_pipeline.rag_pipeline import RagPipelineService @@ -71,7 +74,9 @@ class PipelineTemplateDetailResponse(ResponseModel): class CustomizedPipelineTemplatePayload(BaseModel): name: str = Field(..., min_length=1, max_length=40) description: str = Field(default="", max_length=400) - icon_info: dict[str, object] = Field(default_factory=lambda: IconInfo(icon="").model_dump()) + icon_info: dict[str, object] = Field( + default_factory=lambda: IconInfo(icon="").model_dump(), + ) register_schema_models( @@ -96,10 +101,11 @@ class PipelineTemplateListApi(Resource): @login_required @account_initialization_required @enterprise_license_required - def get(self) -> JsonResponseWithStatus: + @with_current_tenant_id + def get(self, current_tenant_id: str) -> JsonResponseWithStatus: query = PipelineTemplateListQuery.model_validate(request.args.to_dict(flat=True)) # get pipeline templates - pipeline_templates = RagPipelineService.get_pipeline_templates(query.type, query.language) + pipeline_templates = RagPipelineService.get_pipeline_templates(query.type, query.language, current_tenant_id) return dump_response(PipelineTemplateListResponse, pipeline_templates), 200 @@ -128,10 +134,14 @@ class CustomizedPipelineTemplateApi(Resource): @login_required @account_initialization_required @enterprise_license_required - def patch(self, template_id: str) -> tuple[str, int]: + @with_current_user + @with_current_tenant_id + def patch(self, current_tenant_id: str, current_user: Account, template_id: str) -> tuple[str, int]: payload = CustomizedPipelineTemplatePayload.model_validate(console_ns.payload or {}) pipeline_template_info = PipelineTemplateInfoEntity.model_validate(payload.model_dump()) - RagPipelineService.update_customized_pipeline_template(template_id, pipeline_template_info) + RagPipelineService.update_customized_pipeline_template( + template_id, pipeline_template_info, current_user, current_tenant_id + ) return "", 204 @console_ns.response(204, "Pipeline template deleted") @@ -139,8 +149,9 @@ class CustomizedPipelineTemplateApi(Resource): @login_required @account_initialization_required @enterprise_license_required - def delete(self, template_id: str) -> tuple[str, int]: - RagPipelineService.delete_customized_pipeline_template(template_id) + @with_current_tenant_id + def delete(self, current_tenant_id: str, template_id: str) -> tuple[str, int]: + RagPipelineService.delete_customized_pipeline_template(template_id, current_tenant_id) return "", 204 @setup_required @@ -168,8 +179,12 @@ class PublishCustomizedPipelineTemplateApi(Resource): @account_initialization_required @enterprise_license_required @knowledge_pipeline_publish_enabled - def post(self, pipeline_id: str) -> tuple[str, int]: + @with_current_user + @with_current_tenant_id + def post(self, current_tenant_id: str, current_user: Account, pipeline_id: str) -> tuple[str, int]: payload = CustomizedPipelineTemplatePayload.model_validate(console_ns.payload or {}) rag_pipeline_service = RagPipelineService() - rag_pipeline_service.publish_customized_pipeline_template(pipeline_id, payload.model_dump()) + rag_pipeline_service.publish_customized_pipeline_template( + pipeline_id, payload.model_dump(), current_user, current_tenant_id + ) return "", 204 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 cd326dffe9e..af417f24dfe 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 @@ -11,13 +11,14 @@ from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import Forbidden from controllers.common.errors import InvalidArgumentError, NotFoundError -from controllers.common.schema import register_schema_models +from controllers.common.schema import query_params_from_model, 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, @@ -40,14 +41,9 @@ logger = logging.getLogger(__name__) _file_access_controller = DatabaseFileAccessController() -def _create_pagination_parser(): - class PaginationQuery(BaseModel): - page: int = Field(default=1, ge=1, le=100_000) - limit: int = Field(default=20, ge=1, le=100) - - register_schema_models(console_ns, PaginationQuery) - - return PaginationQuery +class PaginationQuery(BaseModel): + page: int = Field(default=1, ge=1, le=100_000) + limit: int = Field(default=20, ge=1, le=100) class WorkflowDraftVariablePatchPayload(BaseModel): @@ -55,7 +51,7 @@ class WorkflowDraftVariablePatchPayload(BaseModel): value: Any | None = None -register_schema_models(console_ns, WorkflowDraftVariablePatchPayload) +register_schema_models(console_ns, PaginationQuery, WorkflowDraftVariablePatchPayload) def _api_prerequisite[T, **P, R]( @@ -87,14 +83,19 @@ def _api_prerequisite[T, **P, R]( @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, + ) @_api_prerequisite @marshal_with(workflow_draft_variable_list_without_value_model) def get(self, current_user: Account, pipeline: Pipeline): """ Get draft workflow """ - pagination = _create_pagination_parser() - query = pagination.model_validate(request.args.to_dict()) + query = PaginationQuery.model_validate(request.args.to_dict()) # fetch draft workflow by app_model rag_pipeline_service = RagPipelineService() @@ -116,6 +117,7 @@ class RagPipelineVariableCollectionApi(Resource): return workflow_vars + @console_ns.response(204, "Workflow variables deleted successfully") @_api_prerequisite def delete(self, current_user: Account, pipeline: Pipeline): draft_var_srv = WorkflowDraftVariableService( @@ -146,6 +148,7 @@ def validate_node_id(node_id: str) -> NoReturn | None: @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) @_api_prerequisite @marshal_with(workflow_draft_variable_list_model) def get(self, current_user: Account, pipeline: Pipeline, node_id: str): @@ -158,6 +161,7 @@ class RagPipelineNodeVariableCollectionApi(Resource): return node_vars + @console_ns.response(204, "Node variables deleted successfully") @_api_prerequisite def delete(self, current_user: Account, pipeline: Pipeline, node_id: str): validate_node_id(node_id) @@ -172,6 +176,7 @@ class RagPipelineVariableApi(Resource): _PATCH_NAME_FIELD = "name" _PATCH_VALUE_FIELD = "value" + @console_ns.response(200, "Variable retrieved successfully", workflow_draft_variable_model) @_api_prerequisite @marshal_with(workflow_draft_variable_model) def get(self, _current_user: Account, pipeline: Pipeline, variable_id: UUID): @@ -186,6 +191,7 @@ class RagPipelineVariableApi(Resource): raise NotFoundError(description=f"variable not found, id={variable_id_str}") return variable + @console_ns.response(200, "Variable updated successfully", workflow_draft_variable_model) @_api_prerequisite @marshal_with(workflow_draft_variable_model) @console_ns.expect(console_ns.models[WorkflowDraftVariablePatchPayload.__name__]) @@ -257,6 +263,7 @@ class RagPipelineVariableApi(Resource): db.session.commit() return variable + @console_ns.response(204, "Variable deleted successfully") @_api_prerequisite def delete(self, _current_user: Account, pipeline: Pipeline, variable_id: UUID): draft_var_srv = WorkflowDraftVariableService( @@ -275,6 +282,8 @@ class RagPipelineVariableApi(Resource): @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)") @_api_prerequisite def put(self, _current_user: Account, pipeline: Pipeline, variable_id: UUID): draft_var_srv = WorkflowDraftVariableService( @@ -318,6 +327,7 @@ 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) @_api_prerequisite @marshal_with(workflow_draft_variable_list_model) def get(self, current_user: Account, pipeline: Pipeline): @@ -326,6 +336,11 @@ class RagPipelineSystemVariableCollectionApi(Resource): @console_ns.route("/rag/pipelines//workflows/draft/environment-variables") class RagPipelineEnvironmentVariableCollectionApi(Resource): + @console_ns.response( + 200, + "Environment variables retrieved successfully", + console_ns.models[EnvironmentVariableListResponse.__name__], + ) @_api_prerequisite def get(self, _current_user: Account, pipeline: Pipeline): """ 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 ebc3b92dd63..7c941e14368 100644 --- a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py +++ b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py @@ -5,14 +5,14 @@ from uuid import UUID from flask import abort, request from flask_restx import Resource -from pydantic import BaseModel, Field, ValidationError +from pydantic import BaseModel, Field, RootModel, ValidationError from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound import services from controllers.common.controller_schemas import DefaultBlockConfigQuery, WorkflowListQuery, WorkflowUpdatePayload from controllers.common.fields import SimpleResultResponse -from controllers.common.schema import 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.app.error import ( ConversationCompletedError, @@ -21,6 +21,8 @@ from controllers.console.app.error import ( ) from controllers.console.app.workflow import ( RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE, + DefaultBlockConfigResponse, + DefaultBlockConfigsResponse, WorkflowPaginationResponse, WorkflowResponse, ) @@ -48,7 +50,7 @@ from fields.workflow_run_fields import ( from graphon.model_runtime.utils.encoders import jsonable_encoder from libs import helper from libs.helper import TimestampField, UUIDStrOrEmpty, dump_response -from libs.login import current_user, login_required +from libs.login import login_required from models import Account from models.dataset import Pipeline from models.model import EndUser @@ -67,14 +69,14 @@ logger = logging.getLogger(__name__) class DraftWorkflowSyncPayload(BaseModel): graph: dict[str, Any] hash: str | None = 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 + 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) class NodeRunPayload(BaseModel): - inputs: dict[str, Any] | None = None + inputs: dict[str, Any] | None = Field(default=None) class NodeRunRequiredPayload(BaseModel): @@ -131,6 +133,14 @@ class RagPipelineWorkflowPublishResponse(ResponseModel): created_at: int +class RagPipelineOpaqueResponse(RootModel[Any]): + root: Any + + +class RagPipelineStepParametersResponse(ResponseModel): + variables: Any + + register_schema_models( console_ns, DraftWorkflowSyncPayload, @@ -149,6 +159,10 @@ register_schema_models( ) register_response_schema_models( console_ns, + DefaultBlockConfigResponse, + DefaultBlockConfigsResponse, + RagPipelineOpaqueResponse, + RagPipelineStepParametersResponse, RagPipelineWorkflowPublishResponse, RagPipelineWorkflowSyncResponse, SimpleResultResponse, @@ -192,6 +206,7 @@ class DraftRagPipelineApi(Resource): @with_current_user @get_rag_pipeline @edit_permission_required + @console_ns.expect(console_ns.models[DraftWorkflowSyncPayload.__name__]) @console_ns.response(200, "Success", console_ns.models[RagPipelineWorkflowSyncResponse.__name__]) def post(self, current_user: Account, pipeline: Pipeline): """ @@ -244,6 +259,7 @@ class DraftRagPipelineApi(Resource): @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 @@ -277,6 +293,7 @@ class RagPipelineDraftRunIterationNodeApi(Resource): @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 @@ -310,6 +327,7 @@ class RagPipelineDraftRunLoopNodeApi(Resource): @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 @@ -340,6 +358,7 @@ class DraftRagPipelineRunApi(Resource): @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 @@ -371,6 +390,7 @@ class PublishedRagPipelineRunApi(Resource): @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 @@ -402,6 +422,7 @@ class RagPipelinePublishedDatasourceNodeRunApi(Resource): @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 @@ -541,6 +562,11 @@ class PublishedRagPipelineApi(Resource): @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__], + ) @setup_required @login_required @account_initialization_required @@ -557,6 +583,12 @@ class DefaultRagPipelineBlockConfigsApi(Resource): @console_ns.route("/rag/pipelines//workflows/default-workflow-block-configs/") 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__], + ) @setup_required @login_required @account_initialization_required @@ -582,6 +614,7 @@ class DefaultRagPipelineBlockConfigApi(Resource): @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", @@ -673,6 +706,7 @@ class RagPipelineByIdApi(Resource): @edit_permission_required @with_current_user @get_rag_pipeline + @console_ns.expect(console_ns.models[WorkflowUpdatePayload.__name__]) def patch(self, current_user: Account, pipeline: Pipeline, workflow_id: str): """ Update workflow attributes @@ -734,6 +768,8 @@ class RagPipelineByIdApi(Resource): @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__]) @setup_required @login_required @account_initialization_required @@ -754,6 +790,8 @@ class PublishedRagPipelineSecondStepApi(Resource): @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__]) @setup_required @login_required @account_initialization_required @@ -774,6 +812,8 @@ class PublishedRagPipelineFirstStepApi(Resource): @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__]) @setup_required @login_required @account_initialization_required @@ -794,6 +834,8 @@ class DraftRagPipelineFirstStepApi(Resource): @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__]) @setup_required @login_required @account_initialization_required @@ -815,6 +857,7 @@ class DraftRagPipelineSecondStepApi(Resource): @console_ns.route("/rag/pipelines//workflow-runs") class RagPipelineWorkflowRunListApi(Resource): + @console_ns.doc(params=query_params_from_model(WorkflowRunQuery)) @console_ns.response( 200, "Workflow runs retrieved successfully", @@ -835,7 +878,7 @@ class RagPipelineWorkflowRunListApi(Resource): } ) args = { - "last_id": str(query.last_id) if query.last_id else None, + "last_id": query.last_id or None, "limit": query.limit, } @@ -881,7 +924,8 @@ class RagPipelineWorkflowRunNodeExecutionListApi(Resource): @login_required @account_initialization_required @get_rag_pipeline - def get(self, pipeline: Pipeline, run_id: UUID): + @with_current_user + def get(self, current_user: Account, pipeline: Pipeline, run_id: UUID): """ Get workflow run node execution list """ @@ -902,6 +946,7 @@ class RagPipelineWorkflowRunNodeExecutionListApi(Resource): @console_ns.route("/rag/pipelines/datasource-plugins") class DatasourceListApi(Resource): + @console_ns.response(200, "Success", console_ns.models[RagPipelineOpaqueResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -938,6 +983,7 @@ class RagPipelineWorkflowLastRunApi(Resource): @console_ns.route("/rag/pipelines/transform/datasets/") class RagPipelineTransformApi(Resource): + @console_ns.response(200, "Success", console_ns.models[RagPipelineOpaqueResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -948,7 +994,7 @@ class RagPipelineTransformApi(Resource): dataset_id_str = str(dataset_id) rag_pipeline_transform_service = RagPipelineTransformService() - result = rag_pipeline_transform_service.transform_dataset(dataset_id_str) + result = rag_pipeline_transform_service.transform_dataset(dataset_id_str, db.session) return result @@ -985,12 +1031,16 @@ class RagPipelineDatasourceVariableApi(Resource): @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__]) @setup_required @login_required @account_initialization_required - def get(self): + @with_current_user + @with_current_tenant_id + def get(self, current_tenant_id: str, current_user: Account): query = RagPipelineRecommendedPluginQuery.model_validate(request.args.to_dict()) rag_pipeline_service = RagPipelineService() - recommended_plugins = rag_pipeline_service.get_recommended_plugins(query.type) + recommended_plugins = rag_pipeline_service.get_recommended_plugins(query.type, current_user, current_tenant_id) return recommended_plugins diff --git a/api/controllers/console/datasets/website.py b/api/controllers/console/datasets/website.py index 335c8f60308..39be3f3ce5f 100644 --- a/api/controllers/console/datasets/website.py +++ b/api/controllers/console/datasets/website.py @@ -1,10 +1,10 @@ -from typing import Literal +from typing import Any, Literal from flask import request from flask_restx import Resource -from pydantic import BaseModel +from pydantic import BaseModel, RootModel -from controllers.common.schema import 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 WebsiteCrawlError from controllers.console.wraps import account_initialization_required, setup_required @@ -22,7 +22,12 @@ class WebsiteCrawlStatusQuery(BaseModel): provider: Literal["firecrawl", "watercrawl", "jinareader"] +class WebsiteCrawlResponse(RootModel[dict[str, Any]]): + root: dict[str, Any] + + register_schema_models(console_ns, WebsiteCrawlPayload, WebsiteCrawlStatusQuery) +register_response_schema_models(console_ns, WebsiteCrawlResponse) @console_ns.route("/website/crawl") @@ -30,7 +35,7 @@ class WebsiteCrawlApi(Resource): @console_ns.doc("crawl_website") @console_ns.doc(description="Crawl website content") @console_ns.expect(console_ns.models[WebsiteCrawlPayload.__name__]) - @console_ns.response(200, "Website crawl initiated successfully") + @console_ns.response(200, "Website crawl initiated successfully", console_ns.models[WebsiteCrawlResponse.__name__]) @console_ns.response(400, "Invalid crawl parameters") @setup_required @login_required @@ -57,8 +62,8 @@ class WebsiteCrawlStatusApi(Resource): @console_ns.doc("get_crawl_status") @console_ns.doc(description="Get website crawl status") @console_ns.doc(params={"job_id": "Crawl job ID", "provider": "Crawl provider (firecrawl/watercrawl/jinareader)"}) - @console_ns.expect(console_ns.models[WebsiteCrawlStatusQuery.__name__]) - @console_ns.response(200, "Crawl status retrieved successfully") + @console_ns.doc(params=query_params_from_model(WebsiteCrawlStatusQuery)) + @console_ns.response(200, "Crawl status retrieved successfully", console_ns.models[WebsiteCrawlResponse.__name__]) @console_ns.response(404, "Crawl job not found") @console_ns.response(400, "Invalid provider") @setup_required diff --git a/api/controllers/console/explore/audio.py b/api/controllers/console/explore/audio.py index 42b611cafd6..756dfe84f6c 100644 --- a/api/controllers/console/explore/audio.py +++ b/api/controllers/console/explore/audio.py @@ -5,7 +5,8 @@ from werkzeug.exceptions import InternalServerError import services from controllers.common.controller_schemas import TextToAudioPayload -from controllers.common.schema import register_schema_model +from controllers.common.fields import AudioBinaryResponse, AudioTranscriptResponse +from controllers.common.schema import register_response_schema_models, register_schema_model from controllers.console.app.error import ( AppUnavailableError, AudioTooLargeError, @@ -34,6 +35,7 @@ from .. import console_ns logger = logging.getLogger(__name__) register_schema_model(console_ns, TextToAudioPayload) +register_response_schema_models(console_ns, AudioBinaryResponse, AudioTranscriptResponse) @console_ns.route( @@ -41,6 +43,7 @@ register_schema_model(console_ns, TextToAudioPayload) endpoint="installed_app_audio", ) class ChatAudioApi(InstalledAppResource): + @console_ns.response(200, "Success", console_ns.models[AudioTranscriptResponse.__name__]) def post(self, installed_app: InstalledApp): app_model = installed_app.app if app_model is None: @@ -84,6 +87,7 @@ class ChatAudioApi(InstalledAppResource): ) class ChatTextApi(InstalledAppResource): @console_ns.expect(console_ns.models[TextToAudioPayload.__name__]) + @console_ns.response(200, "Success", console_ns.models[AudioBinaryResponse.__name__]) def post(self, installed_app: InstalledApp): app_model = installed_app.app if app_model is None: diff --git a/api/controllers/console/explore/banner.py b/api/controllers/console/explore/banner.py index 757061d8dda..a6321bb797c 100644 --- a/api/controllers/console/explore/banner.py +++ b/api/controllers/console/explore/banner.py @@ -1,17 +1,44 @@ +from typing import Any, cast + from flask import request -from flask_restx import Resource +from flask_restx import Namespace, Resource +from pydantic import BaseModel, Field, RootModel from sqlalchemy import select +from controllers.common.schema import query_params_from_model, register_response_schema_models from controllers.console import api from controllers.console.explore.wraps import explore_banner_enabled from extensions.ext_database import db +from fields.base import ResponseModel from models.enums import BannerStatus from models.model import ExporleBanner +class BannerListQuery(BaseModel): + language: str = Field(default="en-US", description="Banner language") + + +class BannerResponse(ResponseModel): + id: str + content: Any + link: str | None = None + sort: int + status: str + created_at: str | None = None + + +class BannerListResponse(RootModel[list[BannerResponse]]): + root: list[BannerResponse] + + +register_response_schema_models(cast(Namespace, api), BannerListResponse) + + class BannerApi(Resource): """Resource for banner list.""" + @api.doc(params=query_params_from_model(BannerListQuery)) + @api.response(200, "Success", api.models[BannerListResponse.__name__]) @explore_banner_enabled def get(self): """Get banner list.""" diff --git a/api/controllers/console/explore/completion.py b/api/controllers/console/explore/completion.py index d1ae6526c68..729be6a909b 100644 --- a/api/controllers/console/explore/completion.py +++ b/api/controllers/console/explore/completion.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, Field, field_validator from werkzeug.exceptions import InternalServerError, NotFound import services -from controllers.common.fields import SimpleResultResponse +from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.console.app.error import ( AppUnavailableError, @@ -18,7 +18,7 @@ from controllers.console.app.error import ( ) from controllers.console.explore.error import NotChatAppError, NotCompletionAppError from controllers.console.explore.wraps import InstalledAppResource -from controllers.console.wraps import with_current_user_id +from controllers.console.wraps import with_current_user, with_current_user_id from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError from core.app.entities.app_invoke_entities import InvokeFrom from core.errors.error import ( @@ -30,7 +30,6 @@ from extensions.ext_database import db from graphon.model_runtime.errors.invoke import InvokeError from libs import helper from libs.datetime_utils import naive_utc_now -from libs.login import current_user from models import Account from models.model import AppMode, InstalledApp from services.app_generate_service import AppGenerateService @@ -45,7 +44,7 @@ logger = logging.getLogger(__name__) class CompletionMessageExplorePayload(BaseModel): inputs: dict[str, Any] query: str = "" - files: list[dict[str, Any]] | None = None + files: list[dict[str, Any]] | None = Field(default=None) response_mode: Literal["blocking", "streaming"] | None = None retriever_from: str = Field(default="explore_app") @@ -53,7 +52,7 @@ class CompletionMessageExplorePayload(BaseModel): class ChatMessagePayload(BaseModel): inputs: dict[str, Any] query: str - files: list[dict[str, Any]] | None = None + files: list[dict[str, Any]] | None = Field(default=None) conversation_id: str | None = None parent_message_id: str | None = None retriever_from: str = Field(default="explore_app") @@ -74,7 +73,7 @@ class ChatMessagePayload(BaseModel): register_schema_models(console_ns, CompletionMessageExplorePayload, ChatMessagePayload) -register_response_schema_models(console_ns, SimpleResultResponse) +register_response_schema_models(console_ns, GeneratedAppResponse, SimpleResultResponse) # define completion api for user @@ -84,7 +83,9 @@ register_response_schema_models(console_ns, SimpleResultResponse) ) class CompletionApi(InstalledAppResource): @console_ns.expect(console_ns.models[CompletionMessageExplorePayload.__name__]) - def post(self, installed_app: InstalledApp): + @console_ns.response(200, "Success", console_ns.models[GeneratedAppResponse.__name__]) + @with_current_user + def post(self, current_user: Account, installed_app: InstalledApp): app_model = installed_app.app if app_model is None: raise AppUnavailableError() @@ -101,8 +102,6 @@ class CompletionApi(InstalledAppResource): db.session.commit() try: - if not isinstance(current_user, Account): - raise ValueError("current_user must be an Account instance") response = AppGenerateService.generate( app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.EXPLORE, streaming=streaming ) @@ -160,7 +159,9 @@ class CompletionStopApi(InstalledAppResource): ) class ChatApi(InstalledAppResource): @console_ns.expect(console_ns.models[ChatMessagePayload.__name__]) - def post(self, installed_app: InstalledApp): + @console_ns.response(200, "Success", console_ns.models[GeneratedAppResponse.__name__]) + @with_current_user + def post(self, current_user: Account, installed_app: InstalledApp): app_model = installed_app.app if app_model is None: raise AppUnavailableError() @@ -177,8 +178,6 @@ class ChatApi(InstalledAppResource): db.session.commit() try: - if not isinstance(current_user, Account): - raise ValueError("current_user must be an Account instance") response = AppGenerateService.generate( app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.EXPLORE, streaming=True ) diff --git a/api/controllers/console/explore/conversation.py b/api/controllers/console/explore/conversation.py index 68e18a0207b..2004e648f19 100644 --- a/api/controllers/console/explore/conversation.py +++ b/api/controllers/console/explore/conversation.py @@ -7,10 +7,11 @@ from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import NotFound from controllers.common.controller_schemas import ConversationRenamePayload -from controllers.common.schema import 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.app.error import AppUnavailableError from controllers.console.explore.error import NotChatAppError from controllers.console.explore.wraps import InstalledAppResource +from controllers.console.wraps import with_current_user from core.app.entities.app_invoke_entities import InvokeFrom from extensions.ext_database import db from fields.conversation_fields import ( @@ -19,7 +20,6 @@ from fields.conversation_fields import ( SimpleConversation, ) from libs.helper import UUIDStrOrEmpty -from libs.login import current_user from models import Account from models.model import AppMode, InstalledApp from services.conversation_service import ConversationService @@ -36,7 +36,12 @@ class ConversationListQuery(BaseModel): register_schema_models(console_ns, ConversationListQuery, ConversationRenamePayload) -register_response_schema_models(console_ns, ResultResponse) +register_response_schema_models( + console_ns, + ConversationInfiniteScrollPagination, + ResultResponse, + SimpleConversation, +) @console_ns.route( @@ -44,8 +49,10 @@ register_response_schema_models(console_ns, ResultResponse) endpoint="installed_app_conversations", ) class ConversationListApi(InstalledAppResource): - @console_ns.expect(console_ns.models[ConversationListQuery.__name__]) - def get(self, installed_app: InstalledApp): + @console_ns.doc(params=query_params_from_model(ConversationListQuery)) + @console_ns.response(200, "Success", console_ns.models[ConversationInfiniteScrollPagination.__name__]) + @with_current_user + def get(self, current_user: Account, installed_app: InstalledApp): app_model = installed_app.app if app_model is None: raise AppUnavailableError() @@ -66,14 +73,12 @@ class ConversationListApi(InstalledAppResource): args = ConversationListQuery.model_validate(raw_args) try: - if not isinstance(current_user, Account): - raise ValueError("current_user must be an Account instance") with sessionmaker(db.engine).begin() as session: pagination = WebConversationService.pagination_by_last_id( session=session, app_model=app_model, user=current_user, - last_id=str(args.last_id) if args.last_id else None, + last_id=args.last_id or None, limit=args.limit, invoke_from=InvokeFrom.EXPLORE, pinned=args.pinned, @@ -95,7 +100,8 @@ class ConversationListApi(InstalledAppResource): ) class ConversationApi(InstalledAppResource): @console_ns.response(204, "Conversation deleted successfully") - def delete(self, installed_app: InstalledApp, c_id: UUID): + @with_current_user + def delete(self, current_user: Account, installed_app: InstalledApp, c_id: UUID): app_model = installed_app.app if app_model is None: raise AppUnavailableError() @@ -105,8 +111,6 @@ class ConversationApi(InstalledAppResource): conversation_id = str(c_id) try: - if not isinstance(current_user, Account): - raise ValueError("current_user must be an Account instance") ConversationService.delete(app_model, conversation_id, current_user) except ConversationNotExistsError: raise NotFound("Conversation Not Exists.") @@ -120,7 +124,9 @@ class ConversationApi(InstalledAppResource): ) class ConversationRenameApi(InstalledAppResource): @console_ns.expect(console_ns.models[ConversationRenamePayload.__name__]) - def post(self, installed_app: InstalledApp, c_id: UUID): + @console_ns.response(200, "Conversation renamed successfully", console_ns.models[SimpleConversation.__name__]) + @with_current_user + def post(self, current_user: Account, installed_app: InstalledApp, c_id: UUID): app_model = installed_app.app if app_model is None: raise AppUnavailableError() @@ -133,8 +139,6 @@ class ConversationRenameApi(InstalledAppResource): payload = ConversationRenamePayload.model_validate(console_ns.payload or {}) try: - if not isinstance(current_user, Account): - raise ValueError("current_user must be an Account instance") conversation = ConversationService.rename( app_model, conversation_id, current_user, payload.name, payload.auto_generate ) @@ -153,7 +157,8 @@ class ConversationRenameApi(InstalledAppResource): ) class ConversationPinApi(InstalledAppResource): @console_ns.response(200, "Success", console_ns.models[ResultResponse.__name__]) - def patch(self, installed_app: InstalledApp, c_id: UUID): + @with_current_user + def patch(self, current_user: Account, installed_app: InstalledApp, c_id: UUID): app_model = installed_app.app if app_model is None: raise AppUnavailableError() @@ -164,8 +169,6 @@ class ConversationPinApi(InstalledAppResource): conversation_id = str(c_id) try: - if not isinstance(current_user, Account): - raise ValueError("current_user must be an Account instance") WebConversationService.pin(app_model, conversation_id, current_user) except ConversationNotExistsError: raise NotFound("Conversation Not Exists.") @@ -179,7 +182,8 @@ class ConversationPinApi(InstalledAppResource): ) class ConversationUnPinApi(InstalledAppResource): @console_ns.response(200, "Success", console_ns.models[ResultResponse.__name__]) - def patch(self, installed_app: InstalledApp, c_id: UUID): + @with_current_user + def patch(self, current_user: Account, installed_app: InstalledApp, c_id: UUID): app_model = installed_app.app if app_model is None: raise AppUnavailableError() @@ -188,8 +192,6 @@ class ConversationUnPinApi(InstalledAppResource): raise NotChatAppError() conversation_id = str(c_id) - if not isinstance(current_user, Account): - raise ValueError("current_user must be an Account instance") WebConversationService.unpin(app_model, conversation_id, current_user) return ResultResponse(result="success").model_dump(mode="json") diff --git a/api/controllers/console/explore/installed_app.py b/api/controllers/console/explore/installed_app.py index 0ab7a96f112..14837897502 100644 --- a/api/controllers/console/explore/installed_app.py +++ b/api/controllers/console/explore/installed_app.py @@ -9,7 +9,7 @@ from sqlalchemy import and_, exists, or_, select from werkzeug.exceptions import BadRequest, Forbidden, NotFound from controllers.common.fields import SimpleMessageResponse, SimpleResultMessageResponse -from controllers.common.schema import 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.explore.wraps import InstalledAppResource from controllers.console.wraps import ( @@ -153,6 +153,7 @@ register_response_schema_models(console_ns, SimpleMessageResponse, SimpleResultM class InstalledAppsListApi(Resource): @login_required @account_initialization_required + @console_ns.doc(params=query_params_from_model(InstalledAppsListQuery)) @console_ns.response(200, "Success", console_ns.models[InstalledAppListResponse.__name__]) @with_current_user @with_current_tenant_id @@ -234,6 +235,7 @@ class InstalledAppsListApi(Resource): @login_required @account_initialization_required @cloud_edition_billing_resource_check("apps") + @console_ns.expect(console_ns.models[InstalledAppCreatePayload.__name__]) @console_ns.response(200, "Success", console_ns.models[SimpleMessageResponse.__name__]) @with_current_tenant_id def post(self, current_tenant_id: str): @@ -295,6 +297,7 @@ class InstalledAppApi(InstalledAppResource): return "", 204 @console_ns.response(200, "Success", console_ns.models[SimpleResultMessageResponse.__name__]) + @console_ns.expect(console_ns.models[InstalledAppUpdatePayload.__name__]) def patch(self, installed_app: InstalledApp): payload = InstalledAppUpdatePayload.model_validate(console_ns.payload or {}) diff --git a/api/controllers/console/explore/message.py b/api/controllers/console/explore/message.py index a19355f90ba..1c82f6ba9d4 100644 --- a/api/controllers/console/explore/message.py +++ b/api/controllers/console/explore/message.py @@ -7,7 +7,8 @@ from pydantic import BaseModel, TypeAdapter from werkzeug.exceptions import InternalServerError, NotFound from controllers.common.controller_schemas import MessageFeedbackPayload, MessageListQuery -from controllers.common.schema import register_response_schema_models, register_schema_models +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, AppUnavailableError, @@ -52,7 +53,13 @@ class MoreLikeThisQuery(BaseModel): register_schema_models(console_ns, MessageListQuery, MessageFeedbackPayload, MoreLikeThisQuery) -register_response_schema_models(console_ns, ResultResponse, SuggestedQuestionsResponse) +register_response_schema_models( + console_ns, + GeneratedAppResponse, + MessageInfiniteScrollPagination, + ResultResponse, + SuggestedQuestionsResponse, +) @console_ns.route( @@ -60,7 +67,8 @@ register_response_schema_models(console_ns, ResultResponse, SuggestedQuestionsRe endpoint="installed_app_messages", ) class MessageListApi(InstalledAppResource): - @console_ns.expect(console_ns.models[MessageListQuery.__name__]) + @console_ns.doc(params=query_params_from_model(MessageListQuery)) + @console_ns.response(200, "Success", console_ns.models[MessageInfiniteScrollPagination.__name__]) @with_current_user def get(self, current_user: Account, installed_app: InstalledApp): app_model = installed_app.app @@ -129,7 +137,8 @@ class MessageFeedbackApi(InstalledAppResource): endpoint="installed_app_more_like_this", ) class MessageMoreLikeThisApi(InstalledAppResource): - @console_ns.expect(console_ns.models[MoreLikeThisQuery.__name__]) + @console_ns.doc(params=query_params_from_model(MoreLikeThisQuery)) + @console_ns.response(200, "Success", console_ns.models[GeneratedAppResponse.__name__]) @with_current_user def get(self, current_user: Account, installed_app: InstalledApp, message_id: UUID): app_model = installed_app.app diff --git a/api/controllers/console/explore/parameter.py b/api/controllers/console/explore/parameter.py index 0f296277464..37d29eda3fb 100644 --- a/api/controllers/console/explore/parameter.py +++ b/api/controllers/console/explore/parameter.py @@ -1,6 +1,9 @@ from typing import Any, cast +from pydantic import BaseModel, Field + from controllers.common import fields +from controllers.common.schema import register_response_schema_models from controllers.console import console_ns from controllers.console.app.error import AppUnavailableError from controllers.console.explore.wraps import InstalledAppResource @@ -9,10 +12,18 @@ from models.model import AppMode, InstalledApp from services.app_service import AppService +class ExploreAppMetaResponse(BaseModel): + tool_icons: dict[str, Any] = Field(default_factory=dict) + + +register_response_schema_models(console_ns, fields.Parameters, ExploreAppMetaResponse) + + @console_ns.route("/installed-apps//parameters", endpoint="installed_app_parameters") class AppParameterApi(InstalledAppResource): """Resource for app variables.""" + @console_ns.response(200, "Success", console_ns.models[fields.Parameters.__name__]) def get(self, installed_app: InstalledApp): """Retrieve app parameters.""" app_model = installed_app.app @@ -42,6 +53,7 @@ class AppParameterApi(InstalledAppResource): @console_ns.route("/installed-apps//meta", endpoint="installed_app_meta") class ExploreAppMetaApi(InstalledAppResource): + @console_ns.response(200, "Success", console_ns.models[ExploreAppMetaResponse.__name__]) def get(self, installed_app: InstalledApp): """Get app meta""" app_model = installed_app.app diff --git a/api/controllers/console/explore/recommended_app.py b/api/controllers/console/explore/recommended_app.py index 44f9c1e7e3a..72f797eeb36 100644 --- a/api/controllers/console/explore/recommended_app.py +++ b/api/controllers/console/explore/recommended_app.py @@ -3,12 +3,13 @@ from uuid import UUID from flask import request from flask_restx import Resource -from pydantic import BaseModel, Field, computed_field, field_validator +from pydantic import BaseModel, Field, RootModel, computed_field, field_validator from constants.languages import languages -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.wraps import account_initialization_required, with_current_user +from extensions.ext_database import db from fields.base import ResponseModel from libs.helper import build_icon_url from libs.login import login_required @@ -65,13 +66,31 @@ class RecommendedAppListResponse(ResponseModel): categories: list[str] +class LearnDifyAppListResponse(ResponseModel): + recommended_apps: list[RecommendedAppResponse] + + +class RecommendedAppDetailResponse(RootModel[dict[str, Any]]): + root: dict[str, Any] + + register_schema_models( console_ns, RecommendedAppsQuery, RecommendedAppInfoResponse, RecommendedAppResponse, RecommendedAppListResponse, + LearnDifyAppListResponse, ) +register_response_schema_models(console_ns, RecommendedAppDetailResponse) + + +def _resolve_language(language: str | None, user: Account) -> str: + if language and language in languages: + return language + if user.interface_language: + return user.interface_language + return languages[0] @console_ns.route("/explore/apps") @@ -84,23 +103,35 @@ class RecommendedAppListApi(Resource): def get(self, current_user: Account): # language args args = RecommendedAppsQuery.model_validate(request.args.to_dict(flat=True)) - language = args.language - if language and language in languages: - language_prefix = language - elif current_user.interface_language: - language_prefix = current_user.interface_language - else: - language_prefix = languages[0] + language_prefix = _resolve_language(args.language, current_user) return RecommendedAppListResponse.model_validate( - RecommendedAppService.get_recommended_apps_and_categories(language_prefix), + RecommendedAppService.get_recommended_apps_and_categories(db.session, language_prefix), + from_attributes=True, + ).model_dump(mode="json") + + +@console_ns.route("/explore/apps/learn-dify") +class LearnDifyAppListApi(Resource): + @console_ns.doc(params=query_params_from_model(RecommendedAppsQuery)) + @console_ns.response(200, "Success", console_ns.models[LearnDifyAppListResponse.__name__]) + @login_required + @account_initialization_required + @with_current_user + def get(self, current_user: Account): + args = RecommendedAppsQuery.model_validate(request.args.to_dict(flat=True)) + language_prefix = _resolve_language(args.language, current_user) + + return LearnDifyAppListResponse.model_validate( + RecommendedAppService.get_learn_dify_apps(db.session, language_prefix), from_attributes=True, ).model_dump(mode="json") @console_ns.route("/explore/apps/") class RecommendedAppApi(Resource): + @console_ns.response(200, "Success", console_ns.models[RecommendedAppDetailResponse.__name__]) @login_required @account_initialization_required def get(self, app_id: UUID): - return RecommendedAppService.get_recommend_app_detail(str(app_id)) + return RecommendedAppService.get_recommend_app_detail(db.session, str(app_id)) diff --git a/api/controllers/console/explore/saved_message.py b/api/controllers/console/explore/saved_message.py index cf48eeea725..3e8f1ce9083 100644 --- a/api/controllers/console/explore/saved_message.py +++ b/api/controllers/console/explore/saved_message.py @@ -5,7 +5,7 @@ from pydantic import TypeAdapter from werkzeug.exceptions import NotFound from controllers.common.controller_schemas import SavedMessageCreatePayload, SavedMessageListQuery -from controllers.common.schema import 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.app.error import AppUnavailableError from controllers.console.explore.error import NotCompletionAppError @@ -19,12 +19,13 @@ from services.errors.message import MessageNotExistsError from services.saved_message_service import SavedMessageService register_schema_models(console_ns, SavedMessageListQuery, SavedMessageCreatePayload) -register_response_schema_models(console_ns, ResultResponse) +register_response_schema_models(console_ns, ResultResponse, SavedMessageInfiniteScrollPagination) @console_ns.route("/installed-apps//saved-messages", endpoint="installed_app_saved_messages") class SavedMessageListApi(InstalledAppResource): - @console_ns.expect(console_ns.models[SavedMessageListQuery.__name__]) + @console_ns.doc(params=query_params_from_model(SavedMessageListQuery)) + @console_ns.response(200, "Success", console_ns.models[SavedMessageInfiniteScrollPagination.__name__]) @with_current_user def get(self, current_user: Account, installed_app: InstalledApp): app_model = installed_app.app diff --git a/api/controllers/console/explore/trial.py b/api/controllers/console/explore/trial.py index 26b48ec599a..ad98dd303fb 100644 --- a/api/controllers/console/explore/trial.py +++ b/api/controllers/console/explore/trial.py @@ -3,14 +3,25 @@ from typing import Any, Literal, cast from flask import request from flask_restx import Resource, fields, marshal, marshal_with -from pydantic import BaseModel +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 Parameters as ParametersResponse from controllers.common.fields import Site as SiteResponse -from controllers.common.schema import get_or_create_model, register_schema_models +from controllers.common.schema import ( + get_or_create_model, + query_params_from_model, + register_response_schema_models, + register_schema_models, +) from controllers.console import console_ns from controllers.console.app.error import ( AppUnavailableError, @@ -33,6 +44,7 @@ from controllers.console.explore.error import ( NotWorkflowAppError, ) from controllers.console.explore.wraps import TrialAppResource, trial_feature_enable +from controllers.console.wraps import with_current_user from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict from core.app.apps.base_app_queue_manager import AppQueueManager @@ -53,6 +65,7 @@ from fields.app_fields import ( ) from fields.dataset_fields import dataset_fields from fields.member_fields import simple_account_fields +from fields.message_fields import SuggestedQuestionsResponse from fields.workflow_fields import ( conversation_variable_fields, pipeline_variable_fields, @@ -63,7 +76,6 @@ 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.login import current_user from models import Account from models.account import TenantStatus from models.model import AppMode, Site @@ -119,16 +131,28 @@ workflow_fields_copy["conversation_variables"] = fields.List(fields.Nested(conve 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 WorkflowRunRequest(BaseModel): inputs: dict - files: list | None = None + files: list | None = Field(default=None) class ChatRequest(BaseModel): inputs: dict query: str - files: list | None = None + files: list | None = Field(default=None) conversation_id: str | None = None parent_message_id: str | None = None retriever_from: str = "explore_app" @@ -144,18 +168,43 @@ class TextToSpeechRequest(BaseModel): class CompletionRequest(BaseModel): inputs: dict query: str = "" - files: list | None = None + files: list | None = Field(default=None) response_mode: Literal["blocking", "streaming"] | None = None retriever_from: str = "explore_app" -register_schema_models(console_ns, WorkflowRunRequest, ChatRequest, TextToSpeechRequest, CompletionRequest) +class TrialDatasetListQuery(BaseModel): + page: int = Field(default=1, ge=1, description="Page number") + limit: int = Field(default=20, ge=1, description="Number of items per page") + ids: list[str] = Field(default_factory=list, description="Dataset IDs") + + +register_schema_models( + console_ns, + WorkflowRunRequest, + ChatRequest, + TextToSpeechRequest, + 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__]) - def post(self, trial_app): + @console_ns.response(200, "Success", console_ns.models[GeneratedAppResponse.__name__]) + @with_current_user + def post(self, current_user: Account, trial_app): """ Run workflow """ @@ -168,14 +217,13 @@ class TrialAppWorkflowRunApi(TrialAppResource): request_data = WorkflowRunRequest.model_validate(console_ns.payload) args = request_data.model_dump() - assert current_user is not None try: app_id = app_model.id user_id = current_user.id response = AppGenerateService.generate( app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.EXPLORE, streaming=True ) - RecommendedAppService.add_trial_app_record(app_id, user_id) + RecommendedAppService.add_trial_app_record(db.session, app_id, user_id) return helper.compact_generate_response(response) except ProviderTokenNotInitError as ex: raise ProviderNotInitializeError(ex.description) @@ -195,6 +243,7 @@ class TrialAppWorkflowRunApi(TrialAppResource): class TrialAppWorkflowTaskStopApi(TrialAppResource): + @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) @trial_feature_enable def post(self, trial_app, task_id: str): """ @@ -206,7 +255,6 @@ class TrialAppWorkflowTaskStopApi(TrialAppResource): app_mode = AppMode.value_of(app_model.mode) if app_mode != AppMode.WORKFLOW: raise NotWorkflowAppError() - assert current_user is not None # Stop using both mechanisms for backward compatibility # Legacy stop flag mechanism (without user check) @@ -220,8 +268,10 @@ class TrialAppWorkflowTaskStopApi(TrialAppResource): class TrialChatApi(TrialAppResource): @console_ns.expect(console_ns.models[ChatRequest.__name__]) + @console_ns.response(200, "Success", console_ns.models[GeneratedAppResponse.__name__]) @trial_feature_enable - def post(self, trial_app): + @with_current_user + def post(self, current_user: Account, trial_app): app_model = trial_app app_mode = AppMode.value_of(app_model.mode) if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}: @@ -239,9 +289,6 @@ class TrialChatApi(TrialAppResource): args["auto_generate_name"] = False try: - if not isinstance(current_user, Account): - raise ValueError("current_user must be an Account instance") - # Get IDs before they might be detached from session app_id = app_model.id user_id = current_user.id @@ -249,7 +296,7 @@ class TrialChatApi(TrialAppResource): response = AppGenerateService.generate( app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.EXPLORE, streaming=True ) - RecommendedAppService.add_trial_app_record(app_id, user_id) + RecommendedAppService.add_trial_app_record(db.session, app_id, user_id) return helper.compact_generate_response(response) except services.errors.conversation.ConversationNotExistsError: raise NotFound("Conversation Not Exists.") @@ -276,7 +323,9 @@ class TrialChatApi(TrialAppResource): class TrialMessageSuggestedQuestionApi(TrialAppResource): - def get(self, trial_app, message_id): + @console_ns.response(200, "Success", console_ns.models[SuggestedQuestionsResponse.__name__]) + @with_current_user + def get(self, current_user: Account, trial_app, message_id): app_model = trial_app app_mode = AppMode.value_of(app_model.mode) if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}: @@ -285,8 +334,6 @@ class TrialMessageSuggestedQuestionApi(TrialAppResource): message_id = str(message_id) try: - if not isinstance(current_user, Account): - raise ValueError("current_user must be an Account instance") questions = MessageService.get_suggested_questions_after_answer( app_model=app_model, user=current_user, message_id=message_id, invoke_from=InvokeFrom.EXPLORE ) @@ -312,22 +359,21 @@ class TrialMessageSuggestedQuestionApi(TrialAppResource): class TrialChatAudioApi(TrialAppResource): + @console_ns.response(200, "Success", console_ns.models[AudioTranscriptResponse.__name__]) @trial_feature_enable - def post(self, trial_app): + @with_current_user + def post(self, current_user: Account, trial_app): app_model = trial_app file = request.files["file"] try: - if not isinstance(current_user, Account): - raise ValueError("current_user must be an Account instance") - # Get IDs before they might be detached from session app_id = app_model.id user_id = current_user.id response = AudioService.transcript_asr(app_model=app_model, file=file, end_user=None) - RecommendedAppService.add_trial_app_record(app_id, user_id) + RecommendedAppService.add_trial_app_record(db.session, app_id, user_id) return response except services.errors.app_model_config.AppModelConfigBrokenError: logger.exception("App model config broken.") @@ -357,8 +403,10 @@ class TrialChatAudioApi(TrialAppResource): class TrialChatTextApi(TrialAppResource): @console_ns.expect(console_ns.models[TextToSpeechRequest.__name__]) + @console_ns.response(200, "Success", console_ns.models[AudioBinaryResponse.__name__]) @trial_feature_enable - def post(self, trial_app): + @with_current_user + def post(self, current_user: Account, trial_app): app_model = trial_app try: request_data = TextToSpeechRequest.model_validate(console_ns.payload) @@ -366,15 +414,13 @@ class TrialChatTextApi(TrialAppResource): message_id = request_data.message_id text = request_data.text voice = request_data.voice - if not isinstance(current_user, Account): - raise ValueError("current_user must be an Account instance") # Get IDs before they might be detached from session app_id = app_model.id user_id = current_user.id response = AudioService.transcript_tts(app_model=app_model, text=text, voice=voice, message_id=message_id) - RecommendedAppService.add_trial_app_record(app_id, user_id) + RecommendedAppService.add_trial_app_record(db.session, app_id, user_id) return response except services.errors.app_model_config.AppModelConfigBrokenError: logger.exception("App model config broken.") @@ -404,8 +450,10 @@ class TrialChatTextApi(TrialAppResource): class TrialCompletionApi(TrialAppResource): @console_ns.expect(console_ns.models[CompletionRequest.__name__]) + @console_ns.response(200, "Success", console_ns.models[GeneratedAppResponse.__name__]) @trial_feature_enable - def post(self, trial_app): + @with_current_user + def post(self, current_user: Account, trial_app): app_model = trial_app if app_model.mode != "completion": raise NotCompletionAppError() @@ -417,9 +465,6 @@ class TrialCompletionApi(TrialAppResource): args["auto_generate_name"] = False try: - if not isinstance(current_user, Account): - raise ValueError("current_user must be an Account instance") - # Get IDs before they might be detached from session app_id = app_model.id user_id = current_user.id @@ -428,7 +473,7 @@ class TrialCompletionApi(TrialAppResource): app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.EXPLORE, streaming=streaming ) - RecommendedAppService.add_trial_app_record(app_id, user_id) + RecommendedAppService.add_trial_app_record(db.session, app_id, user_id) return helper.compact_generate_response(response) except services.errors.conversation.ConversationNotExistsError: raise NotFound("Conversation Not Exists.") @@ -455,6 +500,7 @@ class TrialCompletionApi(TrialAppResource): class TrialSitApi(Resource): """Resource for trial app sites.""" + @console_ns.response(200, "Success", console_ns.models[SiteResponse.__name__]) @get_app_model_with_trial(None) def get(self, app_model): """Retrieve app site info. @@ -476,6 +522,7 @@ class TrialSitApi(Resource): class TrialAppParameterApi(Resource): """Resource for app variables.""" + @console_ns.response(200, "Success", console_ns.models[ParametersResponse.__name__]) @get_app_model_with_trial(None) def get(self, app_model): """Retrieve app parameters.""" @@ -504,6 +551,7 @@ class TrialAppParameterApi(Resource): 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) def get(self, app_model): @@ -516,6 +564,7 @@ class AppApi(Resource): class AppWorkflowApi(Resource): + @console_ns.response(200, "Success", workflow_model) @get_app_model_with_trial(None) @marshal_with(workflow_model) def get(self, app_model): @@ -528,6 +577,8 @@ class AppWorkflowApi(Resource): class DatasetListApi(Resource): + @console_ns.doc(params=query_params_from_model(TrialDatasetListQuery)) + @console_ns.response(200, "Success", dataset_list_model) @get_app_model_with_trial(None) def get(self, app_model): page = request.args.get("page", default=1, type=int) diff --git a/api/controllers/console/explore/workflow.py b/api/controllers/console/explore/workflow.py index ebd13e586b6..f4e9e3cd7ef 100644 --- a/api/controllers/console/explore/workflow.py +++ b/api/controllers/console/explore/workflow.py @@ -3,7 +3,7 @@ import logging from werkzeug.exceptions import InternalServerError from controllers.common.controller_schemas import WorkflowRunPayload -from controllers.common.fields import SimpleResultResponse +from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse from controllers.common.schema import register_response_schema_models, register_schema_model from controllers.console.app.error import ( CompletionRequestError, @@ -36,12 +36,13 @@ from .. import console_ns logger = logging.getLogger(__name__) register_schema_model(console_ns, WorkflowRunPayload) -register_response_schema_models(console_ns, SimpleResultResponse) +register_response_schema_models(console_ns, GeneratedAppResponse, 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__]) @with_current_user def post(self, current_user: Account, installed_app: InstalledApp): """ diff --git a/api/controllers/console/extension.py b/api/controllers/console/extension.py index 26a348da404..6d9362ae0b1 100644 --- a/api/controllers/console/extension.py +++ b/api/controllers/console/extension.py @@ -14,7 +14,7 @@ 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_SWAGGER_2_0, register_schema_models +from ..common.schema import DEFAULT_REF_TEMPLATE_OPENAPI_3_0, query_params_from_model, register_schema_models from . import console_ns from .wraps import account_initialization_required, setup_required, with_current_tenant_id @@ -60,10 +60,16 @@ class APIBasedExtensionResponse(ResponseModel): return to_timestamp(value) -register_schema_models(console_ns, APIBasedExtensionPayload, CodeBasedExtensionResponse, APIBasedExtensionResponse) +register_schema_models( + console_ns, + CodeBasedExtensionQuery, + APIBasedExtensionPayload, + CodeBasedExtensionResponse, + APIBasedExtensionResponse, +) console_ns.schema_model( "APIBasedExtensionListResponse", - TypeAdapter(list[APIBasedExtensionResponse]).json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), + TypeAdapter(list[APIBasedExtensionResponse]).json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0), ) @@ -90,7 +96,7 @@ def _serialize_saved_api_based_extension(extension: APIBasedExtension, api_key: class CodeBasedExtensionAPI(Resource): @console_ns.doc("get_code_based_extension") @console_ns.doc(description="Get code-based extension data by module name") - @console_ns.doc(params={"module": "Extension module name"}) + @console_ns.doc(params=query_params_from_model(CodeBasedExtensionQuery)) @console_ns.response( 200, "Success", diff --git a/api/controllers/console/feature.py b/api/controllers/console/feature.py index b221db697ba..3b1b414150a 100644 --- a/api/controllers/console/feature.py +++ b/api/controllers/console/feature.py @@ -1,10 +1,9 @@ from flask_restx import Resource -from werkzeug.exceptions import Unauthorized from controllers.common.schema import register_response_schema_models from fields.base import ResponseModel from libs.helper import dump_response -from libs.login import current_user, login_required +from libs.login import current_account_with_tenant_optional, login_required from services.feature_service import ( FeatureModel, FeatureService, @@ -13,7 +12,12 @@ from services.feature_service import ( ) from . import console_ns -from .wraps import account_initialization_required, cloud_utm_record, setup_required, with_current_tenant_id +from .wraps import ( + account_initialization_required, + cloud_utm_record, + setup_required, + with_current_tenant_id, +) class TrialModelsResponse(ResponseModel): @@ -133,12 +137,6 @@ class SystemFeatureApi(Resource): Only non-sensitive configuration data should be returned by this endpoint. """ - # NOTE(QuantumGhost): ideally we should access `current_user.is_authenticated` - # without a try-catch. However, due to the implementation of user loader (the `load_user_from_request` - # in api/extensions/ext_login.py), accessing `current_user.is_authenticated` will - # raise `Unauthorized` exception if authentication token is not provided. - try: - is_authenticated = current_user.is_authenticated - except Unauthorized: - is_authenticated = False + current_user, _ = current_account_with_tenant_optional() + is_authenticated = current_user is not None return FeatureService.get_system_features(is_authenticated=is_authenticated).model_dump() diff --git a/api/controllers/console/human_input_form.py b/api/controllers/console/human_input_form.py index 0ca9d18168e..9700667f4aa 100644 --- a/api/controllers/console/human_input_form.py +++ b/api/controllers/console/human_input_form.py @@ -5,15 +5,18 @@ Console/Studio Human Input Form APIs. import json import logging from collections.abc import Generator +from typing import Any from flask import Response, jsonify, request from flask_restx import Resource +from pydantic import RootModel from sqlalchemy import select from sqlalchemy.orm import Session, sessionmaker from controllers.common.errors import InvalidArgumentError, NotFoundError +from controllers.common.fields import EventStreamResponse from controllers.common.human_input import HumanInputFormSubmitPayload -from controllers.common.schema import register_schema_models +from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.wraps import ( account_initialization_required, @@ -40,7 +43,22 @@ from services.workflow_event_snapshot_service import build_workflow_event_stream logger = logging.getLogger(__name__) + +class ConsoleHumanInputFormDefinitionResponse(RootModel[dict[str, Any]]): + root: dict[str, Any] + + +class ConsoleHumanInputFormSubmitResponse(RootModel[dict[str, Any]]): + root: dict[str, Any] + + register_schema_models(console_ns, HumanInputFormSubmitPayload) +register_response_schema_models( + console_ns, + ConsoleHumanInputFormDefinitionResponse, + ConsoleHumanInputFormSubmitResponse, + EventStreamResponse, +) def _jsonify_form_definition(form: Form) -> Response: @@ -67,6 +85,7 @@ class ConsoleHumanInputFormApi(Resource): @setup_required @login_required @account_initialization_required + @console_ns.response(200, "Success", console_ns.models[ConsoleHumanInputFormDefinitionResponse.__name__]) @with_current_tenant_id def get(self, current_tenant_id: str, form_token: str): """ @@ -89,6 +108,7 @@ class ConsoleHumanInputFormApi(Resource): @with_current_tenant_id @model_validate(HumanInputFormSubmitPayload) @console_ns.expect(console_ns.models[HumanInputFormSubmitPayload.__name__]) + @console_ns.response(200, "Success", console_ns.models[ConsoleHumanInputFormSubmitResponse.__name__]) def post( self, payload: HumanInputFormSubmitPayload, @@ -136,6 +156,7 @@ class ConsoleHumanInputFormApi(Resource): class ConsoleWorkflowEventsApi(Resource): """Console API for getting workflow execution events after resume.""" + @console_ns.response(200, "SSE event stream", console_ns.models[EventStreamResponse.__name__]) @account_initialization_required @login_required @with_current_user diff --git a/api/controllers/console/notification.py b/api/controllers/console/notification.py index ee59f3d564a..798d674f9e9 100644 --- a/api/controllers/console/notification.py +++ b/api/controllers/console/notification.py @@ -6,7 +6,7 @@ from flask_restx import Resource from pydantic import BaseModel, Field from controllers.common.fields import SimpleResultResponse -from controllers.common.schema import register_response_schema_models +from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.wraps import ( account_initialization_required, @@ -14,6 +14,7 @@ from controllers.console.wraps import ( setup_required, with_current_user, ) +from fields.base import ResponseModel from libs.login import login_required from models import Account from services.billing_service import BillingService @@ -56,7 +57,23 @@ class DismissNotificationPayload(BaseModel): notification_id: str = Field(...) -register_response_schema_models(console_ns, SimpleResultResponse) +class NotificationItemResponse(ResponseModel): + notification_id: str | None = None + frequency: str | None = None + lang: str + title: str + subtitle: str + body: str + title_pic_url: str + + +class NotificationResponse(ResponseModel): + should_show: bool + notifications: list[NotificationItemResponse] + + +register_schema_models(console_ns, DismissNotificationPayload) +register_response_schema_models(console_ns, SimpleResultResponse, NotificationResponse) @console_ns.route("/notification") @@ -74,6 +91,7 @@ class NotificationApi(Resource): 401: "Unauthorized", }, ) + @console_ns.response(200, "Success", console_ns.models[NotificationResponse.__name__]) @setup_required @login_required @with_current_user @@ -121,6 +139,7 @@ class NotificationDismissApi(Resource): @with_current_user @account_initialization_required @only_edition_cloud + @console_ns.expect(console_ns.models[DismissNotificationPayload.__name__]) @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) def post(self, current_user: Account): payload = DismissNotificationPayload.model_validate(request.get_json()) diff --git a/api/controllers/console/snippets/payloads.py b/api/controllers/console/snippets/payloads.py index 24cc990fa77..70730e754de 100644 --- a/api/controllers/console/snippets/payloads.py +++ b/api/controllers/console/snippets/payloads.py @@ -76,7 +76,7 @@ class CreateSnippetPayload(BaseModel): description: str | None = Field(default=None, max_length=2000) type: Literal["node", "group"] = "node" icon_info: IconInfo | None = None - graph: dict[str, Any] | None = None + graph: dict[str, Any] | None = Field(default=None) input_fields: list[InputFieldDefinition] | None = Field(default_factory=list) @@ -97,7 +97,7 @@ class SnippetDraftSyncPayload(BaseModel): default=None, description="Ignored. Snippet workflows do not persist conversation variables.", ) - input_fields: list[dict[str, Any]] | None = None + input_fields: list[dict[str, Any]] | None = Field(default=None) class SnippetWorkflowListQuery(BaseModel): @@ -118,7 +118,7 @@ class SnippetDraftRunPayload(BaseModel): """Payload for running snippet draft workflow.""" inputs: dict[str, Any] - files: list[dict[str, Any]] | None = None + files: list[dict[str, Any]] | None = Field(default=None) class SnippetDraftNodeRunPayload(BaseModel): @@ -126,25 +126,25 @@ class SnippetDraftNodeRunPayload(BaseModel): inputs: dict[str, Any] query: str = "" - files: list[dict[str, Any]] | None = None + files: list[dict[str, Any]] | None = Field(default=None) class SnippetIterationNodeRunPayload(BaseModel): """Payload for running an iteration node in snippet draft workflow.""" - inputs: dict[str, Any] | None = None + inputs: dict[str, Any] | None = Field(default=None) class SnippetLoopNodeRunPayload(BaseModel): """Payload for running a loop node in snippet draft workflow.""" - inputs: dict[str, Any] | None = None + inputs: dict[str, Any] | None = Field(default=None) class PublishWorkflowPayload(BaseModel): """Payload for publishing snippet workflow.""" - knowledge_base_setting: dict[str, Any] | None = None + knowledge_base_setting: dict[str, Any] | None = Field(default=None) class SnippetImportPayload(BaseModel): diff --git a/api/controllers/console/snippets/snippet_workflow.py b/api/controllers/console/snippets/snippet_workflow.py index 59608afc554..243c29e6719 100644 --- a/api/controllers/console/snippets/snippet_workflow.py +++ b/api/controllers/console/snippets/snippet_workflow.py @@ -4,17 +4,21 @@ from functools import wraps from flask import request from flask_restx import Resource -from pydantic import Field +from pydantic import BaseModel, Field from sqlalchemy.orm import Session, sessionmaker from werkzeug.exceptions import BadRequest, InternalServerError, NotFound -from controllers.common.schema import register_response_schema_models, register_schema_models +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.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, WorkflowPaginationResponse, + WorkflowPublishResponse, WorkflowResponse, + WorkflowRestoreResponse, ) from controllers.console.snippets.payloads import ( PublishWorkflowPayload, @@ -69,6 +73,10 @@ class SnippetWorkflowResponse(WorkflowResponse): input_fields: list[dict] = Field(default_factory=list) +class SnippetDraftConfigResponse(BaseModel): + parallel_depth_limit: int + + register_schema_models( console_ns, SnippetDraftSyncPayload, @@ -82,8 +90,14 @@ register_schema_models( ) register_response_schema_models( console_ns, + DefaultBlockConfigsResponse, + GeneratedAppResponse, + SimpleResultResponse, + SnippetDraftConfigResponse, SnippetWorkflowResponse, + WorkflowPublishResponse, WorkflowPaginationResponse, + WorkflowRestoreResponse, WorkflowRunPaginationResponse, WorkflowRunDetailResponse, WorkflowRunNodeExecutionListResponse, @@ -155,7 +169,11 @@ class SnippetDraftWorkflowApi(Resource): @console_ns.doc("sync_snippet_draft_workflow") @console_ns.expect(console_ns.models.get(SnippetDraftSyncPayload.__name__)) - @console_ns.response(200, "Draft workflow synced successfully") + @console_ns.response( + 200, + "Draft workflow synced successfully", + console_ns.models[WorkflowRestoreResponse.__name__], + ) @console_ns.response(400, "Hash mismatch") @setup_required @login_required @@ -191,7 +209,11 @@ class SnippetDraftWorkflowApi(Resource): @console_ns.route("/snippets//workflows/draft/config") class SnippetDraftConfigApi(Resource): @console_ns.doc("get_snippet_draft_config") - @console_ns.response(200, "Draft config retrieved successfully") + @console_ns.response( + 200, + "Draft config retrieved successfully", + console_ns.models[SnippetDraftConfigResponse.__name__], + ) @setup_required @login_required @account_initialization_required @@ -235,7 +257,7 @@ class SnippetPublishedWorkflowApi(Resource): @console_ns.doc("publish_snippet_workflow") @console_ns.expect(console_ns.models.get(PublishWorkflowPayload.__name__)) - @console_ns.response(200, "Workflow published successfully") + @console_ns.response(200, "Workflow published successfully", console_ns.models[WorkflowPublishResponse.__name__]) @console_ns.response(400, "No draft workflow found") @setup_required @login_required @@ -269,7 +291,11 @@ class SnippetPublishedWorkflowApi(Resource): @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.response( + 200, + "Default block configs retrieved successfully", + console_ns.models[DefaultBlockConfigsResponse.__name__], + ) @setup_required @login_required @account_initialization_required @@ -283,7 +309,7 @@ class SnippetDefaultBlockConfigsApi(Resource): @console_ns.route("/snippets//workflows") class SnippetPublishedAllWorkflowApi(Resource): - @console_ns.expect(console_ns.models[SnippetWorkflowListQuery.__name__]) + @console_ns.doc(params=query_params_from_model(SnippetWorkflowListQuery)) @console_ns.doc("get_all_snippet_published_workflows") @console_ns.doc(description="Get all published workflows for a snippet") @console_ns.doc(params={"snippet_id": "Snippet ID"}) @@ -326,7 +352,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.response(200, "Workflow restored successfully", console_ns.models[WorkflowRestoreResponse.__name__]) @console_ns.response(400, "Source workflow must be published") @console_ns.response(404, "Workflow not found") @setup_required @@ -362,6 +388,7 @@ class SnippetDraftWorkflowRestoreApi(Resource): @console_ns.route("/snippets//workflow-runs") class SnippetWorkflowRunsApi(Resource): @console_ns.doc("list_snippet_workflow_runs") + @console_ns.doc(params=query_params_from_model(WorkflowRunQuery)) @console_ns.response( 200, "Workflow runs retrieved successfully", @@ -535,7 +562,11 @@ class SnippetDraftRunIterationNodeApi(Resource): @console_ns.doc(description="Run draft workflow iteration node for snippet") @console_ns.doc(params={"snippet_id": "Snippet ID", "node_id": "Node ID"}) @console_ns.expect(console_ns.models.get(SnippetIterationNodeRunPayload.__name__)) - @console_ns.response(200, "Iteration node run started successfully (SSE stream)") + @console_ns.response( + 200, + "Iteration node run started successfully (SSE stream)", + console_ns.models[GeneratedAppResponse.__name__], + ) @console_ns.response(404, "Snippet or draft workflow not found") @setup_required @login_required @@ -576,7 +607,11 @@ class SnippetDraftRunLoopNodeApi(Resource): @console_ns.doc(description="Run draft workflow loop node for snippet") @console_ns.doc(params={"snippet_id": "Snippet ID", "node_id": "Node ID"}) @console_ns.expect(console_ns.models.get(SnippetLoopNodeRunPayload.__name__)) - @console_ns.response(200, "Loop node run started successfully (SSE stream)") + @console_ns.response( + 200, + "Loop node run started successfully (SSE stream)", + console_ns.models[GeneratedAppResponse.__name__], + ) @console_ns.response(404, "Snippet or draft workflow not found") @setup_required @login_required @@ -615,7 +650,11 @@ class SnippetDraftRunLoopNodeApi(Resource): class SnippetDraftWorkflowRunApi(Resource): @console_ns.doc("run_snippet_draft_workflow") @console_ns.expect(console_ns.models.get(SnippetDraftRunPayload.__name__)) - @console_ns.response(200, "Draft workflow run started successfully (SSE stream)") + @console_ns.response( + 200, + "Draft workflow run started successfully (SSE stream)", + console_ns.models[GeneratedAppResponse.__name__], + ) @console_ns.response(404, "Snippet or draft workflow not found") @setup_required @login_required @@ -654,7 +693,7 @@ class SnippetDraftWorkflowRunApi(Resource): @console_ns.route("/snippets//workflow-runs/tasks//stop") class SnippetWorkflowTaskStopApi(Resource): @console_ns.doc("stop_snippet_workflow_task") - @console_ns.response(200, "Task stopped successfully") + @console_ns.response(200, "Task stopped successfully", console_ns.models[SimpleResultResponse.__name__]) @console_ns.response(404, "Snippet not found") @setup_required @login_required diff --git a/api/controllers/console/snippets/snippet_workflow_draft_variable.py b/api/controllers/console/snippets/snippet_workflow_draft_variable.py index 491a78a9b93..a28ba07b5dd 100644 --- a/api/controllers/console/snippets/snippet_workflow_draft_variable.py +++ b/api/controllers/console/snippets/snippet_workflow_draft_variable.py @@ -19,9 +19,11 @@ from flask_restx import Resource, marshal, marshal_with from sqlalchemy.orm import Session, sessionmaker from controllers.common.errors import InvalidArgumentError, NotFoundError +from controllers.common.schema import query_params_from_model from controllers.console import console_ns from controllers.console.app.error import DraftWorkflowNotExist from controllers.console.app.workflow_draft_variable import ( + EnvironmentVariableListResponse, WorkflowDraftVariableListQuery, WorkflowDraftVariableUpdatePayload, ensure_variable_access, @@ -90,7 +92,7 @@ def _snippet_draft_var_prerequisite[T, **P, R]( @console_ns.route("/snippets//workflows/draft/variables") class SnippetWorkflowVariableCollectionApi(Resource): - @console_ns.expect(console_ns.models[WorkflowDraftVariableListQuery.__name__]) + @console_ns.doc(params=query_params_from_model(WorkflowDraftVariableListQuery)) @console_ns.doc("get_snippet_workflow_variables") @console_ns.doc(description="List draft workflow variables without values (paginated, snippet scope)") @console_ns.response( @@ -305,7 +307,11 @@ class SnippetSystemVariableCollectionApi(Resource): class SnippetEnvironmentVariableCollectionApi(Resource): @console_ns.doc("get_snippet_environment_variables") @console_ns.doc(description="Get environment variables from snippet draft workflow graph") - @console_ns.response(200, "Environment variables retrieved successfully") + @console_ns.response( + 200, + "Environment variables retrieved successfully", + console_ns.models[EnvironmentVariableListResponse.__name__], + ) @console_ns.response(404, "Draft workflow not found") @_snippet_draft_var_prerequisite def get(self, _current_user: Account, snippet: CustomizedSnippet) -> dict[str, list[dict[str, Any]]]: diff --git a/api/controllers/console/spec.py b/api/controllers/console/spec.py index 1795e2d1724..27b07b4dd81 100644 --- a/api/controllers/console/spec.py +++ b/api/controllers/console/spec.py @@ -1,7 +1,10 @@ import logging +from typing import Any from flask_restx import Resource +from pydantic import RootModel +from controllers.common.schema import register_response_schema_models from controllers.console.wraps import ( account_initialization_required, setup_required, @@ -14,8 +17,16 @@ from . import console_ns logger = logging.getLogger(__name__) +class SchemaDefinitionsResponse(RootModel[Any]): + root: Any + + +register_response_schema_models(console_ns, SchemaDefinitionsResponse) + + @console_ns.route("/spec/schema-definitions") class SpecSchemaDefinitionsApi(Resource): + @console_ns.response(200, "Success", console_ns.models[SchemaDefinitionsResponse.__name__]) @setup_required @login_required @account_initialization_required diff --git a/api/controllers/console/tag/tags.py b/api/controllers/console/tag/tags.py index d2ec06c062e..82a713f1c6f 100644 --- a/api/controllers/console/tag/tags.py +++ b/api/controllers/console/tag/tags.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, Field, field_validator from werkzeug.exceptions import Forbidden from controllers.common.fields import SimpleResultResponse -from controllers.common.schema import 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.wraps import ( account_initialization_required, @@ -16,6 +16,7 @@ from controllers.console.wraps import ( with_current_tenant_id, with_current_user, ) +from extensions.ext_database import db from fields.base import ResponseModel from libs.login import login_required from models import Account @@ -95,18 +96,13 @@ class TagListApi(Resource): @setup_required @login_required @account_initialization_required - @console_ns.doc( - params={ - "type": 'Tag type filter. Can be "knowledge", "app", or "snippet".', - "keyword": "Search keyword for tag name.", - } - ) + @console_ns.doc(params=query_params_from_model(TagListQueryParam)) @console_ns.doc(responses={200: ("Success", [console_ns.models[TagResponse.__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(param.type, current_tenant_id, param.keyword) + 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 diff --git a/api/controllers/console/workspace/account.py b/api/controllers/console/workspace/account.py index e58f34dc3b9..0ac26168bc5 100644 --- a/api/controllers/console/workspace/account.py +++ b/api/controllers/console/workspace/account.py @@ -6,7 +6,7 @@ from typing import Any, Literal import pytz from flask import request from flask_restx import Resource -from pydantic import BaseModel, Field, field_validator, model_validator +from pydantic import BaseModel, Field, RootModel, field_validator, model_validator from sqlalchemy import select from werkzeug.exceptions import NotFound @@ -236,6 +236,10 @@ class EducationAutocompleteResponse(ResponseModel): has_next: bool | None = None +class EducationActivateResponse(RootModel[dict[str, Any]]): + root: dict[str, Any] + + register_schema_models( console_ns, AccountIntegrateResponse, @@ -248,6 +252,7 @@ register_response_schema_models( console_ns, AccountResponse, AvatarUrlResponse, + EducationActivateResponse, SimpleResultDataResponse, SimpleResultResponse, VerificationTokenResponse, @@ -556,6 +561,7 @@ class EducationVerifyApi(Resource): @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__]) @setup_required @login_required @account_initialization_required @@ -585,7 +591,7 @@ class EducationApi(Resource): @console_ns.route("/account/education/autocomplete") class EducationAutoCompleteApi(Resource): - @console_ns.expect(console_ns.models[EducationAutocompleteQuery.__name__]) + @console_ns.doc(params=query_params_from_model(EducationAutocompleteQuery)) @setup_required @login_required @account_initialization_required diff --git a/api/controllers/console/workspace/agent_providers.py b/api/controllers/console/workspace/agent_providers.py index 8e968bb07f3..4ba2faefc74 100644 --- a/api/controllers/console/workspace/agent_providers.py +++ b/api/controllers/console/workspace/agent_providers.py @@ -1,5 +1,9 @@ -from flask_restx import Resource, fields +from typing import Any +from flask_restx import Resource +from pydantic import RootModel + +from controllers.common.schema import register_response_schema_models from controllers.console import console_ns from controllers.console.wraps import ( account_initialization_required, @@ -13,6 +17,17 @@ from models import Account from services.agent_service import AgentService +class AgentProviderListResponse(RootModel[list[dict[str, Any]]]): + root: list[dict[str, Any]] + + +class AgentProviderResponse(RootModel[dict[str, Any]]): + root: dict[str, Any] + + +register_response_schema_models(console_ns, AgentProviderListResponse, AgentProviderResponse) + + @console_ns.route("/workspaces/current/agent-providers") class AgentProviderListApi(Resource): @console_ns.doc("list_agent_providers") @@ -20,7 +35,7 @@ class AgentProviderListApi(Resource): @console_ns.response( 200, "Success", - fields.List(fields.Raw(description="Agent provider information")), + console_ns.models[AgentProviderListResponse.__name__], ) @setup_required @login_required @@ -39,7 +54,7 @@ class AgentProviderApi(Resource): @console_ns.response( 200, "Success", - fields.Raw(description="Agent provider details"), + console_ns.models[AgentProviderResponse.__name__], ) @setup_required @login_required diff --git a/api/controllers/console/workspace/endpoint.py b/api/controllers/console/workspace/endpoint.py index c539debf087..9c5aa166bc0 100644 --- a/api/controllers/console/workspace/endpoint.py +++ b/api/controllers/console/workspace/endpoint.py @@ -12,7 +12,7 @@ from flask import request from flask_restx import Resource from pydantic import BaseModel, Field -from controllers.common.schema import register_schema_models +from controllers.common.schema import query_params_from_model, register_schema_models from controllers.console import console_ns from controllers.console.wraps import ( account_initialization_required, @@ -61,11 +61,15 @@ class EndpointCreateResponse(BaseModel): class EndpointListResponse(BaseModel): - endpoints: list[dict[str, Any]] = Field(description="Endpoint information") + endpoints: list[dict[str, Any]] = Field( + description="Endpoint information", + ) class PluginEndpointListResponse(BaseModel): - endpoints: list[dict[str, Any]] = Field(description="Endpoint information") + endpoints: list[dict[str, Any]] = Field( + description="Endpoint information", + ) class EndpointDeleteResponse(BaseModel): @@ -201,7 +205,7 @@ class DeprecatedEndpointCreateApi(Resource): class EndpointListApi(Resource): @console_ns.doc("list_endpoints") @console_ns.doc(description="List plugin endpoints with pagination") - @console_ns.expect(console_ns.models[EndpointListQuery.__name__]) + @console_ns.doc(params=query_params_from_model(EndpointListQuery)) @console_ns.response( 200, "Success", @@ -234,7 +238,7 @@ class EndpointListApi(Resource): class EndpointListForSinglePluginApi(Resource): @console_ns.doc("list_plugin_endpoints") @console_ns.doc(description="List endpoints for a specific plugin") - @console_ns.expect(console_ns.models[EndpointListForPluginQuery.__name__]) + @console_ns.doc(params=query_params_from_model(EndpointListForPluginQuery)) @console_ns.response( 200, "Success", diff --git a/api/controllers/console/workspace/load_balancing_config.py b/api/controllers/console/workspace/load_balancing_config.py index 969483d1383..5983a4e10be 100644 --- a/api/controllers/console/workspace/load_balancing_config.py +++ b/api/controllers/console/workspace/load_balancing_config.py @@ -2,7 +2,7 @@ from flask_restx import Resource from pydantic import BaseModel from werkzeug.exceptions import Forbidden -from controllers.common.schema import register_schema_models +from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.wraps import ( account_initialization_required, @@ -10,6 +10,7 @@ from controllers.console.wraps import ( with_current_tenant_id, with_current_user, ) +from fields.base import ResponseModel from graphon.model_runtime.entities.model_entities import ModelType from graphon.model_runtime.errors.validate import CredentialsValidateFailedError from libs.login import login_required @@ -23,7 +24,13 @@ class LoadBalancingCredentialPayload(BaseModel): credentials: dict[str, object] +class LoadBalancingCredentialValidateResponse(ResponseModel): + result: str + error: str | None = None + + register_schema_models(console_ns, LoadBalancingCredentialPayload) +register_response_schema_models(console_ns, LoadBalancingCredentialValidateResponse) @console_ns.route( @@ -31,6 +38,11 @@ register_schema_models(console_ns, LoadBalancingCredentialPayload) ) class LoadBalancingCredentialsValidateApi(Resource): @console_ns.expect(console_ns.models[LoadBalancingCredentialPayload.__name__]) + @console_ns.response( + 200, + "Credential validation result", + console_ns.models[LoadBalancingCredentialValidateResponse.__name__], + ) @setup_required @login_required @account_initialization_required @@ -75,6 +87,11 @@ class LoadBalancingCredentialsValidateApi(Resource): ) class LoadBalancingConfigCredentialsValidateApi(Resource): @console_ns.expect(console_ns.models[LoadBalancingCredentialPayload.__name__]) + @console_ns.response( + 200, + "Credential validation result", + console_ns.models[LoadBalancingCredentialValidateResponse.__name__], + ) @setup_required @login_required @account_initialization_required diff --git a/api/controllers/console/workspace/members.py b/api/controllers/console/workspace/members.py index 6e343f98d7f..6fea5417152 100644 --- a/api/controllers/console/workspace/members.py +++ b/api/controllers/console/workspace/members.py @@ -8,7 +8,7 @@ from sqlalchemy import func, select import services from configs import dify_config -from controllers.common.fields import SimpleResultDataResponse, VerificationTokenResponse +from controllers.common.fields import SimpleResultDataResponse, SimpleResultResponse, VerificationTokenResponse from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.auth.error import ( @@ -29,6 +29,7 @@ from controllers.console.wraps import ( ) 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 libs.login import login_required @@ -61,6 +62,24 @@ class OwnerTransferPayload(BaseModel): token: str +class MemberInviteResultResponse(ResponseModel): + status: str + email: str + url: str | None = None + message: str | None = None + + +class MemberInviteResponse(ResponseModel): + result: str + invitation_results: list[MemberInviteResultResponse] + tenant_id: str + + +class MemberActionTenantResponse(ResponseModel): + result: str + tenant_id: str + + register_enum_models(console_ns, TenantAccountRole) register_schema_models( console_ns, @@ -72,7 +91,14 @@ register_schema_models( OwnerTransferCheckPayload, OwnerTransferPayload, ) -register_response_schema_models(console_ns, SimpleResultDataResponse, VerificationTokenResponse) +register_response_schema_models( + console_ns, + SimpleResultDataResponse, + SimpleResultResponse, + VerificationTokenResponse, + MemberInviteResponse, + MemberActionTenantResponse, +) def _is_role_enabled(role: TenantAccountRole | str, tenant_id: str) -> bool: @@ -152,6 +178,7 @@ class MemberInviteEmailApi(Resource): """Invite a new member by email.""" @console_ns.expect(console_ns.models[MemberInvitePayload.__name__]) + @console_ns.response(201, "Success", console_ns.models[MemberInviteResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -221,6 +248,7 @@ class MemberInviteEmailApi(Resource): 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 @@ -254,6 +282,7 @@ class MemberUpdateRoleApi(Resource): """Update member role.""" @console_ns.expect(console_ns.models[MemberRoleUpdatePayload.__name__]) + @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -396,6 +425,7 @@ class OwnerTransferCheckApi(Resource): @console_ns.route("/workspaces/current/members//owner-transfer") class OwnerTransfer(Resource): @console_ns.expect(console_ns.models[OwnerTransferPayload.__name__]) + @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) @setup_required @login_required @account_initialization_required diff --git a/api/controllers/console/workspace/model_providers.py b/api/controllers/console/workspace/model_providers.py index e77f17b2d0e..07c124890cf 100644 --- a/api/controllers/console/workspace/model_providers.py +++ b/api/controllers/console/workspace/model_providers.py @@ -5,8 +5,8 @@ from flask import request, send_file from flask_restx import Resource from pydantic import BaseModel, Field, field_validator -from controllers.common.fields import SimpleResultResponse -from controllers.common.schema import register_response_schema_models, register_schema_models +from controllers.common.fields import BinaryFileResponse, 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 ( account_initialization_required, @@ -15,6 +15,7 @@ from controllers.console.wraps import ( with_current_tenant_id, with_current_user, ) +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 @@ -22,6 +23,7 @@ from libs.helper import uuid_value from libs.login import login_required from models import Account from services.billing_service import BillingService +from services.entities.model_provider_entities import ProviderResponse from services.model_provider_service import ModelProviderService @@ -82,6 +84,23 @@ class ParserPreferredProviderType(BaseModel): preferred_provider_type: Literal["system", "custom"] +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 ModelProviderPaymentCheckoutUrlResponse(ResponseModel): + payment_link: str + + register_schema_models( console_ns, ParserModelList, @@ -93,12 +112,21 @@ register_schema_models( ParserCredentialValidate, ParserPreferredProviderType, ) -register_response_schema_models(console_ns, SimpleResultResponse) +register_response_schema_models( + console_ns, + BinaryFileResponse, + SimpleResultResponse, + ModelProviderListResponse, + ModelProviderPaymentCheckoutUrlResponse, + ProviderCredentialResponse, + ProviderCredentialValidateResponse, +) @console_ns.route("/workspaces/current/model-providers") class ModelProviderListApi(Resource): - @console_ns.expect(console_ns.models[ParserModelList.__name__]) + @console_ns.doc(params=query_params_from_model(ParserModelList)) + @console_ns.response(200, "Success", console_ns.models[ModelProviderListResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -115,7 +143,8 @@ class ModelProviderListApi(Resource): @console_ns.route("/workspaces/current/model-providers//credentials") class ModelProviderCredentialApi(Resource): - @console_ns.expect(console_ns.models[ParserCredentialId.__name__]) + @console_ns.doc(params=query_params_from_model(ParserCredentialId)) + @console_ns.response(200, "Success", console_ns.models[ProviderCredentialResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -133,6 +162,7 @@ class ModelProviderCredentialApi(Resource): return {"credentials": credentials} @console_ns.expect(console_ns.models[ParserCredentialCreate.__name__]) + @console_ns.response(201, "Credential created successfully", console_ns.models[SimpleResultResponse.__name__]) @setup_required @login_required @is_admin_or_owner_required @@ -157,6 +187,7 @@ class ModelProviderCredentialApi(Resource): return {"result": "success"}, 201 @console_ns.expect(console_ns.models[ParserCredentialUpdate.__name__]) + @console_ns.response(200, "Credential updated successfully", console_ns.models[SimpleResultResponse.__name__]) @setup_required @login_required @is_admin_or_owner_required @@ -225,6 +256,11 @@ class ModelProviderCredentialSwitchApi(Resource): @console_ns.route("/workspaces/current/model-providers//credentials/validate") class ModelProviderValidateApi(Resource): @console_ns.expect(console_ns.models[ParserCredentialValidate.__name__]) + @console_ns.response( + 200, + "Credential validation result", + console_ns.models[ProviderCredentialValidateResponse.__name__], + ) @setup_required @login_required @account_initialization_required @@ -262,6 +298,7 @@ class ModelProviderIconApi(Resource): Get model provider icon """ + @console_ns.response(200, "Success", console_ns.models[BinaryFileResponse.__name__]) def get(self, tenant_id: str, provider: str, icon_type: str, lang: str): model_provider_service = ModelProviderService() icon, mimetype = model_provider_service.get_model_provider_icon( @@ -298,6 +335,7 @@ class PreferredProviderTypeUpdateApi(Resource): @console_ns.route("/workspaces/current/model-providers//checkout-url") class ModelProviderPaymentCheckoutUrlApi(Resource): + @console_ns.response(200, "Success", console_ns.models[ModelProviderPaymentCheckoutUrlResponse.__name__]) @setup_required @login_required @account_initialization_required diff --git a/api/controllers/console/workspace/models.py b/api/controllers/console/workspace/models.py index 19e3fc60bbf..2139d6f18e0 100644 --- a/api/controllers/console/workspace/models.py +++ b/api/controllers/console/workspace/models.py @@ -6,7 +6,12 @@ from flask_restx import Resource from pydantic import BaseModel, Field, field_validator from controllers.common.fields import SimpleResultResponse -from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models +from controllers.common.schema import ( + query_params_from_model, + register_enum_models, + register_response_schema_models, + register_schema_models, +) from controllers.console import console_ns from controllers.console.wraps import ( account_initialization_required, @@ -15,12 +20,19 @@ from controllers.console.wraps import ( with_current_tenant_id, with_current_user, ) -from graphon.model_runtime.entities.model_entities import ModelType +from core.entities.provider_entities import CredentialConfiguration +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 +from services.entities.model_provider_entities import ( + DefaultModelResponse, + ModelWithProviderEntityResponse, + ProviderWithModelsResponse, +) from services.model_load_balancing_service import ModelLoadBalancingService from services.model_provider_service import ModelProviderService @@ -47,7 +59,7 @@ class ParserDeleteModels(BaseModel): class LoadBalancingPayload(BaseModel): - configs: list[dict[str, Any]] | None = None + configs: list[dict[str, Any]] | None = Field(default=None) enabled: bool | None = None @@ -120,6 +132,40 @@ class ParserSwitch(BaseModel): credential_id: str +class DefaultModelDataResponse(ResponseModel): + data: DefaultModelResponse | None = None + + +class ModelWithProviderListResponse(ResponseModel): + data: list[ModelWithProviderEntityResponse] + + +class ProviderWithModelsDataResponse(ResponseModel): + data: list[ProviderWithModelsResponse] + + +class ModelCredentialLoadBalancingResponse(ResponseModel): + enabled: bool + configs: list[dict[str, Any]] = Field(default_factory=list) + + +class ModelCredentialResponse(ResponseModel): + credentials: dict[str, Any] = Field(default_factory=dict) + current_credential_id: str | None = None + current_credential_name: str | None = None + load_balancing: ModelCredentialLoadBalancingResponse + available_credentials: list[CredentialConfiguration] + + +class ModelCredentialValidateResponse(ResponseModel): + result: str + error: str | None = None + + +class ModelParameterRulesResponse(ResponseModel): + data: list[ParameterRule] + + register_schema_models( console_ns, ParserGetDefault, @@ -134,14 +180,24 @@ register_schema_models( Inner, ParserSwitch, ) -register_response_schema_models(console_ns, SimpleResultResponse) +register_response_schema_models( + console_ns, + SimpleResultResponse, + DefaultModelDataResponse, + ModelWithProviderListResponse, + ProviderWithModelsDataResponse, + ModelCredentialResponse, + ModelCredentialValidateResponse, + ModelParameterRulesResponse, +) register_enum_models(console_ns, ModelType) @console_ns.route("/workspaces/current/default-model") class DefaultModelApi(Resource): - @console_ns.expect(console_ns.models[ParserGetDefault.__name__]) + @console_ns.doc(params=query_params_from_model(ParserGetDefault)) + @console_ns.response(200, "Success", console_ns.models[DefaultModelDataResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -191,6 +247,7 @@ class DefaultModelApi(Resource): @console_ns.route("/workspaces/current/model-providers//models") class ModelProviderModelApi(Resource): + @console_ns.response(200, "Success", console_ns.models[ModelWithProviderListResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -202,6 +259,7 @@ class ModelProviderModelApi(Resource): return jsonable_encoder({"data": models}) @console_ns.expect(console_ns.models[ParserPostModels.__name__]) + @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) @setup_required @login_required @is_admin_or_owner_required @@ -267,7 +325,8 @@ class ModelProviderModelApi(Resource): @console_ns.route("/workspaces/current/model-providers//models/credentials") class ModelProviderModelCredentialApi(Resource): - @console_ns.expect(console_ns.models[ParserGetCredentials.__name__]) + @console_ns.doc(params=query_params_from_model(ParserGetCredentials)) + @console_ns.response(200, "Success", console_ns.models[ModelCredentialResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -326,6 +385,7 @@ class ModelProviderModelCredentialApi(Resource): ) @console_ns.expect(console_ns.models[ParserCreateCredential.__name__]) + @console_ns.response(201, "Credential created successfully", console_ns.models[SimpleResultResponse.__name__]) @setup_required @login_required @is_admin_or_owner_required @@ -357,6 +417,7 @@ class ModelProviderModelCredentialApi(Resource): return {"result": "success"}, 201 @console_ns.expect(console_ns.models[ParserUpdateCredential.__name__]) + @console_ns.response(200, "Credential updated successfully", console_ns.models[SimpleResultResponse.__name__]) @setup_required @login_required @is_admin_or_owner_required @@ -481,6 +542,11 @@ register_schema_models(console_ns, ParserSwitch, ParserValidate) @console_ns.route("/workspaces/current/model-providers//models/credentials/validate") class ModelProviderModelValidateApi(Resource): @console_ns.expect(console_ns.models[ParserValidate.__name__]) + @console_ns.response( + 200, + "Credential validation result", + console_ns.models[ModelCredentialValidateResponse.__name__], + ) @setup_required @login_required @account_initialization_required @@ -515,7 +581,8 @@ class ModelProviderModelValidateApi(Resource): @console_ns.route("/workspaces/current/model-providers//models/parameter-rules") class ModelProviderModelParameterRuleApi(Resource): - @console_ns.expect(console_ns.models[ParserParameter.__name__]) + @console_ns.doc(params=query_params_from_model(ParserParameter)) + @console_ns.response(200, "Success", console_ns.models[ModelParameterRulesResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -533,6 +600,7 @@ class ModelProviderModelParameterRuleApi(Resource): @console_ns.route("/workspaces/current/models/model-types/") class ModelProviderAvailableModelApi(Resource): + @console_ns.response(200, "Success", console_ns.models[ProviderWithModelsDataResponse.__name__]) @setup_required @login_required @account_initialization_required diff --git a/api/controllers/console/workspace/plugin.py b/api/controllers/console/workspace/plugin.py index d0538f2de22..94979e25b36 100644 --- a/api/controllers/console/workspace/plugin.py +++ b/api/controllers/console/workspace/plugin.py @@ -1,16 +1,22 @@ import io from collections.abc import Mapping -from typing import Any, Literal +from datetime import datetime +from typing import Any, Literal, TypedDict from flask import request, send_file from flask_restx import Resource -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field, RootModel from werkzeug.datastructures import FileStorage from werkzeug.exceptions import Forbidden from configs import dify_config -from controllers.common.fields import SuccessResponse -from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models +from controllers.common.fields import BinaryFileResponse, SuccessResponse +from controllers.common.schema import ( + query_params_from_model, + register_enum_models, + register_response_schema_models, + register_schema_models, +) from controllers.console import console_ns from controllers.console.workspace import plugin_permission_required from controllers.console.wraps import ( @@ -21,15 +27,33 @@ from controllers.console.wraps import ( with_current_user, with_current_user_id, ) +from core.helper.position_helper import is_filtered +from core.plugin.entities.plugin import PluginCategory, PluginInstallationSource from core.plugin.impl.exc import PluginDaemonClientSideError from core.plugin.plugin_service import PluginService +from core.tools.builtin_tool.providers._positions import BuiltinToolProviderSort +from core.tools.entities.common_entities import I18nObject +from core.tools.entities.tool_entities import ToolProviderType +from core.tools.tool_manager import ToolManager from fields.base import ResponseModel from graphon.model_runtime.utils.encoders import jsonable_encoder +from libs.helper import dump_response from libs.login import login_required from models.account import Account, TenantPluginAutoUpgradeStrategy, TenantPluginPermission +from models.provider_ids import ToolProviderID +from services.entities.model_provider_entities import ProviderEntityResponse from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService from services.plugin.plugin_parameter_service import PluginParameterService from services.plugin.plugin_permission_service import PluginPermissionService +from services.tools.tools_transform_service import ToolTransformService + + +class AutoUpgradeSettingsResponse(TypedDict): + strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting + upgrade_time_of_day: int + upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode + exclude_plugins: list[str] + include_plugins: list[str] class ParserList(BaseModel): @@ -37,6 +61,11 @@ class ParserList(BaseModel): page_size: int = Field(default=256, ge=1, le=256, description="Page size (1-256)") +class PluginCategoryListQuery(BaseModel): + page: int = Field(default=1, ge=1, description="Page number") + page_size: int = Field(default=256, ge=1, le=256, description="Page size (1-256)") + + class ParserLatest(BaseModel): plugin_ids: list[str] @@ -95,8 +124,8 @@ class ParserUninstall(BaseModel): class ParserPermissionChange(BaseModel): - install_permission: TenantPluginPermission.InstallPermission - debug_permission: TenantPluginPermission.DebugPermission + install_permission: TenantPluginPermission.InstallPermission = TenantPluginPermission.InstallPermission.EVERYONE + debug_permission: TenantPluginPermission.DebugPermission = TenantPluginPermission.DebugPermission.EVERYONE class ParserDynamicOptions(BaseModel): @@ -132,13 +161,64 @@ class PluginAutoUpgradeSettingsPayload(BaseModel): include_plugins: list[str] = Field(default_factory=list) -class ParserPreferencesChange(BaseModel): - permission: PluginPermissionSettingsPayload +class PluginAutoUpgradeChangeResponse(ResponseModel): + success: bool + message: str | None = None + + +class PluginAutoUpgradeSettingsResponseModel(ResponseModel): + strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting + upgrade_time_of_day: int + upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode + exclude_plugins: list[str] + include_plugins: list[str] + + +class PluginAutoUpgradeFetchResponse(ResponseModel): + category: TenantPluginAutoUpgradeStrategy.PluginCategory + auto_upgrade: PluginAutoUpgradeSettingsResponseModel + + +class PluginDeclarationResponse(ResponseModel): + version: str + author: str | None + name: str + description: I18nObject + icon: str + icon_dark: str | None = None + label: I18nObject + category: PluginCategory + created_at: datetime + resource: Mapping[str, Any] + plugins: Mapping[str, list[str] | None] + tags: list[str] = Field(default_factory=list) + repo: str | None = None + verified: bool = False + tool: Mapping[str, Any] | None = None + model: ProviderEntityResponse | None = None + endpoint: Mapping[str, Any] | None = None + agent_strategy: Mapping[str, Any] | None = None + datasource: Mapping[str, Any] | None = None + trigger: Mapping[str, Any] | None = None + meta: Mapping[str, Any] + + +class ParserAutoUpgradeChange(BaseModel): + model_config = ConfigDict(extra="forbid") + + category: TenantPluginAutoUpgradeStrategy.PluginCategory auto_upgrade: PluginAutoUpgradeSettingsPayload +class ParserAutoUpgradeFetch(BaseModel): + category: TenantPluginAutoUpgradeStrategy.PluginCategory + + class ParserExcludePlugin(BaseModel): + model_config = ConfigDict(extra="forbid") + plugin_id: str + category: TenantPluginAutoUpgradeStrategy.PluginCategory class ParserReadme(BaseModel): @@ -152,9 +232,114 @@ class PluginDebuggingKeyResponse(ResponseModel): port: int +class PluginCategoryInstalledPluginResponse(ResponseModel): + id: str + name: str + tenant_id: str + plugin_id: str + plugin_unique_identifier: str + endpoints_active: int + endpoints_setups: int + installation_id: str + declaration: PluginDeclarationResponse + runtime_type: str + version: str + created_at: datetime + updated_at: datetime + source: PluginInstallationSource + checksum: str + meta: Mapping[str, Any] + + +class PluginCategoryBuiltinToolResponse(ResponseModel): + model_config = ConfigDict(extra="allow") + + author: str + name: str + label: I18nObject + description: I18nObject + parameters: list[Mapping[str, Any]] | None = None + labels: list[str] + output_schema: Mapping[str, object] + + +class PluginCategoryBuiltinToolProviderResponse(ResponseModel): + model_config = ConfigDict(extra="allow") + + id: str + author: str + name: str + plugin_id: str | None + plugin_unique_identifier: str | None + description: I18nObject + icon: str | Mapping[str, str] + icon_dark: str | Mapping[str, str] | None + label: I18nObject + type: ToolProviderType + team_credentials: Mapping[str, object] + is_team_authorization: bool + allow_delete: bool + tools: list[PluginCategoryBuiltinToolResponse] + labels: list[str] + + +class PluginCategoryListResponse(ResponseModel): + plugins: list[PluginCategoryInstalledPluginResponse] + builtin_tools: list[PluginCategoryBuiltinToolProviderResponse] + has_more: bool + + +class PluginDaemonOperationResponse(RootModel[Any]): + root: Any + + +class PluginListResponse(ResponseModel): + plugins: Any + total: int + + +class PluginVersionsResponse(ResponseModel): + versions: Any + + +class PluginInstallationsResponse(ResponseModel): + plugins: Any + + +class PluginManifestResponse(ResponseModel): + manifest: Any + + +class PluginTasksResponse(ResponseModel): + tasks: Any + + +class PluginTaskResponse(ResponseModel): + task: Any + + +class PluginPermissionResponse(ResponseModel): + install_permission: TenantPluginPermission.InstallPermission + debug_permission: TenantPluginPermission.DebugPermission + + +class PluginDynamicOptionsResponse(ResponseModel): + options: Any + + +class PluginOperationSuccessResponse(ResponseModel): + success: bool + message: str | None = None + + +class PluginReadmeResponse(ResponseModel): + readme: str + + register_schema_models( console_ns, ParserList, + PluginCategoryListQuery, PluginAutoUpgradeSettingsPayload, PluginPermissionSettingsPayload, ParserLatest, @@ -171,21 +356,69 @@ register_schema_models( ParserPermissionChange, ParserDynamicOptions, ParserDynamicOptionsWithCredentials, - ParserPreferencesChange, + ParserAutoUpgradeChange, + ParserAutoUpgradeFetch, ParserExcludePlugin, ParserReadme, ) -register_response_schema_models(console_ns, PluginDebuggingKeyResponse, SuccessResponse) +register_response_schema_models( + console_ns, + PluginAutoUpgradeChangeResponse, + PluginAutoUpgradeFetchResponse, + PluginAutoUpgradeSettingsResponseModel, + BinaryFileResponse, + PluginCategoryBuiltinToolProviderResponse, + PluginCategoryBuiltinToolResponse, + PluginCategoryInstalledPluginResponse, + PluginCategoryListResponse, + PluginDaemonOperationResponse, + PluginDebuggingKeyResponse, + PluginDynamicOptionsResponse, + PluginInstallationsResponse, + PluginListResponse, + PluginManifestResponse, + PluginOperationSuccessResponse, + PluginPermissionResponse, + PluginReadmeResponse, + PluginTaskResponse, + PluginTasksResponse, + PluginVersionsResponse, + SuccessResponse, +) register_enum_models( console_ns, TenantPluginPermission.DebugPermission, + TenantPluginAutoUpgradeStrategy.PluginCategory, TenantPluginAutoUpgradeStrategy.UpgradeMode, TenantPluginAutoUpgradeStrategy.StrategySetting, TenantPluginPermission.InstallPermission, ) +def _default_auto_upgrade_settings( + tenant_id: str, + category: TenantPluginAutoUpgradeStrategy.PluginCategory, +) -> AutoUpgradeSettingsResponse: + return { + "strategy_setting": PluginAutoUpgradeService.default_strategy_setting_for_category(category), + "upgrade_time_of_day": PluginAutoUpgradeService.default_upgrade_time_of_day(tenant_id), + "upgrade_mode": TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE, + "exclude_plugins": [], + "include_plugins": [], + } + + +def _auto_upgrade_settings_to_dict(strategy: TenantPluginAutoUpgradeStrategy) -> AutoUpgradeSettingsResponse: + return { + "strategy_setting": strategy.strategy_setting, + "upgrade_time_of_day": strategy.upgrade_time_of_day, + "upgrade_mode": strategy.upgrade_mode, + "exclude_plugins": strategy.exclude_plugins, + "include_plugins": strategy.include_plugins, + } + + def _read_upload_content(file: FileStorage, max_size: int) -> bytes: """ Read the uploaded file and validate its actual size before delegating to the plugin service. @@ -200,6 +433,33 @@ def _read_upload_content(file: FileStorage, max_size: int) -> bytes: return content +def _list_hardcoded_builtin_tool_providers(tenant_id: str) -> list[dict[str, Any]]: + db_builtin_providers = { + str(ToolProviderID(provider.provider)): provider + for provider in ToolManager.list_default_builtin_providers(tenant_id) + } + builtin_providers = [] + + for provider in ToolManager.list_hardcoded_providers(): + if is_filtered( + include_set=dify_config.POSITION_TOOL_INCLUDES_SET, + exclude_set=dify_config.POSITION_TOOL_EXCLUDES_SET, + data=provider, + name_func=lambda provider_controller: provider_controller.entity.identity.name, + ): + continue + + user_provider = ToolTransformService.builtin_provider_to_user_provider( + provider_controller=provider, + db_provider=db_builtin_providers.get(provider.entity.identity.name), + decrypt_credentials=False, + ) + ToolTransformService.repack_provider(tenant_id=tenant_id, provider=user_provider) + builtin_providers.append(user_provider) + + return [provider.to_dict() for provider in BuiltinToolProviderSort.sort(builtin_providers)] + + @console_ns.route("/workspaces/current/plugin/debugging-key") class PluginDebuggingKeyApi(Resource): @console_ns.response(200, "Success", console_ns.models[PluginDebuggingKeyResponse.__name__]) @@ -221,7 +481,8 @@ class PluginDebuggingKeyApi(Resource): @console_ns.route("/workspaces/current/plugin/list") class PluginListApi(Resource): - @console_ns.expect(console_ns.models[ParserList.__name__]) + @console_ns.doc(params=query_params_from_model(ParserList)) + @console_ns.response(200, "Success", console_ns.models[PluginListResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -237,9 +498,45 @@ class PluginListApi(Resource): return jsonable_encoder({"plugins": plugins_with_total.list, "total": plugins_with_total.total}) +@console_ns.route("/workspaces/current/plugin//list") +class PluginCategoryListApi(Resource): + @console_ns.doc(params=query_params_from_model(PluginCategoryListQuery)) + @console_ns.response(200, "Success", console_ns.models[PluginCategoryListResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_tenant_id + def get(self, tenant_id: str, category: str): + args = PluginCategoryListQuery.model_validate(request.args.to_dict(flat=True)) + + try: + plugin_category = PluginCategory(category) + except ValueError: + return {"code": "invalid_param", "message": "invalid plugin category"}, 400 + + try: + plugins = PluginService.list_by_category(tenant_id, plugin_category, args.page, args.page_size) + except PluginDaemonClientSideError as e: + return {"code": "plugin_error", "message": e.description}, 400 + + builtin_tools = [] + if plugin_category == PluginCategory.Tool: + builtin_tools = _list_hardcoded_builtin_tool_providers(tenant_id) + + return dump_response( + PluginCategoryListResponse, + { + "plugins": jsonable_encoder(plugins.list), + "builtin_tools": builtin_tools, + "has_more": plugins.has_more, + }, + ) + + @console_ns.route("/workspaces/current/plugin/list/latest-versions") class PluginListLatestVersionsApi(Resource): @console_ns.expect(console_ns.models[ParserLatest.__name__]) + @console_ns.response(200, "Success", console_ns.models[PluginVersionsResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -257,6 +554,7 @@ class PluginListLatestVersionsApi(Resource): @console_ns.route("/workspaces/current/plugin/list/installations/ids") class PluginListInstallationsFromIdsApi(Resource): @console_ns.expect(console_ns.models[ParserLatest.__name__]) + @console_ns.response(200, "Success", console_ns.models[PluginInstallationsResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -274,7 +572,8 @@ class PluginListInstallationsFromIdsApi(Resource): @console_ns.route("/workspaces/current/plugin/icon") class PluginIconApi(Resource): - @console_ns.expect(console_ns.models[ParserIcon.__name__]) + @console_ns.doc(params=query_params_from_model(ParserIcon)) + @console_ns.response(200, "Success", console_ns.models[BinaryFileResponse.__name__]) @setup_required def get(self): args = ParserIcon.model_validate(request.args.to_dict(flat=True)) @@ -290,7 +589,8 @@ class PluginIconApi(Resource): @console_ns.route("/workspaces/current/plugin/asset") class PluginAssetApi(Resource): - @console_ns.expect(console_ns.models[ParserAsset.__name__]) + @console_ns.doc(params=query_params_from_model(ParserAsset)) + @console_ns.response(200, "Success", console_ns.models[BinaryFileResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -307,6 +607,7 @@ class PluginAssetApi(Resource): @console_ns.route("/workspaces/current/plugin/upload/pkg") class PluginUploadFromPkgApi(Resource): + @console_ns.response(200, "Success", console_ns.models[PluginDaemonOperationResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -326,6 +627,7 @@ class PluginUploadFromPkgApi(Resource): @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__]) @setup_required @login_required @account_initialization_required @@ -344,6 +646,7 @@ class PluginUploadFromGithubApi(Resource): @console_ns.route("/workspaces/current/plugin/upload/bundle") class PluginUploadFromBundleApi(Resource): + @console_ns.response(200, "Success", console_ns.models[PluginDaemonOperationResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -363,6 +666,7 @@ class PluginUploadFromBundleApi(Resource): @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__]) @setup_required @login_required @account_initialization_required @@ -382,6 +686,7 @@ class PluginInstallFromPkgApi(Resource): @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__]) @setup_required @login_required @account_initialization_required @@ -407,6 +712,7 @@ class PluginInstallFromGithubApi(Resource): @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__]) @setup_required @login_required @account_initialization_required @@ -425,7 +731,8 @@ class PluginInstallFromMarketplaceApi(Resource): @console_ns.route("/workspaces/current/plugin/marketplace/pkg") class PluginFetchMarketplacePkgApi(Resource): - @console_ns.expect(console_ns.models[ParserPluginIdentifierQuery.__name__]) + @console_ns.doc(params=query_params_from_model(ParserPluginIdentifierQuery)) + @console_ns.response(200, "Success", console_ns.models[PluginManifestResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -449,7 +756,8 @@ class PluginFetchMarketplacePkgApi(Resource): @console_ns.route("/workspaces/current/plugin/fetch-manifest") class PluginFetchManifestApi(Resource): - @console_ns.expect(console_ns.models[ParserPluginIdentifierQuery.__name__]) + @console_ns.doc(params=query_params_from_model(ParserPluginIdentifierQuery)) + @console_ns.response(200, "Success", console_ns.models[PluginManifestResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -468,7 +776,8 @@ class PluginFetchManifestApi(Resource): @console_ns.route("/workspaces/current/plugin/tasks") class PluginFetchInstallTasksApi(Resource): - @console_ns.expect(console_ns.models[ParserTasks.__name__]) + @console_ns.doc(params=query_params_from_model(ParserTasks)) + @console_ns.response(200, "Success", console_ns.models[PluginTasksResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -485,6 +794,7 @@ class PluginFetchInstallTasksApi(Resource): @console_ns.route("/workspaces/current/plugin/tasks/") class PluginFetchInstallTaskApi(Resource): + @console_ns.response(200, "Success", console_ns.models[PluginTaskResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -545,6 +855,7 @@ class PluginDeleteInstallTaskItemApi(Resource): @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__]) @setup_required @login_required @account_initialization_required @@ -566,6 +877,7 @@ class PluginUpgradeFromMarketplaceApi(Resource): @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__]) @setup_required @login_required @account_initialization_required @@ -622,15 +934,18 @@ class PluginChangePermissionApi(Resource): args = ParserPermissionChange.model_validate(console_ns.payload) - return { - "success": PluginPermissionService.change_permission( - tenant_id, args.install_permission, args.debug_permission - ) - } + set_permission_result = PluginPermissionService.change_permission( + tenant_id, args.install_permission, args.debug_permission + ) + if not set_permission_result: + return jsonable_encoder({"success": False, "message": "Failed to set permission"}) + + return jsonable_encoder({"success": True}) @console_ns.route("/workspaces/current/plugin/permission/fetch") class PluginFetchPermissionApi(Resource): + @console_ns.response(200, "Success", console_ns.models[PluginPermissionResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -655,7 +970,8 @@ class PluginFetchPermissionApi(Resource): @console_ns.route("/workspaces/current/plugin/parameters/dynamic-options") class PluginFetchDynamicSelectOptionsApi(Resource): - @console_ns.expect(console_ns.models[ParserDynamicOptions.__name__]) + @console_ns.doc(params=query_params_from_model(ParserDynamicOptions)) + @console_ns.response(200, "Success", console_ns.models[PluginDynamicOptionsResponse.__name__]) @setup_required @login_required @is_admin_or_owner_required @@ -685,6 +1001,7 @@ class PluginFetchDynamicSelectOptionsApi(Resource): @console_ns.route("/workspaces/current/plugin/parameters/dynamic-options-with-credentials") class PluginFetchDynamicSelectOptionsWithCredentialsApi(Resource): @console_ns.expect(console_ns.models[ParserDynamicOptionsWithCredentials.__name__]) + @console_ns.response(200, "Success", console_ns.models[PluginDynamicOptionsResponse.__name__]) @setup_required @login_required @is_admin_or_owner_required @@ -712,9 +1029,10 @@ class PluginFetchDynamicSelectOptionsWithCredentialsApi(Resource): return jsonable_encoder({"options": options}) -@console_ns.route("/workspaces/current/plugin/preferences/change") -class PluginChangePreferencesApi(Resource): - @console_ns.expect(console_ns.models[ParserPreferencesChange.__name__]) +@console_ns.route("/workspaces/current/plugin/auto-upgrade/change") +class PluginChangeAutoUpgradeApi(Resource): + @console_ns.expect(console_ns.models[ParserAutoUpgradeChange.__name__]) + @console_ns.response(200, "Success", console_ns.models[PluginAutoUpgradeChangeResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -724,38 +1042,17 @@ class PluginChangePreferencesApi(Resource): if not user.is_admin_or_owner: raise Forbidden() - args = ParserPreferencesChange.model_validate(console_ns.payload) - - permission = args.permission - - install_permission = permission.install_permission - debug_permission = permission.debug_permission + args = ParserAutoUpgradeChange.model_validate(console_ns.payload) auto_upgrade = args.auto_upgrade - - strategy_setting = auto_upgrade.strategy_setting - upgrade_time_of_day = auto_upgrade.upgrade_time_of_day - upgrade_mode = auto_upgrade.upgrade_mode - exclude_plugins = auto_upgrade.exclude_plugins - include_plugins = auto_upgrade.include_plugins - - # set permission - set_permission_result = PluginPermissionService.change_permission( - tenant_id, - install_permission, - debug_permission, - ) - if not set_permission_result: - return jsonable_encoder({"success": False, "message": "Failed to set permission"}) - - # set auto upgrade strategy set_auto_upgrade_strategy_result = PluginAutoUpgradeService.change_strategy( tenant_id, - strategy_setting, - upgrade_time_of_day, - upgrade_mode, - exclude_plugins, - include_plugins, + auto_upgrade.strategy_setting, + auto_upgrade.upgrade_time_of_day, + auto_upgrade.upgrade_mode, + auto_upgrade.exclude_plugins, + auto_upgrade.include_plugins, + category=args.category, ) if not set_auto_upgrade_strategy_result: return jsonable_encoder({"success": False, "message": "Failed to set auto upgrade strategy"}) @@ -763,47 +1060,35 @@ class PluginChangePreferencesApi(Resource): return jsonable_encoder({"success": True}) -@console_ns.route("/workspaces/current/plugin/preferences/fetch") -class PluginFetchPreferencesApi(Resource): +@console_ns.route("/workspaces/current/plugin/auto-upgrade/fetch") +class PluginFetchAutoUpgradeApi(Resource): + @console_ns.doc(params=query_params_from_model(ParserAutoUpgradeFetch)) + @console_ns.response(200, "Success", console_ns.models[PluginAutoUpgradeFetchResponse.__name__]) @setup_required @login_required @account_initialization_required @with_current_tenant_id def get(self, tenant_id: str): - permission = PluginPermissionService.get_permission(tenant_id) - permission_dict = { - "install_permission": TenantPluginPermission.InstallPermission.EVERYONE, - "debug_permission": TenantPluginPermission.DebugPermission.EVERYONE, - } + args = ParserAutoUpgradeFetch.model_validate(request.args.to_dict(flat=True)) + auto_upgrade = PluginAutoUpgradeService.get_strategy(tenant_id, args.category) + auto_upgrade_dict = ( + _auto_upgrade_settings_to_dict(auto_upgrade) + if auto_upgrade + else _default_auto_upgrade_settings(tenant_id, args.category) + ) - if permission: - permission_dict["install_permission"] = permission.install_permission - permission_dict["debug_permission"] = permission.debug_permission - - auto_upgrade = PluginAutoUpgradeService.get_strategy(tenant_id) - auto_upgrade_dict = { - "strategy_setting": TenantPluginAutoUpgradeStrategy.StrategySetting.DISABLED, - "upgrade_time_of_day": 0, - "upgrade_mode": TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE, - "exclude_plugins": [], - "include_plugins": [], - } - - if auto_upgrade: - auto_upgrade_dict = { - "strategy_setting": auto_upgrade.strategy_setting, - "upgrade_time_of_day": auto_upgrade.upgrade_time_of_day, - "upgrade_mode": auto_upgrade.upgrade_mode, - "exclude_plugins": auto_upgrade.exclude_plugins, - "include_plugins": auto_upgrade.include_plugins, + return jsonable_encoder( + { + "category": args.category, + "auto_upgrade": auto_upgrade_dict, } - - return jsonable_encoder({"permission": permission_dict, "auto_upgrade": auto_upgrade_dict}) + ) -@console_ns.route("/workspaces/current/plugin/preferences/autoupgrade/exclude") +@console_ns.route("/workspaces/current/plugin/auto-upgrade/exclude") class PluginAutoUpgradeExcludePluginApi(Resource): @console_ns.expect(console_ns.models[ParserExcludePlugin.__name__]) + @console_ns.response(200, "Success", console_ns.models[SuccessResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -812,12 +1097,15 @@ class PluginAutoUpgradeExcludePluginApi(Resource): # exclude one single plugin args = ParserExcludePlugin.model_validate(console_ns.payload) - return jsonable_encoder({"success": PluginAutoUpgradeService.exclude_plugin(tenant_id, args.plugin_id)}) + return jsonable_encoder( + {"success": PluginAutoUpgradeService.exclude_plugin(tenant_id, args.plugin_id, args.category)} + ) @console_ns.route("/workspaces/current/plugin/readme") class PluginReadmeApi(Resource): - @console_ns.expect(console_ns.models[ParserReadme.__name__]) + @console_ns.doc(params=query_params_from_model(ParserReadme)) + @console_ns.response(200, "Success", console_ns.models[PluginReadmeResponse.__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 4bec22e091d..4bd493d25e9 100644 --- a/api/controllers/console/workspace/snippets.py +++ b/api/controllers/console/workspace/snippets.py @@ -1,14 +1,17 @@ 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 sqlalchemy.orm import Session, sessionmaker from werkzeug.datastructures import MultiDict from werkzeug.exceptions import NotFound -from controllers.common.schema import register_schema_models +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 ( CreateSnippetPayload, @@ -25,6 +28,7 @@ from controllers.console.wraps import ( with_current_user, ) 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 libs.login import login_required from models import Account @@ -38,6 +42,19 @@ _TAG_IDS_BRACKET_PATTERN = re.compile(r"^tag_ids\[(\d+)\]$") _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): + result: str + use_count: int + + def _snippet_service() -> SnippetService: return SnippetService(sessionmaker(bind=db.engine, expire_on_commit=False)) @@ -79,6 +96,13 @@ register_schema_models( SnippetImportPayload, IncludeSecretQuery, ) +register_response_schema_models( + console_ns, + TextFileResponse, + SnippetImportResponse, + SnippetDependencyCheckResponse, + SnippetUseCountResponse, +) # Create namespace models for marshaling snippet_model = console_ns.model("Snippet", snippet_fields) @@ -89,7 +113,7 @@ snippet_pagination_model = console_ns.model("SnippetPagination", snippet_paginat @console_ns.route("/workspaces/current/customized-snippets") class CustomizedSnippetsApi(Resource): @console_ns.doc("list_customized_snippets") - @console_ns.expect(console_ns.models.get(SnippetListQuery.__name__)) + @console_ns.doc(params=query_params_from_model(SnippetListQuery)) @console_ns.response(200, "Snippets retrieved successfully", snippet_pagination_model) @setup_required @login_required @@ -260,7 +284,8 @@ class CustomizedSnippetExportApi(Resource): @console_ns.doc("export_customized_snippet") @console_ns.doc(description="Export snippet configuration as DSL") @console_ns.doc(params={"snippet_id": "Snippet ID to export"}) - @console_ns.response(200, "Snippet exported successfully") + @console_ns.doc(params=query_params_from_model(IncludeSecretQuery)) + @console_ns.response(200, "Snippet exported successfully", console_ns.models[TextFileResponse.__name__]) @console_ns.response(404, "Snippet not found") @setup_required @login_required @@ -304,8 +329,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.response(202, "Import pending confirmation") + @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(400, "Import failed") @setup_required @login_required @@ -343,7 +368,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.response(200, "Import confirmed successfully", console_ns.models[SnippetImportResponse.__name__]) @console_ns.response(400, "Import failed") @setup_required @login_required @@ -367,7 +392,11 @@ 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.response( + 200, + "Dependencies checked successfully", + console_ns.models[SnippetDependencyCheckResponse.__name__], + ) @console_ns.response(404, "Snippet not found") @setup_required @login_required @@ -397,7 +426,7 @@ 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.response(200, "Use count incremented successfully", console_ns.models[SnippetUseCountResponse.__name__]) @console_ns.response(404, "Snippet not found") @setup_required @login_required diff --git a/api/controllers/console/workspace/tool_providers.py b/api/controllers/console/workspace/tool_providers.py index 4f1fd6be0ae..94600f38860 100644 --- a/api/controllers/console/workspace/tool_providers.py +++ b/api/controllers/console/workspace/tool_providers.py @@ -5,13 +5,13 @@ 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, 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 SimpleResultResponse -from controllers.common.schema import register_response_schema_models, register_schema_models +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.console import console_ns from controllers.console.wraps import ( account_initialization_required, @@ -26,7 +26,7 @@ from core.entities.mcp_provider import IdentityMode, MCPAuthentication, MCPConfi 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 +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 extensions.ext_database import db @@ -77,7 +77,7 @@ class BuiltinToolAddPayload(BaseModel): class BuiltinToolUpdatePayload(BaseModel): credential_id: str - credentials: dict[str, Any] | None = None + credentials: dict[str, Any] | None = Field(default=None) name: str | None = Field(default=None, max_length=30) @@ -108,6 +108,13 @@ class ProviderQuery(BaseModel): provider: str +class BuiltinCredentialListQuery(BaseModel): + include_credential_ids: list[str] = Field( + default_factory=list, + description="Credential IDs to include even if visibility would hide them", + ) + + class ApiToolProviderDeletePayload(BaseModel): provider: str @@ -199,7 +206,7 @@ class BuiltinProviderDefaultCredentialPayload(BaseModel): class ToolOAuthCustomClientPayload(BaseModel): - client_params: dict[str, Any] | None = None + client_params: dict[str, Any] | None = Field(default=None) enable_oauth_custom_client: bool | None = True @@ -261,8 +268,27 @@ class MCPCallbackQuery(BaseModel): state: str +class ToolOAuthCustomClientResponse(RootModel[dict[str, Any]]): + root: dict[str, Any] + + +class ToolOAuthClientSchemaResponse(RootModel[list[dict[str, Any]]]): + root: list[dict[str, Any]] + + +class ToolProviderOpaqueResponse(RootModel[Any]): + root: Any + + register_schema_models( console_ns, + ToolProviderListQuery, + UrlQuery, + ProviderQuery, + BuiltinCredentialListQuery, + WorkflowToolGetQuery, + WorkflowToolListQuery, + MCPCallbackQuery, BuiltinToolCredentialDeletePayload, BuiltinToolAddPayload, BuiltinToolUpdatePayload, @@ -281,11 +307,22 @@ register_schema_models( MCPProviderDeletePayload, MCPAuthPayload, ) -register_response_schema_models(console_ns, SimpleResultResponse) +register_response_schema_models( + console_ns, + BinaryFileResponse, + PluginOAuthAuthorizationUrlResponse, + RedirectResponse, + SimpleResultResponse, + ToolOAuthClientSchemaResponse, + ToolOAuthCustomClientResponse, + ToolProviderOpaqueResponse, +) @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__]) @setup_required @login_required @account_initialization_required @@ -300,6 +337,7 @@ class ToolProviderListApi(Resource): @console_ns.route("/workspaces/current/tool-provider/builtin//tools") class ToolBuiltinProviderListToolsApi(Resource): + @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -315,6 +353,7 @@ class ToolBuiltinProviderListToolsApi(Resource): @console_ns.route("/workspaces/current/tool-provider/builtin//info") class ToolBuiltinProviderInfoApi(Resource): + @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -326,6 +365,7 @@ class ToolBuiltinProviderInfoApi(Resource): @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__]) @setup_required @login_required @is_admin_or_owner_required @@ -344,6 +384,7 @@ class ToolBuiltinProviderDeleteApi(Resource): @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__]) @setup_required @login_required @account_initialization_required @@ -366,6 +407,7 @@ class ToolBuiltinProviderAddApi(Resource): @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__]) @setup_required @login_required @is_admin_or_owner_required @@ -388,6 +430,8 @@ class ToolBuiltinProviderUpdateApi(Resource): @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__]) @setup_required @login_required @account_initialization_required @@ -412,6 +456,7 @@ class ToolBuiltinProviderGetCredentialsApi(Resource): @console_ns.route("/workspaces/current/tool-provider/builtin//icon") class ToolBuiltinProviderIconApi(Resource): + @console_ns.response(200, "Success", console_ns.models[BinaryFileResponse.__name__]) @setup_required def get(self, provider: str): icon_bytes, mimetype = BuiltinToolManageService.get_builtin_tool_provider_icon(provider) @@ -422,6 +467,7 @@ class ToolBuiltinProviderIconApi(Resource): @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__]) @setup_required @login_required @is_admin_or_owner_required @@ -447,6 +493,8 @@ class ToolApiProviderAddApi(Resource): @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__]) @setup_required @login_required @account_initialization_required @@ -465,6 +513,8 @@ class ToolApiProviderGetRemoteSchemaApi(Resource): @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__]) @setup_required @login_required @account_initialization_required @@ -486,6 +536,7 @@ class ToolApiProviderListToolsApi(Resource): @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__]) @setup_required @login_required @is_admin_or_owner_required @@ -513,6 +564,7 @@ class ToolApiProviderUpdateApi(Resource): @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__]) @setup_required @login_required @is_admin_or_owner_required @@ -531,6 +583,8 @@ class ToolApiProviderDeleteApi(Resource): @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__]) @setup_required @login_required @account_initialization_required @@ -549,6 +603,7 @@ class ToolApiProviderGetApi(Resource): @console_ns.route("/workspaces/current/tool-provider/builtin//credential/schema/") class ToolBuiltinProviderCredentialsSchemaApi(Resource): + @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -564,6 +619,7 @@ class ToolBuiltinProviderCredentialsSchemaApi(Resource): @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__]) @setup_required @login_required @account_initialization_required @@ -578,6 +634,7 @@ class ToolApiProviderSchemaApi(Resource): @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__]) @setup_required @login_required @account_initialization_required @@ -598,6 +655,7 @@ class ToolApiProviderPreviousTestApi(Resource): @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__]) @setup_required @login_required @is_admin_or_owner_required @@ -624,6 +682,7 @@ class ToolWorkflowProviderCreateApi(Resource): @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__]) @setup_required @login_required @is_admin_or_owner_required @@ -650,6 +709,7 @@ class ToolWorkflowProviderUpdateApi(Resource): @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__]) @setup_required @login_required @is_admin_or_owner_required @@ -668,6 +728,8 @@ class ToolWorkflowProviderDeleteApi(Resource): @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__]) @setup_required @login_required @account_initialization_required @@ -697,6 +759,8 @@ class ToolWorkflowProviderGetApi(Resource): @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__]) @setup_required @login_required @account_initialization_required @@ -717,6 +781,7 @@ class ToolWorkflowProviderListToolApi(Resource): @console_ns.route("/workspaces/current/tools/builtin") class ToolBuiltinListApi(Resource): + @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -736,6 +801,7 @@ class ToolBuiltinListApi(Resource): @console_ns.route("/workspaces/current/tools/api") class ToolApiListApi(Resource): + @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -753,6 +819,7 @@ class ToolApiListApi(Resource): @console_ns.route("/workspaces/current/tools/workflow") class ToolWorkflowListApi(Resource): + @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -772,6 +839,7 @@ class ToolWorkflowListApi(Resource): @console_ns.route("/workspaces/current/tool-labels") class ToolLabelsApi(Resource): + @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -782,6 +850,11 @@ class ToolLabelsApi(Resource): @console_ns.route("/oauth/plugin//tool/authorization-url") class ToolPluginOAuthApi(Resource): + @console_ns.response( + 200, + "Authorization URL retrieved successfully", + console_ns.models[PluginOAuthAuthorizationUrlResponse.__name__], + ) @setup_required @login_required @is_admin_or_owner_required @@ -823,6 +896,11 @@ class ToolPluginOAuthApi(Resource): @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__], + ) @setup_required def get(self, provider: str): context_id = request.cookies.get("context_id") @@ -877,6 +955,7 @@ class ToolOAuthCallback(Resource): @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__]) @setup_required @login_required @is_admin_or_owner_required @@ -892,6 +971,7 @@ class ToolBuiltinProviderSetDefaultApi(Resource): @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__]) @setup_required @login_required @is_admin_or_owner_required @@ -912,6 +992,7 @@ class ToolOAuthCustomClient(Resource): @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( @@ -921,6 +1002,7 @@ class ToolOAuthCustomClient(Resource): @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( @@ -930,6 +1012,7 @@ class ToolOAuthCustomClient(Resource): @console_ns.route("/workspaces/current/tool-provider/builtin//oauth/client-schema") class ToolBuiltinProviderGetOauthClientSchemaApi(Resource): + @console_ns.response(200, "Success", console_ns.models[ToolOAuthClientSchemaResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -944,6 +1027,8 @@ class ToolBuiltinProviderGetOauthClientSchemaApi(Resource): @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__]) @setup_required @login_required @account_initialization_required @@ -967,6 +1052,7 @@ class ToolBuiltinProviderGetCredentialInfoApi(Resource): @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__]) @setup_required @login_required @account_initialization_required @@ -1021,6 +1107,7 @@ class ToolProviderMCPApi(Resource): return jsonable_encoder(result) @console_ns.expect(console_ns.models[MCPProviderUpdatePayload.__name__]) + @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -1091,6 +1178,7 @@ class ToolProviderMCPApi(Resource): @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__]) @setup_required @login_required @account_initialization_required @@ -1164,6 +1252,7 @@ class ToolMCPAuthApi(Resource): @console_ns.route("/workspaces/current/tool-provider/mcp/tools/") class ToolMCPDetailApi(Resource): + @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -1177,6 +1266,7 @@ class ToolMCPDetailApi(Resource): @console_ns.route("/workspaces/current/tools/mcp") class ToolMCPListAllApi(Resource): + @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -1192,6 +1282,7 @@ class ToolMCPListAllApi(Resource): @console_ns.route("/workspaces/current/tool-provider/mcp/update/") class ToolMCPUpdateApi(Resource): + @console_ns.response(200, "Success", console_ns.models[ToolProviderOpaqueResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -1208,6 +1299,12 @@ class ToolMCPUpdateApi(Resource): @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__], + ) def get(self): raw_args = request.args.to_dict() query = MCPCallbackQuery.model_validate(raw_args) diff --git a/api/controllers/console/workspace/trigger_providers.py b/api/controllers/console/workspace/trigger_providers.py index 3805d0ff372..ff1a5c18bd9 100644 --- a/api/controllers/console/workspace/trigger_providers.py +++ b/api/controllers/console/workspace/trigger_providers.py @@ -3,13 +3,13 @@ from typing import Any from flask import make_response, redirect, request from flask_restx import Resource -from pydantic import BaseModel, model_validator +from pydantic import BaseModel, Field, RootModel, model_validator from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import BadRequest, Forbidden from configs import dify_config from controllers.common.errors import NotFoundError -from controllers.common.fields import SimpleResultResponse +from controllers.common.fields import BinaryFileResponse, RedirectResponse, SimpleResultResponse from controllers.common.schema import register_response_schema_models, register_schema_models from core.plugin.entities.plugin_daemon import CredentialType from core.plugin.impl.oauth import OAuthHandler @@ -17,7 +17,7 @@ from core.trigger.entities.entities import SubscriptionBuilderUpdater from core.trigger.trigger_manager import TriggerManager from extensions.ext_database import db from graphon.model_runtime.utils.encoders import jsonable_encoder -from libs.login import current_user, login_required +from libs.login import login_required from models.account import Account from models.provider_ids import TriggerProviderID from services.plugin.oauth_service import OAuthProxyService @@ -31,6 +31,8 @@ from ..wraps import ( edit_permission_required, is_admin_or_owner_required, setup_required, + with_current_tenant_id, + with_current_user, ) logger = logging.getLogger(__name__) @@ -46,9 +48,9 @@ class TriggerSubscriptionBuilderVerifyPayload(BaseModel): class TriggerSubscriptionBuilderUpdatePayload(BaseModel): name: str | None = None - parameters: dict[str, Any] | None = None - properties: dict[str, Any] | None = None - credentials: dict[str, Any] | 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) @model_validator(mode="after") def check_at_least_one_field(self): @@ -58,10 +60,30 @@ class TriggerSubscriptionBuilderUpdatePayload(BaseModel): class TriggerOAuthClientPayload(BaseModel): - client_params: dict[str, Any] | None = None + client_params: dict[str, Any] | None = Field(default=None) enabled: bool | None = None +class TriggerOAuthAuthorizeResponse(BaseModel): + authorization_url: str + subscription_builder_id: str + subscription_builder: Any + + +class TriggerOAuthClientResponse(BaseModel): + configured: bool + system_configured: bool + custom_configured: bool + oauth_client_schema: Any + custom_enabled: bool + redirect_uri: str + params: dict[str, Any] + + +class TriggerProviderOpaqueResponse(RootModel[Any]): + root: Any + + register_schema_models( console_ns, TriggerSubscriptionBuilderCreatePayload, @@ -69,66 +91,67 @@ register_schema_models( TriggerSubscriptionBuilderUpdatePayload, TriggerOAuthClientPayload, ) -register_response_schema_models(console_ns, SimpleResultResponse) +register_response_schema_models( + console_ns, + BinaryFileResponse, + RedirectResponse, + SimpleResultResponse, + TriggerOAuthAuthorizeResponse, + TriggerOAuthClientResponse, + TriggerProviderOpaqueResponse, +) @console_ns.route("/workspaces/current/trigger-provider//icon") class TriggerProviderIconApi(Resource): + @console_ns.response(200, "Success", console_ns.models[BinaryFileResponse.__name__]) @setup_required @login_required @account_initialization_required - def get(self, provider: str): - user = current_user - assert isinstance(user, Account) - assert user.current_tenant_id is not None - - return TriggerManager.get_trigger_plugin_icon(tenant_id=user.current_tenant_id, provider_id=provider) + @with_current_tenant_id + def get(self, tenant_id: str, provider: str): + return TriggerManager.get_trigger_plugin_icon(tenant_id=tenant_id, provider_id=provider) @console_ns.route("/workspaces/current/triggers") class TriggerProviderListApi(Resource): + @console_ns.response(200, "Success", console_ns.models[TriggerProviderOpaqueResponse.__name__]) @setup_required @login_required @account_initialization_required - def get(self): + @with_current_tenant_id + def get(self, tenant_id: str): """List all trigger providers for the current tenant""" - user = current_user - assert isinstance(user, Account) - assert user.current_tenant_id is not None - return jsonable_encoder(TriggerProviderService.list_trigger_providers(user.current_tenant_id)) + return jsonable_encoder(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__]) @setup_required @login_required @account_initialization_required - def get(self, provider: str): + @with_current_tenant_id + def get(self, tenant_id: str, provider: str): """Get info for a trigger provider""" - user = current_user - assert isinstance(user, Account) - assert user.current_tenant_id is not None - return jsonable_encoder( - TriggerProviderService.get_trigger_provider(user.current_tenant_id, TriggerProviderID(provider)) - ) + return jsonable_encoder(TriggerProviderService.get_trigger_provider(tenant_id, TriggerProviderID(provider))) @console_ns.route("/workspaces/current/trigger-provider//subscriptions/list") class TriggerSubscriptionListApi(Resource): + @console_ns.response(200, "Success", console_ns.models[TriggerProviderOpaqueResponse.__name__]) @setup_required @login_required @edit_permission_required @account_initialization_required - def get(self, provider: str): + @with_current_user + @with_current_tenant_id + def get(self, tenant_id: str, user: Account, provider: str): """List all trigger subscriptions for the current tenant's provider""" - user = current_user - assert isinstance(user, Account) - assert user.current_tenant_id is not None - try: return jsonable_encoder( TriggerProviderService.list_trigger_provider_subscriptions( - tenant_id=user.current_tenant_id, + tenant_id=tenant_id, provider_id=TriggerProviderID(provider), user=user, ) @@ -145,21 +168,21 @@ class TriggerSubscriptionListApi(Resource): ) class TriggerSubscriptionBuilderCreateApi(Resource): @console_ns.expect(console_ns.models[TriggerSubscriptionBuilderCreatePayload.__name__]) + @console_ns.response(200, "Success", console_ns.models[TriggerProviderOpaqueResponse.__name__]) @setup_required @login_required @edit_permission_required @account_initialization_required - def post(self, provider: str): + @with_current_user + @with_current_tenant_id + def post(self, tenant_id: str, user: Account, provider: str): """Add a new subscription instance for a trigger provider""" - user = current_user - assert user.current_tenant_id is not None - payload = TriggerSubscriptionBuilderCreatePayload.model_validate(console_ns.payload or {}) try: credential_type = CredentialType.of(payload.credential_type) subscription_builder = TriggerSubscriptionBuilderService.create_trigger_subscription_builder( - tenant_id=user.current_tenant_id, + tenant_id=tenant_id, user_id=user.id, provider_id=TriggerProviderID(provider), credential_type=credential_type, @@ -174,6 +197,7 @@ class TriggerSubscriptionBuilderCreateApi(Resource): "/workspaces/current/trigger-provider//subscriptions/builder/", ) class TriggerSubscriptionBuilderGetApi(Resource): + @console_ns.response(200, "Success", console_ns.models[TriggerProviderOpaqueResponse.__name__]) @setup_required @login_required @edit_permission_required @@ -190,21 +214,21 @@ class TriggerSubscriptionBuilderGetApi(Resource): ) class TriggerSubscriptionBuilderVerifyApi(Resource): @console_ns.expect(console_ns.models[TriggerSubscriptionBuilderVerifyPayload.__name__]) + @console_ns.response(200, "Success", console_ns.models[TriggerProviderOpaqueResponse.__name__]) @setup_required @login_required @edit_permission_required @account_initialization_required - def post(self, provider: str, subscription_builder_id: str): + @with_current_user + @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""" - user = current_user - assert user.current_tenant_id is not None - payload = TriggerSubscriptionBuilderVerifyPayload.model_validate(console_ns.payload or {}) try: # Use atomic update_and_verify to prevent race conditions return TriggerSubscriptionBuilderService.update_and_verify_builder( - tenant_id=user.current_tenant_id, + tenant_id=tenant_id, user_id=user.id, provider_id=TriggerProviderID(provider), subscription_builder_id=subscription_builder_id, @@ -222,21 +246,19 @@ class TriggerSubscriptionBuilderVerifyApi(Resource): ) class TriggerSubscriptionBuilderUpdateApi(Resource): @console_ns.expect(console_ns.models[TriggerSubscriptionBuilderUpdatePayload.__name__]) + @console_ns.response(200, "Success", console_ns.models[TriggerProviderOpaqueResponse.__name__]) @setup_required @login_required @edit_permission_required @account_initialization_required - def post(self, provider: str, subscription_builder_id: str): + @with_current_tenant_id + def post(self, tenant_id: str, provider: str, subscription_builder_id: str): """Update a subscription instance for a trigger provider""" - user = current_user - assert isinstance(user, Account) - assert user.current_tenant_id is not None - payload = TriggerSubscriptionBuilderUpdatePayload.model_validate(console_ns.payload or {}) try: return jsonable_encoder( TriggerSubscriptionBuilderService.update_trigger_subscription_builder( - tenant_id=user.current_tenant_id, + tenant_id=tenant_id, provider_id=TriggerProviderID(provider), subscription_builder_id=subscription_builder_id, subscription_builder_updater=SubscriptionBuilderUpdater( @@ -256,16 +278,13 @@ class TriggerSubscriptionBuilderUpdateApi(Resource): "/workspaces/current/trigger-provider//subscriptions/builder/logs/", ) class TriggerSubscriptionBuilderLogsApi(Resource): + @console_ns.response(200, "Success", console_ns.models[TriggerProviderOpaqueResponse.__name__]) @setup_required @login_required @edit_permission_required @account_initialization_required def get(self, provider: str, subscription_builder_id: str): """Get the request logs for a subscription instance for a trigger provider""" - user = current_user - assert isinstance(user, Account) - assert user.current_tenant_id is not None - try: logs = TriggerSubscriptionBuilderService.list_logs(subscription_builder_id) return jsonable_encoder({"logs": [log.model_dump(mode="json") for log in logs]}) @@ -279,19 +298,20 @@ class TriggerSubscriptionBuilderLogsApi(Resource): ) class TriggerSubscriptionBuilderBuildApi(Resource): @console_ns.expect(console_ns.models[TriggerSubscriptionBuilderUpdatePayload.__name__]) + @console_ns.response(200, "Success", console_ns.models[TriggerProviderOpaqueResponse.__name__]) @setup_required @login_required @edit_permission_required @account_initialization_required - def post(self, provider: str, subscription_builder_id: str): + @with_current_user + @with_current_tenant_id + def post(self, tenant_id: str, user: Account, provider: str, subscription_builder_id: str): """Build a subscription instance for a trigger provider""" - user = current_user - assert user.current_tenant_id is not None payload = TriggerSubscriptionBuilderUpdatePayload.model_validate(console_ns.payload or {}) try: # Use atomic update_and_build to prevent race conditions TriggerSubscriptionBuilderService.update_and_build_builder( - tenant_id=user.current_tenant_id, + tenant_id=tenant_id, user_id=user.id, provider_id=TriggerProviderID(provider), subscription_builder_id=subscription_builder_id, @@ -312,19 +332,18 @@ class TriggerSubscriptionBuilderBuildApi(Resource): ) class TriggerSubscriptionUpdateApi(Resource): @console_ns.expect(console_ns.models[TriggerSubscriptionBuilderUpdatePayload.__name__]) + @console_ns.response(200, "Success", console_ns.models[TriggerProviderOpaqueResponse.__name__]) @setup_required @login_required @edit_permission_required @account_initialization_required - def post(self, subscription_id: str): + @with_current_tenant_id + def post(self, tenant_id: str, subscription_id: str): """Update a subscription instance""" - user = current_user - assert user.current_tenant_id is not None - request = TriggerSubscriptionBuilderUpdatePayload.model_validate(console_ns.payload or {}) subscription = TriggerProviderService.get_subscription_by_id( - tenant_id=user.current_tenant_id, + tenant_id=tenant_id, subscription_id=subscription_id, ) if not subscription: @@ -341,7 +360,7 @@ class TriggerSubscriptionUpdateApi(Resource): manually_created = subscription.credential_type == CredentialType.UNAUTHORIZED if rename or manually_created: TriggerProviderService.update_trigger_subscription( - tenant_id=user.current_tenant_id, + tenant_id=tenant_id, subscription_id=subscription_id, name=request.name, properties=request.properties, @@ -351,7 +370,7 @@ class TriggerSubscriptionUpdateApi(Resource): # For the rest cases(API_KEY, OAUTH2) # we need to call third party provider(e.g. GitHub) to rebuild the subscription TriggerProviderService.rebuild_trigger_subscription( - tenant_id=user.current_tenant_id, + tenant_id=tenant_id, name=request.name, provider_id=provider_id, subscription_id=subscription_id, @@ -375,23 +394,21 @@ class TriggerSubscriptionDeleteApi(Resource): @login_required @is_admin_or_owner_required @account_initialization_required - def post(self, subscription_id: str): + @with_current_tenant_id + def post(self, tenant_id: str, subscription_id: str): """Delete a subscription instance""" - user = current_user - assert user.current_tenant_id is not None - try: with sessionmaker(db.engine).begin() as session: # Delete trigger provider subscription TriggerProviderService.delete_trigger_provider( session=session, - tenant_id=user.current_tenant_id, + tenant_id=tenant_id, subscription_id=subscription_id, ) # Delete plugin triggers TriggerSubscriptionOperatorService.delete_plugin_trigger_by_subscription( session=session, - tenant_id=user.current_tenant_id, + tenant_id=tenant_id, subscription_id=subscription_id, ) return {"result": "success"} @@ -404,20 +421,22 @@ class TriggerSubscriptionDeleteApi(Resource): @console_ns.route("/workspaces/current/trigger-provider//subscriptions/oauth/authorize") class TriggerOAuthAuthorizeApi(Resource): + @console_ns.response( + 200, + "Authorization URL retrieved successfully", + console_ns.models[TriggerOAuthAuthorizeResponse.__name__], + ) @setup_required @login_required @account_initialization_required - def get(self, provider: str): + @with_current_user + @with_current_tenant_id + def get(self, tenant_id: str, user: Account, provider: str): """Initiate OAuth authorization flow for a trigger provider""" - user = current_user - assert isinstance(user, Account) - assert user.current_tenant_id is not None - try: provider_id = TriggerProviderID(provider) plugin_id = provider_id.plugin_id provider_name = provider_id.provider_name - tenant_id = user.current_tenant_id # Get OAuth client configuration oauth_client_params = TriggerProviderService.get_oauth_client( @@ -488,6 +507,11 @@ class TriggerOAuthAuthorizeApi(Resource): @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__], + ) @setup_required def get(self, provider: str): """Handle OAuth callback for trigger provider""" @@ -553,34 +577,33 @@ class TriggerOAuthCallbackApi(Resource): @console_ns.route("/workspaces/current/trigger-provider//oauth/client") class TriggerOAuthClientManageApi(Resource): + @console_ns.response(200, "Success", console_ns.models[TriggerOAuthClientResponse.__name__]) @setup_required @login_required @is_admin_or_owner_required @account_initialization_required - def get(self, provider: str): + @with_current_tenant_id + def get(self, tenant_id: str, provider: str): """Get OAuth client configuration for a provider""" - user = current_user - assert user.current_tenant_id is not None - try: provider_id = TriggerProviderID(provider) # Get custom OAuth client params if exists custom_params = TriggerProviderService.get_custom_oauth_client_params( - tenant_id=user.current_tenant_id, + tenant_id=tenant_id, provider_id=provider_id, ) # Check if custom client is enabled is_custom_enabled = TriggerProviderService.is_oauth_custom_client_enabled( - tenant_id=user.current_tenant_id, + tenant_id=tenant_id, provider_id=provider_id, ) system_client_exists = TriggerProviderService.is_oauth_system_client_exists( - tenant_id=user.current_tenant_id, + tenant_id=tenant_id, provider_id=provider_id, ) - provider_controller = TriggerManager.get_trigger_provider(user.current_tenant_id, provider_id) + 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( { @@ -599,21 +622,20 @@ class TriggerOAuthClientManageApi(Resource): raise @console_ns.expect(console_ns.models[TriggerOAuthClientPayload.__name__]) + @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) @setup_required @login_required @is_admin_or_owner_required @account_initialization_required - def post(self, provider: str): + @with_current_tenant_id + def post(self, tenant_id: str, provider: str): """Configure custom OAuth client for a provider""" - user = current_user - assert user.current_tenant_id is not None - payload = TriggerOAuthClientPayload.model_validate(console_ns.payload or {}) try: provider_id = TriggerProviderID(provider) return TriggerProviderService.save_custom_oauth_client_params( - tenant_id=user.current_tenant_id, + tenant_id=tenant_id, provider_id=provider_id, client_params=payload.client_params, enabled=payload.enabled, @@ -629,16 +651,15 @@ class TriggerOAuthClientManageApi(Resource): @login_required @is_admin_or_owner_required @account_initialization_required - def delete(self, provider: str): + @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""" - user = current_user - assert user.current_tenant_id is not None - try: provider_id = TriggerProviderID(provider) return TriggerProviderService.delete_custom_oauth_client_params( - tenant_id=user.current_tenant_id, + tenant_id=tenant_id, provider_id=provider_id, ) except ValueError as e: @@ -653,20 +674,20 @@ class TriggerOAuthClientManageApi(Resource): ) class TriggerSubscriptionVerifyApi(Resource): @console_ns.expect(console_ns.models[TriggerSubscriptionBuilderVerifyPayload.__name__]) + @console_ns.response(200, "Success", console_ns.models[TriggerProviderOpaqueResponse.__name__]) @setup_required @login_required @edit_permission_required @account_initialization_required - def post(self, provider: str, subscription_id: str): + @with_current_user + @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)""" - user = current_user - assert user.current_tenant_id is not None - verify_request = TriggerSubscriptionBuilderVerifyPayload.model_validate(console_ns.payload or {}) try: result = TriggerProviderService.verify_subscription_credentials( - tenant_id=user.current_tenant_id, + tenant_id=tenant_id, user_id=user.id, provider_id=TriggerProviderID(provider), subscription_id=subscription_id, diff --git a/api/controllers/console/workspace/workspace.py b/api/controllers/console/workspace/workspace.py index 60ecaa16bdb..59a33fe0385 100644 --- a/api/controllers/console/workspace/workspace.py +++ b/api/controllers/console/workspace/workspace.py @@ -16,7 +16,7 @@ from controllers.common.errors import ( TooManyFilesError, UnsupportedFileTypeError, ) -from controllers.common.schema import 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.admin import admin_required from controllers.console.error import AccountNotLinkTenantError @@ -31,9 +31,9 @@ from controllers.console.wraps import ( from enums.cloud_plan import CloudPlan from extensions.ext_database import db from fields.base import ResponseModel -from libs.helper import TimestampField, dump_response, to_timestamp +from libs.helper import OptionalTimestampField, TimestampField, dump_response, to_timestamp from libs.login import login_required -from models.account import Account, Tenant, TenantCustomConfigDict, TenantStatus +from models.account import Account, Tenant, TenantAccountJoin, TenantCustomConfigDict, TenantStatus from services.account_service import TenantService from services.billing_service import BillingService, SubscriptionPlan from services.enterprise.enterprise_service import EnterpriseService @@ -96,6 +96,76 @@ class TenantInfoResponse(ResponseModel): return to_timestamp(value) +class TenantListItemResponse(ResponseModel): + id: str + name: str | None = None + plan: str | None = None + status: str | None = None + created_at: int | None = None + current: bool + + @field_validator("plan", "status", mode="before") + @classmethod + def _normalize_enum_like(cls, value): + if value is None: + return None + if isinstance(value, str): + return value + return str(getattr(value, "value", value)) + + @field_validator("created_at", mode="before") + @classmethod + def _normalize_created_at(cls, value: datetime | int | None): + return to_timestamp(value) + + +class TenantListResponse(ResponseModel): + workspaces: list[TenantListItemResponse] + + +class WorkspaceListItemResponse(ResponseModel): + id: str + name: str | None = None + status: str | None = None + created_at: int | None = None + + @field_validator("status", mode="before") + @classmethod + def _normalize_status(cls, value): + if value is None: + return None + if isinstance(value, str): + return value + return str(getattr(value, "value", value)) + + @field_validator("created_at", mode="before") + @classmethod + def _normalize_created_at(cls, value: datetime | int | None): + return to_timestamp(value) + + +class WorkspaceListResponse(ResponseModel): + data: list[WorkspaceListItemResponse] + has_more: bool + limit: int + page: int + total: int + + +class SwitchWorkspaceResponse(ResponseModel): + result: str + new_tenant: TenantInfoResponse + + +class WorkspaceMutationResponse(ResponseModel): + result: str + tenant: TenantInfoResponse + + +class WorkspaceLogoUploadResponse(ResponseModel): + id: str + + class WorkspacePermissionResponse(ResponseModel): workspace_id: str allow_member_invite: bool @@ -112,6 +182,11 @@ register_schema_models( register_response_schema_models( console_ns, TenantInfoResponse, + TenantListResponse, + WorkspaceListResponse, + SwitchWorkspaceResponse, + WorkspaceMutationResponse, + WorkspaceLogoUploadResponse, WorkspaceCustomConfigResponse, WorkspacePermissionResponse, ) @@ -144,6 +219,7 @@ tenants_fields = { "plan": fields.String, "status": fields.String, "created_at": TimestampField, + "last_opened_at": OptionalTimestampField, "current": fields.Boolean, } @@ -152,13 +228,19 @@ workspace_fields = {"id": fields.String, "name": fields.String, "status": fields @console_ns.route("/workspaces") class TenantListApi(Resource): + @console_ns.response(200, "Success", console_ns.models[TenantListResponse.__name__]) @setup_required @login_required @account_initialization_required @with_current_user @with_current_tenant_id def get(self, current_tenant_id: str, current_user: Account): - tenants = TenantService.get_join_tenants(current_user) + tenant_rows: list[tuple[Tenant, TenantAccountJoin]] = [ + (tenant, membership) + for tenant, membership in TenantService.get_workspaces_for_account(db.session, current_user.id) + if tenant.status == TenantStatus.NORMAL + ] + tenants = [tenant for tenant, _ in tenant_rows] tenant_dicts = [] is_enterprise_only = dify_config.ENTERPRISE_ENABLED and not dify_config.BILLING_ENABLED is_saas = dify_config.EDITION == "CLOUD" and dify_config.BILLING_ENABLED @@ -171,7 +253,7 @@ class TenantListApi(Resource): if not tenant_plans: logger.warning("get_plan_bulk returned empty result, falling back to legacy feature path") - for tenant in tenants: + for tenant, membership in tenant_rows: plan: str = CloudPlan.SANDBOX if is_saas: tenant_plan = tenant_plans.get(tenant.id) @@ -190,6 +272,7 @@ class TenantListApi(Resource): "name": tenant.name, "status": tenant.status, "created_at": tenant.created_at, + "last_opened_at": membership.last_opened_at, "plan": plan, "current": tenant.id == current_tenant_id if current_tenant_id else False, } @@ -201,7 +284,8 @@ class TenantListApi(Resource): @console_ns.route("/all-workspaces") class WorkspaceListApi(Resource): - @console_ns.expect(console_ns.models[WorkspaceListQuery.__name__]) + @console_ns.doc(params=query_params_from_model(WorkspaceListQuery)) + @console_ns.response(200, "Success", console_ns.models[WorkspaceListResponse.__name__]) @setup_required @admin_required def get(self): @@ -256,6 +340,7 @@ class TenantApi(Resource): @console_ns.route("/workspaces/switch") class SwitchWorkspaceApi(Resource): @console_ns.expect(console_ns.models[SwitchWorkspacePayload.__name__]) + @console_ns.response(200, "Success", console_ns.models[SwitchWorkspaceResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -280,6 +365,7 @@ class SwitchWorkspaceApi(Resource): @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__]) @setup_required @login_required @account_initialization_required @@ -307,6 +393,7 @@ class CustomConfigWorkspaceApi(Resource): @console_ns.route("/workspaces/custom-config/webapp-logo/upload") class WebappLogoWorkspaceApi(Resource): + @console_ns.response(201, "Logo uploaded", console_ns.models[WorkspaceLogoUploadResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -348,6 +435,7 @@ class WebappLogoWorkspaceApi(Resource): @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__]) @setup_required @login_required @account_initialization_required diff --git a/api/controllers/openapi/__init__.py b/api/controllers/openapi/__init__.py index fc7ee94c91d..e8406ea00cb 100644 --- a/api/controllers/openapi/__init__.py +++ b/api/controllers/openapi/__init__.py @@ -1,6 +1,7 @@ from flask import Blueprint from flask_restx import Namespace +from controllers.openapi._errors import ErrorBody, OpenApiErrorCode, OpenApiErrorFormatter from libs.device_flow_security import attach_anti_framing from libs.external_api import ExternalApi @@ -12,13 +13,15 @@ api = ExternalApi( version="1.0", title="OpenAPI", description="User-scoped programmatic API (bearer auth)", + error_body_formatter=OpenApiErrorFormatter(), ) openapi_ns = Namespace("openapi", description="User-scoped operations", path="/") # Register response/query models BEFORE importing controller modules so that # @openapi_ns.response / @openapi_ns.expect decorators can resolve model names. -from controllers.common.schema import register_response_schema_models, register_schema_models +from controllers.common.fields import EventStreamResponse +from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models from controllers.openapi._models import ( AccountPayload, AccountResponse, @@ -40,8 +43,10 @@ from controllers.openapi._models import ( DeviceMutateRequest, DeviceMutateResponse, DevicePollRequest, + DeviceTokenResponse, FormSubmitResponse, HealthResponse, + HumanInputFormDefinitionResponse, MemberActionResponse, MemberInvitePayload, MemberInviteResponse, @@ -89,6 +94,8 @@ register_schema_models( ) register_response_schema_models( openapi_ns, + ErrorBody, + EventStreamResponse, TagItem, UsageInfo, MessageMetadata, @@ -117,13 +124,18 @@ register_response_schema_models( MemberActionResponse, TaskStopResponse, FormSubmitResponse, + HumanInputFormDefinitionResponse, DeviceCodeResponse, + DeviceTokenResponse, DeviceLookupResponse, DeviceMutateResponse, FileResponse, ServerVersionResponse, HealthResponse, ) +# Standalone definition for contract codegen; ErrorBody.code stays an open +# string on the wire so old clients keep parsing future codes. +register_enum_models(openapi_ns, OpenApiErrorCode) from . import ( _meta, diff --git a/api/controllers/openapi/_contract.py b/api/controllers/openapi/_contract.py index 0979b01a357..a7dcf9093da 100644 --- a/api/controllers/openapi/_contract.py +++ b/api/controllers/openapi/_contract.py @@ -21,6 +21,7 @@ from pydantic import BaseModel, ValidationError from controllers.common.schema import query_params_from_model, query_params_from_request from controllers.openapi import openapi_ns +from controllers.openapi._errors import ErrorBody def accepts(*, query: type[BaseModel] | None = None, body: type[BaseModel] | None = None) -> Callable: @@ -51,6 +52,8 @@ def accepts(*, query: type[BaseModel] | None = None, body: type[BaseModel] | Non openapi_ns.doc(params=query_params_from_model(query))(wrapper) if body is not None: openapi_ns.expect(openapi_ns.models[body.__name__])(wrapper) + if query is not None or body is not None: + openapi_ns.response(422, "Validation error", openapi_ns.models[ErrorBody.__name__])(wrapper) return wrapper return decorator @@ -76,6 +79,7 @@ def returns(code: int, model: type[BaseModel], description: str | None = None) - return result openapi_ns.response(code, description or model.__name__, openapi_ns.models[model.__name__])(wrapper) + openapi_ns.response("default", "Error", openapi_ns.models[ErrorBody.__name__])(wrapper) return wrapper return decorator diff --git a/api/controllers/openapi/_errors.py b/api/controllers/openapi/_errors.py new file mode 100644 index 00000000000..38c068bd354 --- /dev/null +++ b/api/controllers/openapi/_errors.py @@ -0,0 +1,241 @@ +"""Canonical error contract for the /openapi/v1 surface. + +``ErrorBody`` is the only wire shape an /openapi/v1 endpoint may emit for a +non-2xx response (RFC 8628 device-flow responses excepted — that shape is +mandated by the OAuth spec):: + + code str semantic error code (OpenApiErrorCode member) + message str human-readable summary + status int HTTP status, duplicated in the body + hint str | None actionable next step for the caller + details list[ErrorDetail] per-field validation breakdown {type, loc, msg} + +``OpenApiErrorFormatter`` is injected into ``ExternalApi`` so every +error-handler path funnels through one builder, and it also rewrites +``e.data`` because flask-restx ``Api.handle_error`` lets a pre-existing +``e.data`` override the registered handler's return value. + +The transport-generic enum members, ``_CODE_BY_STATUS`` and the +``OpenApiError``/``OpenApiErrorFormatter`` bases are openapi-only today; +promote them to ``libs`` if a second surface adopts ``ErrorBody``. +""" + +import logging +from enum import StrEnum +from typing import Any + +from pydantic import BaseModel +from werkzeug.exceptions import HTTPException + +from libs.external_api import http_status_message + + +class OpenApiErrorCode(StrEnum): + # transport-generic (resolved from HTTP status for plain werkzeug raises) + BAD_REQUEST = "bad_request" + UNAUTHORIZED = "unauthorized" + FORBIDDEN = "forbidden" + NOT_FOUND = "not_found" + METHOD_NOT_ALLOWED = "method_not_allowed" + NOT_ACCEPTABLE = "not_acceptable" + CONFLICT = "conflict" + REQUEST_TOO_LARGE = "request_entity_too_large" + UNSUPPORTED_MEDIA_TYPE = "unsupported_media_type" + INVALID_PARAM = "invalid_param" + TOO_MANY_REQUESTS = "too_many_requests" + INTERNAL_ERROR = "internal_server_error" + BAD_GATEWAY = "bad_gateway" + UNKNOWN = "unknown" + # domain codes (must match the error_code attribute of the exception + # classes raised on the openapi surface) + APP_UNAVAILABLE = "app_unavailable" + CONVERSATION_COMPLETED = "conversation_completed" + PROVIDER_NOT_INITIALIZE = "provider_not_initialize" + PROVIDER_QUOTA_EXCEEDED = "provider_quota_exceeded" + MODEL_NOT_SUPPORTED = "model_currently_not_support" + COMPLETION_REQUEST_ERROR = "completion_request_error" + RATE_LIMIT_ERROR = "rate_limit_error" + FILE_TOO_LARGE = "file_too_large" + UNSUPPORTED_FILE_TYPE = "unsupported_file_type" + NO_FILE_UPLOADED = "no_file_uploaded" + TOO_MANY_FILES = "too_many_files" + FILENAME_NOT_EXISTS = "filename_not_exists" + FILE_EXTENSION_BLOCKED = "file_extension_blocked" + MEMBER_LIMIT_EXCEEDED = "member_limit_exceeded" + MEMBER_LICENSE_EXCEEDED = "member_license_exceeded" + + +class ErrorDetail(BaseModel): + type: str + loc: list[str | int] = [] + msg: str + + +class ErrorBody(BaseModel): + """Canonical non-2xx body. ``code`` is typed ``str`` (not the enum) so the + generated client schema stays an open enum — old CLIs keep parsing when a + future server adds a code. Formatter tests pin emitted values to the enum.""" + + code: str + message: str + status: int + hint: str | None = None + details: list[ErrorDetail] | None = None + + +_CODE_BY_STATUS: dict[int, OpenApiErrorCode] = { + 400: OpenApiErrorCode.BAD_REQUEST, + 401: OpenApiErrorCode.UNAUTHORIZED, + 403: OpenApiErrorCode.FORBIDDEN, + 404: OpenApiErrorCode.NOT_FOUND, + 405: OpenApiErrorCode.METHOD_NOT_ALLOWED, + 406: OpenApiErrorCode.NOT_ACCEPTABLE, + 409: OpenApiErrorCode.CONFLICT, + 413: OpenApiErrorCode.REQUEST_TOO_LARGE, + 415: OpenApiErrorCode.UNSUPPORTED_MEDIA_TYPE, + 422: OpenApiErrorCode.INVALID_PARAM, + 429: OpenApiErrorCode.TOO_MANY_REQUESTS, + 500: OpenApiErrorCode.INTERNAL_ERROR, + 502: OpenApiErrorCode.BAD_GATEWAY, +} + +_GENERIC_500_MESSAGE = "Internal Server Error" + +logger = logging.getLogger(__name__) + + +class OpenApiError(HTTPException): + """Dedicated throwable for the /openapi/v1 surface. + + A subclass declares ``code`` (HTTP status), ``error_code`` and + ``description`` exactly once; call sites just ``raise SomeError()`` — + no per-site dict building, no duplicated message constants. The + formatter emits all three (plus optional ``hint``/``details``) verbatim. + """ + + code = 400 + error_code: OpenApiErrorCode = OpenApiErrorCode.UNKNOWN + hint: str | None = None + + def __init__( + self, + message: str | None = None, + *, + hint: str | None = None, + details: list[ErrorDetail] | None = None, + ) -> None: + super().__init__(description=message) + if hint is not None: + self.hint = hint + self.details = details + + +class OpenApiErrorFormatter: + """Builds the canonical ErrorBody from whatever the shared handlers computed. + + Resolution order for ``code``: explicit ``error_code`` class attribute + (BaseHTTPException subclasses and OpenApiError subclasses) → HTTP status + map → ``unknown``. Class-name-derived codes from the shared handler are + deliberately ignored — they are not a stable contract. + """ + + def finalize(self, e: Exception, data: dict[str, Any], status_code: int) -> dict[str, Any]: + exc_data = getattr(e, "data", None) + merged: dict[str, Any] = {**data, **exc_data} if isinstance(exc_data, dict) else dict(data) + + # finalize runs inside the framework error handler: raising here would + # replace the response with an unformatted 500, so fall back instead + try: + body = ErrorBody( + code=self._resolve_code(e, status_code), + message=self._resolve_message(merged, status_code), + status=status_code, + hint=self._resolve_hint(e), + details=self._extract_details(e, merged), + ) + wire = body.model_dump(mode="json", exclude_none=True) + except Exception: + logger.exception("error-body build failed; emitting fallback body") + wire = { + "code": str(_CODE_BY_STATUS.get(status_code, OpenApiErrorCode.UNKNOWN)), + "message": http_status_message(status_code) or "request failed", + "status": status_code, + } + + # flask-restx Api.handle_error does `data = getattr(e, "data", default_data)` + # AFTER our handler returns, so a pre-existing e.data (flask_restx.abort, + # BaseHTTPException) would override the canonical body. Rewrite it. + try: + e.data = wire # type: ignore[attr-defined] + except AttributeError: + pass + return wire + + def _resolve_code(self, e: Exception, status_code: int) -> str: + explicit = getattr(type(e), "error_code", None) + if isinstance(explicit, (OpenApiErrorCode, str)) and str(explicit) != "unknown": + return str(explicit) + return str(_CODE_BY_STATUS.get(status_code, OpenApiErrorCode.UNKNOWN)) + + def _resolve_message(self, merged: dict[str, Any], status_code: int) -> str: + if status_code >= 500: + return _GENERIC_500_MESSAGE + message = merged.get("message") + if isinstance(message, str) and message: + return message + return http_status_message(status_code) or "request failed" + + def _resolve_hint(self, e: Exception) -> str | None: + hint = getattr(e, "hint", None) + return hint if isinstance(hint, str) and hint else None + + def _extract_details(self, e: Exception, merged: dict[str, Any]) -> list[ErrorDetail] | None: + explicit = getattr(e, "details", None) + if isinstance(explicit, list) and explicit and all(isinstance(d, ErrorDetail) for d in explicit): + return explicit + # an already-canonical body (e.g. e.data rewritten by a prior finalize) + # carries "details"; re-validate so finalize stays idempotent + canonical = merged.get("details") + if isinstance(canonical, list) and canonical and all(isinstance(d, dict) for d in canonical): + return [ErrorDetail.model_validate(d) for d in canonical] + errors = merged.get("errors") + if isinstance(errors, list) and errors: + details = [ + ErrorDetail( + type=str(item.get("type", "invalid")), + loc=[part for part in item.get("loc", []) if self._is_loc_part(part)], + msg=str(item.get("msg", "")), + ) + for item in errors + if isinstance(item, dict) + ] + return details or None + params = merged.get("params") + if isinstance(params, str) and params: + return [ErrorDetail(type="invalid", loc=[params], msg=str(merged.get("message", "")))] + return None + + @staticmethod + def _is_loc_part(part: Any) -> bool: + # bool is an int subclass but is not a valid path segment + return isinstance(part, (str, int)) and not isinstance(part, bool) + + +class FilenameNotExists(OpenApiError): # noqa: N818 + code = 400 + error_code = OpenApiErrorCode.FILENAME_NOT_EXISTS + description = "The specified filename does not exist." + + +class MemberLimitExceeded(OpenApiError): # noqa: N818 + code = 403 + error_code = OpenApiErrorCode.MEMBER_LIMIT_EXCEEDED + description = "Subscription member limit reached." + hint = "Upgrade your plan to invite more members or remove an existing member first." + + +class MemberLicenseExceeded(OpenApiError): # noqa: N818 + code = 403 + error_code = OpenApiErrorCode.MEMBER_LICENSE_EXCEEDED + description = "Workspace member license capacity reached." + hint = "Contact your workspace administrator to expand the license seat count." diff --git a/api/controllers/openapi/_models.py b/api/controllers/openapi/_models.py index a80ab63b420..7c225c85f65 100644 --- a/api/controllers/openapi/_models.py +++ b/api/controllers/openapi/_models.py @@ -87,12 +87,8 @@ class AppDescribeInfo(AppInfoResponse): class AppDescribeResponse(BaseModel): info: AppDescribeInfo | None = None - # `parameters` (the app-config blob) and `input_schema` (a Draft 2020-12 JSON Schema derived - # per-app) are deliberately open JSON, not under-annotated. The `x-dify-opaque` marker tells the - # contract generator's readiness detector to treat them as intentional, so the route is not - # flagged "annotations incomplete". CLI/web consume them as opaque objects either way. - parameters: dict[str, Any] | None = Field(default=None, json_schema_extra={"x-dify-opaque": True}) - input_schema: dict[str, Any] | None = Field(default=None, json_schema_extra={"x-dify-opaque": True}) + parameters: dict[str, Any] | None = Field(default=None) + input_schema: dict[str, Any] | None = Field(default=None) class ChatMessageResponse(BaseModel): @@ -150,6 +146,18 @@ class WorkspacePayload(BaseModel): role: str +class DeviceTokenResponse(BaseModel): + token: str + expires_at: str + subject_type: Literal["account", "external_sso"] + account: AccountPayload | None = None + workspaces: list[WorkspacePayload] = [] + default_workspace_id: str | None = None + token_id: str + subject_email: str | None = None + subject_issuer: str | None = None + + class AccountResponse(BaseModel): subject_type: str subject_email: str | None = None @@ -292,7 +300,7 @@ class AppListQuery(BaseModel): class AppRunRequest(BaseModel): inputs: dict[str, Any] query: str | None = None - files: list[dict[str, Any]] | None = None + files: list[dict[str, Any]] | None = Field(default=None) conversation_id: UUIDStrOrEmpty | None = None auto_generate_name: bool = True workflow_id: str | None = None @@ -469,3 +477,11 @@ class FormSubmitResponse(BaseModel): than an under-annotated open object.""" model_config = ConfigDict(extra="forbid") + + +class HumanInputFormDefinitionResponse(BaseModel): + form_content: str + inputs: list[dict[str, Any]] = Field(default_factory=list) + resolved_default_values: dict[str, str] + user_actions: list[dict[str, Any]] = Field(default_factory=list) + expiration_time: int | None = None diff --git a/api/controllers/openapi/app_run.py b/api/controllers/openapi/app_run.py index d801f5183f1..76ddd166596 100644 --- a/api/controllers/openapi/app_run.py +++ b/api/controllers/openapi/app_run.py @@ -8,9 +8,17 @@ from contextlib import contextmanager from typing import Any from flask_restx import Resource -from werkzeug.exceptions import BadRequest, HTTPException, InternalServerError, NotFound, UnprocessableEntity +from werkzeug.exceptions import ( + BadRequest, + HTTPException, + InternalServerError, + NotFound, + TooManyRequests, + UnprocessableEntity, +) import services +from controllers.common.fields import EventStreamResponse from controllers.openapi import openapi_ns from controllers.openapi._audit import emit_app_run from controllers.openapi._contract import accepts, returns @@ -29,6 +37,7 @@ from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpErr from core.app.apps.base_app_queue_manager import AppQueueManager from core.app.entities.app_invoke_entities import InvokeFrom from core.errors.error import ( + AppInvokeQuotaExceededError, ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError, @@ -71,6 +80,11 @@ def _translate_service_errors() -> Iterator[None]: raise ProviderQuotaExceededError() except ModelCurrentlyNotSupportError: raise ProviderModelCurrentlyNotSupportError() + except AppInvokeQuotaExceededError: + # App concurrency limit. Without this it falls through to the bare `except Exception` + # below and surfaces as a 500. Render as the canonical 429 (code "too_many_requests"); + # the source message is dropped since it carries internal detail (client_id / limits). + raise TooManyRequests() except InvokeRateLimitError as ex: raise InvokeRateLimitHttpError(ex.description) except InvokeError as e: @@ -123,7 +137,7 @@ _DISPATCH: dict[AppMode, Callable[[App, Any, AppRunRequest], Any]] = { @openapi_ns.route("/apps//run") class AppRunApi(Resource): @auth_router.guard(scope=Scope.APPS_RUN) - @openapi_ns.response(200, "Run result (SSE stream)") + @openapi_ns.response(200, "Run result (SSE stream)", openapi_ns.models[EventStreamResponse.__name__]) @accepts(body=AppRunRequest) def post(self, app_id: str, *, auth_data: AuthData, body: AppRunRequest): app_model, caller, caller_kind = auth_data.require_app_context() diff --git a/api/controllers/openapi/files.py b/api/controllers/openapi/files.py index e77e4bc3027..7326a4a922e 100644 --- a/api/controllers/openapi/files.py +++ b/api/controllers/openapi/files.py @@ -10,7 +10,6 @@ from werkzeug.exceptions import BadRequest import services from controllers.common.errors import ( BlockedFileExtensionError, - FilenameNotExistsError, FileTooLargeError, NoFileUploadedError, TooManyFilesError, @@ -18,6 +17,7 @@ from controllers.common.errors import ( ) from controllers.openapi import openapi_ns from controllers.openapi._contract import returns +from controllers.openapi._errors import FilenameNotExists from controllers.openapi.auth.composition import auth_router from controllers.openapi.auth.data import AuthData from extensions.ext_database import db @@ -52,7 +52,7 @@ class AppFileUploadApi(Resource): if not file.mimetype: raise UnsupportedFileTypeError() if not file.filename: - raise FilenameNotExistsError() + raise FilenameNotExists() try: upload_file = FileService(db.engine).upload_file( diff --git a/api/controllers/openapi/human_input_form.py b/api/controllers/openapi/human_input_form.py index e04dc8a1afe..995315150cc 100644 --- a/api/controllers/openapi/human_input_form.py +++ b/api/controllers/openapi/human_input_form.py @@ -18,7 +18,7 @@ from controllers.common.human_input import HumanInputFormSubmitPayload, stringif from controllers.common.schema import register_schema_models from controllers.openapi import openapi_ns from controllers.openapi._contract import accepts, returns -from controllers.openapi._models import FormSubmitResponse +from controllers.openapi._models import FormSubmitResponse, HumanInputFormDefinitionResponse from controllers.openapi.auth.composition import auth_router from controllers.openapi.auth.data import AuthData from core.workflow.human_input_policy import HumanInputSurface, is_recipient_type_allowed_for_surface @@ -57,7 +57,7 @@ def _ensure_form_is_allowed_for_openapi(form) -> None: @openapi_ns.route("/apps//form/human_input/") class OpenApiWorkflowHumanInputFormApi(Resource): - @openapi_ns.response(200, "Form definition") + @openapi_ns.response(200, "Form definition", openapi_ns.models[HumanInputFormDefinitionResponse.__name__]) @auth_router.guard(scope=Scope.APPS_RUN) def get(self, app_id: str, form_token: str, *, auth_data: AuthData): app_model, caller, caller_kind = auth_data.require_app_context() diff --git a/api/controllers/openapi/oauth_device.py b/api/controllers/openapi/oauth_device.py index e061f36a6b5..cee187daaf3 100644 --- a/api/controllers/openapi/oauth_device.py +++ b/api/controllers/openapi/oauth_device.py @@ -42,6 +42,7 @@ from controllers.openapi._models import ( DeviceMutateRequest, DeviceMutateResponse, DevicePollRequest, + DeviceTokenResponse, WorkspacePayload, ) from extensions.ext_database import db @@ -130,6 +131,7 @@ class OAuthDeviceTokenApi(Resource): """RFC 8628 poll.""" @openapi_ns.expect(openapi_ns.models[DevicePollRequest.__name__]) + @openapi_ns.response(200, "Device token", openapi_ns.models[DeviceTokenResponse.__name__]) def post(self): payload = _validate_json(DevicePollRequest) device_code = payload.device_code diff --git a/api/controllers/openapi/workflow_events.py b/api/controllers/openapi/workflow_events.py index f21306e491e..61ebb3012dc 100644 --- a/api/controllers/openapi/workflow_events.py +++ b/api/controllers/openapi/workflow_events.py @@ -13,9 +13,12 @@ from collections.abc import Generator from flask import Response, request from flask_restx import Resource +from pydantic import BaseModel, Field from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import NotFound, UnprocessableEntity +from controllers.common.fields import EventStreamResponse +from controllers.common.schema import query_params_from_model from controllers.openapi import openapi_ns from controllers.openapi.auth.composition import auth_router from controllers.openapi.auth.data import AuthData @@ -34,9 +37,15 @@ from repositories.factory import DifyAPIRepositoryFactory from services.workflow_event_snapshot_service import build_workflow_event_stream +class WorkflowEventsQuery(BaseModel): + include_state_snapshot: bool = Field(default=False, description="Whether to include workflow state snapshots") + continue_on_pause: bool = Field(default=False, description="Whether to keep the event stream open on pause") + + @openapi_ns.route("/apps//tasks//events") class OpenApiWorkflowEventsApi(Resource): - @openapi_ns.response(200, "SSE event stream") + @openapi_ns.doc(params=query_params_from_model(WorkflowEventsQuery)) + @openapi_ns.response(200, "SSE event stream", openapi_ns.models[EventStreamResponse.__name__]) @auth_router.guard(scope=Scope.APPS_RUN) def get(self, app_id: str, task_id: str, *, auth_data: AuthData): app_model, caller, caller_kind = auth_data.require_app_context() diff --git a/api/controllers/openapi/workspaces.py b/api/controllers/openapi/workspaces.py index 902337703aa..0ff225271df 100644 --- a/api/controllers/openapi/workspaces.py +++ b/api/controllers/openapi/workspaces.py @@ -14,13 +14,13 @@ from __future__ import annotations from itertools import starmap from urllib import parse -from flask import jsonify, make_response from flask_restx import Resource -from werkzeug.exceptions import BadRequest, Forbidden, NotFound +from werkzeug.exceptions import BadRequest, NotFound from configs import dify_config from controllers.openapi import openapi_ns from controllers.openapi._contract import accepts, returns +from controllers.openapi._errors import MemberLicenseExceeded, MemberLimitExceeded from controllers.openapi._models import ( MemberActionResponse, MemberInvitePayload, @@ -77,34 +77,16 @@ def _load_account(account_id: object) -> Account: return account -def _quota_error(*, code: str, message: str, hint: str) -> Forbidden: - err = Forbidden(message) - err.response = make_response( - jsonify({"code": code, "message": message, "hint": hint}), - 403, - ) - return err - - def _check_member_invite_quota(tenant_id: str) -> None: features = FeatureService.get_features(tenant_id) if features.billing.enabled: members = features.members if 0 < members.limit <= members.size: - raise _quota_error( - code="members.limit_exceeded", - message="Subscription member limit reached.", - hint="Upgrade your plan to invite more members or remove an existing member first.", - ) + raise MemberLimitExceeded() - if features.workspace_members.enabled: - if not features.workspace_members.is_available(1): - raise _quota_error( - code="workspace_members.license_exceeded", - message="Workspace member license capacity reached.", - hint="Contact your workspace administrator to expand the license seat count.", - ) + if features.workspace_members.enabled and not features.workspace_members.is_available(1): + raise MemberLicenseExceeded() @openapi_ns.route("/workspaces") diff --git a/api/controllers/service_api/app/annotation.py b/api/controllers/service_api/app/annotation.py index 8906d544e82..e99018a985d 100644 --- a/api/controllers/service_api/app/annotation.py +++ b/api/controllers/service_api/app/annotation.py @@ -6,12 +6,13 @@ from flask_restx import Resource from flask_restx.api import HTTPStatus from pydantic import BaseModel, Field, TypeAdapter -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.wraps import edit_permission_required from controllers.service_api import service_api_ns from controllers.service_api.wraps import validate_app_token from extensions.ext_redis import redis_client from fields.annotation_fields import Annotation, AnnotationList +from fields.base import ResponseModel from models.model import App from services.annotation_service import ( AppAnnotationService, @@ -38,6 +39,12 @@ class AnnotationListQuery(BaseModel): keyword: str = Field(default="", description="Keyword to search annotations") +class AnnotationJobStatusResponse(ResponseModel): + job_id: str + job_status: str + error_msg: str | None = None + + register_schema_models( service_api_ns, AnnotationCreatePayload, @@ -46,6 +53,7 @@ register_schema_models( Annotation, AnnotationList, ) +register_response_schema_models(service_api_ns, AnnotationJobStatusResponse) @service_api_ns.route("/apps/annotation-reply/") @@ -60,6 +68,11 @@ class AnnotationReplyActionApi(Resource): 401: "Unauthorized - invalid API token", } ) + @service_api_ns.response( + 200, + "Action completed successfully", + service_api_ns.models[AnnotationJobStatusResponse.__name__], + ) @validate_app_token def post(self, app_model: App, action: Literal["enable", "disable"]): """Enable or disable annotation reply feature.""" @@ -89,6 +102,11 @@ class AnnotationReplyActionStatusApi(Resource): 404: "Job not found", } ) + @service_api_ns.response( + 200, + "Job status retrieved successfully", + service_api_ns.models[AnnotationJobStatusResponse.__name__], + ) @validate_app_token def get(self, app_model: App, job_id: UUID, action: str): """Get the status of an annotation reply action job.""" diff --git a/api/controllers/service_api/app/app.py b/api/controllers/service_api/app/app.py index 27b0d8753da..cc55876bd5a 100644 --- a/api/controllers/service_api/app/app.py +++ b/api/controllers/service_api/app/app.py @@ -1,6 +1,7 @@ from typing import Any, cast from flask_restx import Resource +from pydantic import Field from controllers.common.fields import Parameters from controllers.common.schema import register_response_schema_models @@ -21,7 +22,11 @@ class AppInfoResponse(ResponseModel): author_name: str | None -register_response_schema_models(service_api_ns, AppInfoResponse) +class AppMetaResponse(ResponseModel): + tool_icons: dict[str, Any] = Field(default_factory=dict) + + +register_response_schema_models(service_api_ns, Parameters, AppMetaResponse, AppInfoResponse) @service_api_ns.route("/parameters") @@ -37,6 +42,7 @@ class AppParameterApi(Resource): 404: "Application not found", } ) + @service_api_ns.response(200, "Parameters retrieved successfully", service_api_ns.models[Parameters.__name__]) @validate_app_token def get(self, app_model: App): """Retrieve app parameters. @@ -74,6 +80,7 @@ class AppMetaApi(Resource): 404: "Application not found", } ) + @service_api_ns.response(200, "Metadata retrieved successfully", service_api_ns.models[AppMetaResponse.__name__]) @validate_app_token def get(self, app_model: App): """Get app metadata. diff --git a/api/controllers/service_api/app/audio.py b/api/controllers/service_api/app/audio.py index e818573b8f4..0c2047a824e 100644 --- a/api/controllers/service_api/app/audio.py +++ b/api/controllers/service_api/app/audio.py @@ -6,7 +6,8 @@ from werkzeug.exceptions import InternalServerError import services from controllers.common.controller_schemas import TextToAudioPayload -from controllers.common.schema import register_schema_model +from controllers.common.fields import AudioBinaryResponse, AudioTranscriptResponse +from controllers.common.schema import register_response_schema_models, register_schema_model from controllers.service_api import service_api_ns from controllers.service_api.app.error import ( AppUnavailableError, @@ -33,6 +34,8 @@ from services.errors.audio import ( logger = logging.getLogger(__name__) +register_response_schema_models(service_api_ns, AudioBinaryResponse, AudioTranscriptResponse) + @service_api_ns.route("/audio-to-text") class AudioApi(Resource): @@ -48,6 +51,11 @@ class AudioApi(Resource): 500: "Internal server error", } ) + @service_api_ns.response( + 200, + "Audio successfully transcribed", + service_api_ns.models[AudioTranscriptResponse.__name__], + ) @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.FORM)) def post(self, app_model: App, end_user: EndUser): """Convert audio to text using speech-to-text. @@ -102,6 +110,11 @@ class TextApi(Resource): 500: "Internal server error", } ) + @service_api_ns.response( + 200, + "Text successfully converted to audio", + service_api_ns.models[AudioBinaryResponse.__name__], + ) @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON)) def post(self, app_model: App, end_user: EndUser): """Convert text to audio using text-to-speech. diff --git a/api/controllers/service_api/app/completion.py b/api/controllers/service_api/app/completion.py index c2294a3fc1c..7009bbfeaf6 100644 --- a/api/controllers/service_api/app/completion.py +++ b/api/controllers/service_api/app/completion.py @@ -8,7 +8,7 @@ from pydantic import BaseModel, Field, field_validator from werkzeug.exceptions import BadRequest, InternalServerError, NotFound import services -from controllers.common.fields import SimpleResultResponse +from controllers.common.fields import GeneratedAppResponse, 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 ( @@ -53,7 +53,7 @@ def _resolve_agent_app_streaming(*, app_mode: AppMode, response_mode: str | None class CompletionRequestPayload(BaseModel): inputs: dict[str, Any] query: str = Field(default="") - files: list[dict[str, Any]] | None = None + files: list[dict[str, Any]] | None = Field(default=None) response_mode: Literal["blocking", "streaming"] | None = None retriever_from: str = Field(default="dev") trace_session_id: str | None = Field(default=None, description="Trace session ID for observability grouping") @@ -62,7 +62,7 @@ class CompletionRequestPayload(BaseModel): class ChatRequestPayload(BaseModel): inputs: dict[str, Any] query: str - files: list[dict[str, Any]] | None = None + files: list[dict[str, Any]] | None = Field(default=None) response_mode: Literal["blocking", "streaming"] | None = None conversation_id: UUIDStrOrEmpty | None = Field(default=None, description="Conversation UUID") retriever_from: str = Field(default="dev") @@ -87,7 +87,7 @@ class ChatRequestPayload(BaseModel): register_schema_models(service_api_ns, CompletionRequestPayload, ChatRequestPayload) -register_response_schema_models(service_api_ns, SimpleResultResponse) +register_response_schema_models(service_api_ns, GeneratedAppResponse, SimpleResultResponse) @service_api_ns.route("/completion-messages") @@ -104,6 +104,11 @@ class CompletionApi(Resource): 500: "Internal server error", } ) + @service_api_ns.response( + 200, + "Completion created 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): """Create a completion for the given prompt. @@ -205,6 +210,11 @@ class ChatApi(Resource): 500: "Internal server error", } ) + @service_api_ns.response( + 200, + "Message sent 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): """Send a message in a chat conversation. diff --git a/api/controllers/service_api/app/conversation.py b/api/controllers/service_api/app/conversation.py index b298801ca02..f6be7f74cc7 100644 --- a/api/controllers/service_api/app/conversation.py +++ b/api/controllers/service_api/app/conversation.py @@ -10,7 +10,7 @@ from werkzeug.exceptions import BadRequest, NotFound import services from controllers.common.controller_schemas import ConversationRenamePayload -from controllers.common.schema import register_schema_models +from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.service_api import service_api_ns from controllers.service_api.app.error import NotChatAppError from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token @@ -134,11 +134,18 @@ register_schema_models( ConversationVariableResponse, ConversationVariableInfiniteScrollPaginationResponse, ) +register_response_schema_models( + service_api_ns, + ConversationInfiniteScrollPagination, + SimpleConversation, + ConversationVariableResponse, + ConversationVariableInfiniteScrollPaginationResponse, +) @service_api_ns.route("/conversations") class ConversationApi(Resource): - @service_api_ns.expect(service_api_ns.models[ConversationListQuery.__name__]) + @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( @@ -148,6 +155,11 @@ class ConversationApi(Resource): 404: "Last conversation not found", } ) + @service_api_ns.response( + 200, + "Conversations retrieved successfully", + service_api_ns.models[ConversationInfiniteScrollPagination.__name__], + ) @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY)) def get(self, app_model: App, end_user: EndUser): """List all conversations for the current user. @@ -224,6 +236,11 @@ class ConversationRenameApi(Resource): 404: "Conversation not found", } ) + @service_api_ns.response( + 200, + "Conversation renamed successfully", + service_api_ns.models[SimpleConversation.__name__], + ) @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON)) def post(self, app_model: App, end_user: EndUser, c_id: UUID): """Rename a conversation or auto-generate a name.""" @@ -250,7 +267,7 @@ class ConversationRenameApi(Resource): @service_api_ns.route("/conversations//variables") class ConversationVariablesApi(Resource): - @service_api_ns.expect(service_api_ns.models[ConversationVariablesQuery.__name__]) + @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"}) diff --git a/api/controllers/service_api/app/file_preview.py b/api/controllers/service_api/app/file_preview.py index 44f765d866d..7e68399fb0b 100644 --- a/api/controllers/service_api/app/file_preview.py +++ b/api/controllers/service_api/app/file_preview.py @@ -7,8 +7,9 @@ from flask_restx import Resource from pydantic import BaseModel, Field from sqlalchemy import select +from controllers.common.fields import BinaryFileResponse from controllers.common.file_response import enforce_download_for_html -from controllers.common.schema import register_schema_model +from controllers.common.schema import query_params_from_model, register_response_schema_model, register_schema_model from controllers.service_api import service_api_ns from controllers.service_api.app.error import ( FileAccessDeniedError, @@ -27,6 +28,7 @@ class FilePreviewQuery(BaseModel): register_schema_model(service_api_ns, FilePreviewQuery) +register_response_schema_model(service_api_ns, BinaryFileResponse) @service_api_ns.route("/files//preview") @@ -38,7 +40,7 @@ class FilePreviewApi(Resource): Files can only be accessed if they belong to messages within the requesting app's context. """ - @service_api_ns.expect(service_api_ns.models[FilePreviewQuery.__name__]) + @service_api_ns.doc(params=query_params_from_model(FilePreviewQuery)) @service_api_ns.doc("preview_file") @service_api_ns.doc(description="Preview or download a file uploaded via Service API") @service_api_ns.doc(params={"file_id": "UUID of the file to preview"}) @@ -50,6 +52,11 @@ class FilePreviewApi(Resource): 404: "File not found", } ) + @service_api_ns.response( + 200, + "File retrieved successfully", + service_api_ns.models[BinaryFileResponse.__name__], + ) @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY)) def get(self, app_model: App, end_user: EndUser, file_id: UUID): """ diff --git a/api/controllers/service_api/app/human_input_form.py b/api/controllers/service_api/app/human_input_form.py index 87cdca49883..1dc247d7517 100644 --- a/api/controllers/service_api/app/human_input_form.py +++ b/api/controllers/service_api/app/human_input_form.py @@ -8,17 +8,20 @@ paused human input forms in workflow/chatflow runs. import json import logging from collections.abc import Sequence +from typing import Any from flask import Response from flask_restx import Resource +from pydantic import ConfigDict, Field from werkzeug.exceptions import BadRequest, NotFound from controllers.common.human_input import HumanInputFormSubmitPayload, stringify_form_default_values -from controllers.common.schema import register_schema_models +from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.service_api import service_api_ns from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token from core.workflow.human_input_policy import HumanInputSurface, is_recipient_type_allowed_for_surface from extensions.ext_database import db +from fields.base import ResponseModel from graphon.nodes.human_input.entities import FormInputConfig from libs.helper import to_timestamp from models.model import App, EndUser @@ -27,7 +30,20 @@ from services.human_input_service import Form, FormNotFoundError, HumanInputServ logger = logging.getLogger(__name__) +class HumanInputFormDefinitionResponse(ResponseModel): + form_content: str + inputs: list[dict[str, Any]] = Field(default_factory=list) + resolved_default_values: dict[str, str] + user_actions: list[dict[str, Any]] = Field(default_factory=list) + expiration_time: int | None = None + + +class HumanInputFormSubmitResponse(ResponseModel): + model_config = ConfigDict(extra="forbid") + + register_schema_models(service_api_ns, HumanInputFormSubmitPayload) +register_response_schema_models(service_api_ns, HumanInputFormDefinitionResponse, HumanInputFormSubmitResponse) def _jsonify_form_definition(form: Form, *, inputs: Sequence[FormInputConfig] = ()) -> Response: @@ -67,6 +83,11 @@ class WorkflowHumanInputFormApi(Resource): 412: "Form already submitted or expired", } ) + @service_api_ns.response( + 200, + "Form retrieved successfully", + service_api_ns.models[HumanInputFormDefinitionResponse.__name__], + ) @validate_app_token def get(self, app_model: App, form_token: str): service = HumanInputService(db.engine) @@ -93,6 +114,11 @@ class WorkflowHumanInputFormApi(Resource): 412: "Form already submitted or expired", } ) + @service_api_ns.response( + 200, + "Form submitted successfully", + service_api_ns.models[HumanInputFormSubmitResponse.__name__], + ) @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON, required=True)) def post(self, app_model: App, end_user: EndUser, form_token: str): payload = HumanInputFormSubmitPayload.model_validate(service_api_ns.payload or {}) diff --git a/api/controllers/service_api/app/message.py b/api/controllers/service_api/app/message.py index a77c4fb6608..adbea6570dd 100644 --- a/api/controllers/service_api/app/message.py +++ b/api/controllers/service_api/app/message.py @@ -9,11 +9,12 @@ from werkzeug.exceptions import BadRequest, InternalServerError, NotFound import services from controllers.common.controller_schemas import MessageFeedbackPayload, MessageListQuery from controllers.common.fields import SimpleResultStringListResponse -from controllers.common.schema import 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.service_api import service_api_ns from controllers.service_api.app.error import NotChatAppError from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token from core.app.entities.app_invoke_entities import InvokeFrom +from fields.base import ResponseModel from fields.conversation_fields import ResultResponse from fields.message_fields import MessageInfiniteScrollPagination, MessageListItem from models.enums import FeedbackRating @@ -33,13 +34,37 @@ class FeedbackListQuery(BaseModel): limit: int = Field(default=20, ge=1, le=101, description="Number of feedbacks per page") +class AppFeedbackResponse(ResponseModel): + id: str + app_id: str + conversation_id: str + message_id: str + rating: str + content: str | None = None + from_source: str + from_end_user_id: str | None = None + from_account_id: str | None = None + created_at: str + updated_at: str + + +class AppFeedbackListResponse(ResponseModel): + data: list[AppFeedbackResponse] + + register_schema_models(service_api_ns, MessageListQuery, MessageFeedbackPayload, FeedbackListQuery) -register_response_schema_models(service_api_ns, ResultResponse, SimpleResultStringListResponse) +register_response_schema_models( + service_api_ns, + ResultResponse, + SimpleResultStringListResponse, + MessageInfiniteScrollPagination, + AppFeedbackListResponse, +) @service_api_ns.route("/messages") class MessageListApi(Resource): - @service_api_ns.expect(service_api_ns.models[MessageListQuery.__name__]) + @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( @@ -49,6 +74,11 @@ class MessageListApi(Resource): 404: "Conversation or first message not found", } ) + @service_api_ns.response( + 200, + "Messages retrieved successfully", + service_api_ns.models[MessageInfiniteScrollPagination.__name__], + ) @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY)) def get(self, app_model: App, end_user: EndUser): """List messages in a conversation. @@ -120,7 +150,7 @@ class MessageFeedbackApi(Resource): @service_api_ns.route("/app/feedbacks") class AppGetFeedbacksApi(Resource): - @service_api_ns.expect(service_api_ns.models[FeedbackListQuery.__name__]) + @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( @@ -129,6 +159,11 @@ class AppGetFeedbacksApi(Resource): 401: "Unauthorized - invalid API token", } ) + @service_api_ns.response( + 200, + "Feedbacks retrieved successfully", + service_api_ns.models[AppFeedbackListResponse.__name__], + ) @validate_app_token def get(self, app_model: App): """Get all feedbacks for the application. diff --git a/api/controllers/service_api/app/workflow.py b/api/controllers/service_api/app/workflow.py index 975fdf0cd92..b655e0beb4a 100644 --- a/api/controllers/service_api/app/workflow.py +++ b/api/controllers/service_api/app/workflow.py @@ -11,8 +11,8 @@ 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 SimpleResultResponse -from controllers.common.schema import register_response_schema_models, register_schema_models +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.service_api import service_api_ns from controllers.service_api.app.error import ( CompletionRequestError, @@ -69,7 +69,7 @@ class WorkflowLogQuery(BaseModel): register_schema_models(service_api_ns, WorkflowRunPayload, WorkflowLogQuery) -register_response_schema_models(service_api_ns, SimpleResultResponse) +register_response_schema_models(service_api_ns, GeneratedAppResponse, SimpleResultResponse) def _enum_value(value): @@ -97,7 +97,7 @@ class WorkflowRunResponse(ResponseModel): id: str workflow_id: str status: str - inputs: dict | list | str | int | float | bool | None = None + inputs: dict | list | str | int | float | bool | None = Field(default=None) outputs: dict = Field(default_factory=dict) error: str | None = None total_steps: int | None = None @@ -139,7 +139,7 @@ class WorkflowRunForLogResponse(ResponseModel): class WorkflowAppLogPartialResponse(ResponseModel): id: str workflow_run: WorkflowRunForLogResponse | None = None - details: dict | list | str | int | float | bool | None = None + 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 @@ -165,7 +165,7 @@ class WorkflowAppLogPaginationResponse(ResponseModel): data: list[WorkflowAppLogPartialResponse] -register_schema_models( +register_response_schema_models( service_api_ns, WorkflowRunResponse, WorkflowRunForLogResponse, @@ -262,6 +262,11 @@ class WorkflowRunApi(Resource): 500: "Internal server error", } ) + @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): """Execute a workflow. @@ -322,6 +327,11 @@ class WorkflowRunByIdApi(Resource): 500: "Internal server error", } ) + @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): """Run specific workflow by ID. @@ -407,7 +417,7 @@ class WorkflowTaskStopApi(Resource): @service_api_ns.route("/workflows/logs") class WorkflowAppLogApi(Resource): - @service_api_ns.expect(service_api_ns.models[WorkflowLogQuery.__name__]) + @service_api_ns.doc(params=query_params_from_model(WorkflowLogQuery)) @service_api_ns.doc("get_workflow_logs") @service_api_ns.doc(description="Get workflow execution logs") @service_api_ns.doc( diff --git a/api/controllers/service_api/app/workflow_events.py b/api/controllers/service_api/app/workflow_events.py index b281b271c00..6dc9ef6e8dd 100644 --- a/api/controllers/service_api/app/workflow_events.py +++ b/api/controllers/service_api/app/workflow_events.py @@ -7,9 +7,12 @@ from collections.abc import Generator from flask import Response, request from flask_restx import Resource +from pydantic import BaseModel, Field from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import NotFound +from controllers.common.fields import EventStreamResponse +from controllers.common.schema import query_params_from_model, register_response_schema_model, register_schema_models from controllers.service_api import service_api_ns from controllers.service_api.app.error import NotWorkflowAppError from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token @@ -27,26 +30,24 @@ from repositories.factory import DifyAPIRepositoryFactory from services.workflow_event_snapshot_service import build_workflow_event_stream +class WorkflowEventsQuery(BaseModel): + user: str = Field(..., description="End user identifier") + include_state_snapshot: bool = Field(default=False, description="Replay from persisted state snapshot") + continue_on_pause: bool = Field(default=False, description="Keep the stream open across workflow_paused events") + + +register_schema_models(service_api_ns, WorkflowEventsQuery) +register_response_schema_model(service_api_ns, EventStreamResponse) + + @service_api_ns.route("/workflow//events") class WorkflowEventsApi(Resource): """Service API for getting workflow execution events after resume.""" @service_api_ns.doc("get_workflow_events") @service_api_ns.doc(description="Get workflow execution events stream after resume") - @service_api_ns.doc( - params={ - "task_id": "Workflow run ID", - "user": "End user identifier (query param)", - "include_state_snapshot": ( - "Whether to replay from persisted state snapshot, " - 'specify `"true"` to include a status snapshot of executed nodes' - ), - "continue_on_pause": ( - "Whether to keep the stream open across workflow_paused events," - 'specify `"true"` to keep the stream open for `workflow_paused` events.' - ), - } - ) + @service_api_ns.doc(params={"task_id": "Workflow run ID"}) + @service_api_ns.doc(params=query_params_from_model(WorkflowEventsQuery)) @service_api_ns.doc( responses={ 200: "SSE event stream", @@ -54,6 +55,7 @@ class WorkflowEventsApi(Resource): 404: "Workflow run not found", } ) + @service_api_ns.response(200, "SSE event stream", service_api_ns.models[EventStreamResponse.__name__]) @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY, required=True)) def get(self, app_model: App, end_user: EndUser, task_id: str): app_mode = AppMode.value_of(app_model.mode) diff --git a/api/controllers/service_api/dataset/dataset.py b/api/controllers/service_api/dataset/dataset.py index 4bcf9697014..c307063b3e5 100644 --- a/api/controllers/service_api/dataset/dataset.py +++ b/api/controllers/service_api/dataset/dataset.py @@ -22,6 +22,7 @@ from controllers.service_api.wraps import ( ) from core.plugin.impl.model_runtime_factory import create_plugin_provider_manager from core.rag.index_processor.constant.index_type import IndexTechniqueType +from extensions.ext_database import db from fields.base import ResponseModel from fields.dataset_fields import DatasetDetailResponse from graphon.model_runtime.entities.model_entities import ModelType @@ -57,7 +58,7 @@ class DatasetCreatePayload(BaseModel): retrieval_model: RetrievalModel | None = None embedding_model: str | None = None embedding_model_provider: str | None = None - summary_index_setting: dict | None = None + summary_index_setting: dict | None = Field(default=None) class DatasetUpdatePayload(BaseModel): @@ -69,11 +70,15 @@ class DatasetUpdatePayload(BaseModel): embedding_model_provider: str | None = None retrieval_model: RetrievalModel | None = None partial_member_list: list[dict[str, str]] | None = None - external_retrieval_model: dict[str, Any] | None = None + external_retrieval_model: dict[str, Any] | None = Field(default=None) external_knowledge_id: str | None = None external_knowledge_api_id: str | None = None +class DocumentStatusPayload(BaseModel): + document_ids: list[str] = Field(default_factory=list, description="Document IDs to update") + + class TagNamePayload(BaseModel): name: str = Field(..., min_length=1, max_length=50) @@ -175,6 +180,7 @@ register_schema_models( service_api_ns, DatasetCreatePayload, DatasetUpdatePayload, + DocumentStatusPayload, TagCreatePayload, TagUpdatePayload, TagDeletePayload, @@ -535,6 +541,7 @@ class DocumentStatusApi(DatasetApiResource): 400: "Bad request - invalid action", } ) + @service_api_ns.expect(service_api_ns.models[DocumentStatusPayload.__name__]) def patch(self, tenant_id, dataset_id: UUID, action: Literal["enable", "disable", "archive", "un_archive"]): """ Batch update document status. @@ -602,7 +609,7 @@ class DatasetTagsApi(DatasetApiResource): assert isinstance(current_user, Account) cid = current_user.current_tenant_id assert cid is not None - tags = TagService.get_tags("knowledge", cid) + tags = TagService.get_tags(db.session(), "knowledge", cid) return dump_response(KnowledgeTagListResponse, tags), 200 @service_api_ns.expect(service_api_ns.models[TagCreatePayload.__name__]) diff --git a/api/controllers/service_api/dataset/document.py b/api/controllers/service_api/dataset/document.py index d1b81e8162c..c71feb1aa7b 100644 --- a/api/controllers/service_api/dataset/document.py +++ b/api/controllers/service_api/dataset/document.py @@ -8,7 +8,7 @@ deprecated in generated API docs so clients migrate toward the canonical paths. import json from collections.abc import Mapping from contextlib import ExitStack -from typing import Self +from typing import Any, Literal, Self from uuid import UUID from flask import request, send_file @@ -25,7 +25,7 @@ from controllers.common.errors import ( TooManyFilesError, UnsupportedFileTypeError, ) -from controllers.common.fields import UrlResponse +from controllers.common.fields import BinaryFileResponse, UrlResponse from controllers.common.schema import ( query_params_from_model, register_enum_models, @@ -51,6 +51,7 @@ from extensions.ext_database import db from fields.base import ResponseModel from fields.document_fields import ( DocumentListResponse, + DocumentMetadataResponse, DocumentResponse, DocumentStatusListResponse, ) @@ -117,6 +118,10 @@ class DocumentListQuery(BaseModel): status: str | None = Field(default=None, description="Document status filter") +class DocumentGetQuery(BaseModel): + metadata: Literal["all", "only", "without"] = Field(default="all", description="Metadata response mode") + + DOCUMENT_CREATE_BY_FILE_PARAMS = { "dataset_id": "Dataset ID", "file": { @@ -155,6 +160,40 @@ class DocumentAndBatchResponse(ResponseModel): batch: str +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) + 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 + 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 + summary_index_status: str | None = None + need_summary: bool | None = None + + register_enum_models(service_api_ns, RetrievalMethod) register_schema_models( @@ -164,6 +203,7 @@ register_schema_models( DocumentTextCreatePayload, DocumentTextUpdate, DocumentListQuery, + DocumentGetQuery, DocumentBatchDownloadZipPayload, Rule, PreProcessingRule, @@ -171,9 +211,11 @@ register_schema_models( ) register_response_schema_models( service_api_ns, + BinaryFileResponse, UrlResponse, DocumentResponse, DocumentAndBatchResponse, + DocumentDetailResponse, DocumentListResponse, DocumentStatusListResponse, ) @@ -716,6 +758,11 @@ class DocumentBatchDownloadZipApi(DatasetApiResource): 404: "Document or dataset not found", } ) + @service_api_ns.response( + 200, + "ZIP archive generated successfully", + service_api_ns.models[BinaryFileResponse.__name__], + ) @cloud_edition_billing_rate_limit_check("knowledge", "dataset") def post(self, tenant_id, dataset_id: UUID): payload = DocumentBatchDownloadZipPayload.model_validate(service_api_ns.payload or {}) @@ -851,6 +898,7 @@ 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": "Dataset ID", "document_id": "Document ID"}) + @service_api_ns.doc(params=query_params_from_model(DocumentGetQuery)) @service_api_ns.doc( responses={ 200: "Document retrieved successfully", @@ -859,6 +907,11 @@ class DocumentApi(DatasetApiResource): 404: "Document not found", } ) + @service_api_ns.response( + 200, + "Document retrieved successfully", + service_api_ns.models[DocumentDetailResponse.__name__], + ) def get(self, tenant_id, dataset_id: UUID, document_id: UUID): dataset_id_str = str(dataset_id) document_id_str = str(document_id) 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 19b1d008b7e..a1a8b588c42 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 @@ -3,19 +3,26 @@ from typing import Any from uuid import UUID from flask import request -from pydantic import BaseModel +from pydantic import BaseModel, Field, RootModel from sqlalchemy import select from werkzeug.exceptions import Forbidden, NotFound import services from controllers.common.errors import FilenameNotExistsError, NoFileUploadedError, TooManyFilesError -from controllers.common.schema import register_schema_model +from controllers.common.fields import GeneratedAppResponse +from controllers.common.schema import ( + query_params_from_model, + 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.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.login import current_user from models import Account @@ -38,8 +45,50 @@ class DatasourceNodeRunPayload(BaseModel): is_published: bool +class DatasourcePluginsQuery(BaseModel): + is_published: bool = True + + +class DatasourceCredentialInfoResponse(ResponseModel): + id: str | None = None + name: str | None = None + type: str | None = None + is_default: bool | None = None + + +class DatasourcePluginResponse(ResponseModel): + node_id: str | None = None + plugin_id: str | None = None + provider_name: str | None = None + datasource_type: str | None = None + title: str | None = None + user_input_variables: list[dict[str, Any]] = Field(default_factory=list) + credentials: list[DatasourceCredentialInfoResponse] + + +class DatasourcePluginListResponse(RootModel[list[DatasourcePluginResponse]]): + pass + + +class PipelineUploadFileResponse(ResponseModel): + id: str + name: str + size: int + extension: str + mime_type: str | None = None + created_by: str + created_at: str | None = None + + register_schema_model(service_api_ns, DatasourceNodeRunPayload) register_schema_model(service_api_ns, PipelineRunApiEntity) +register_schema_models(service_api_ns, DatasourcePluginsQuery) +register_response_schema_models( + service_api_ns, + DatasourcePluginListResponse, + GeneratedAppResponse, + PipelineUploadFileResponse, +) @service_api_ns.route("/datasets//pipeline/datasource-plugins") @@ -53,18 +102,18 @@ class DatasourcePluginsApi(DatasetApiResource): "dataset_id": "Dataset ID", } ) - @service_api_ns.doc( - params={ - "is_published": "Whether to get published or draft datasource plugins " - "(true for published, false for draft, default: true)" - } - ) + @service_api_ns.doc(params=query_params_from_model(DatasourcePluginsQuery)) @service_api_ns.doc( responses={ 200: "Datasource plugins retrieved successfully", 401: "Unauthorized - invalid API token", } ) + @service_api_ns.response( + 200, + "Datasource plugins retrieved successfully", + service_api_ns.models[DatasourcePluginListResponse.__name__], + ) def get(self, tenant_id: str, dataset_id: UUID): """Resource for getting datasource plugins.""" dataset_id_str = str(dataset_id) @@ -95,15 +144,6 @@ class DatasourceNodeRunApi(DatasetApiResource): "dataset_id": "Dataset ID", } ) - @service_api_ns.doc( - body={ - "inputs": "User input variables", - "datasource_type": "Datasource type, e.g. online_document", - "credential_id": "Credential ID", - "is_published": "Whether to get published or draft datasource plugins " - "(true for published, false for draft, default: true)", - } - ) @service_api_ns.doc( responses={ 200: "Datasource node run successfully", @@ -111,6 +151,11 @@ 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) @@ -157,17 +202,6 @@ class PipelineRunApi(DatasetApiResource): "dataset_id": "Dataset ID", } ) - @service_api_ns.doc( - body={ - "inputs": "User input variables", - "datasource_type": "Datasource type, e.g. online_document", - "datasource_info_list": "Datasource info list", - "start_node_id": "Start node ID", - "is_published": "Whether to get published or draft datasource plugins " - "(true for published, false for draft, default: true)", - "streaming": "Whether to stream the response(streaming or blocking), default: streaming", - } - ) @service_api_ns.doc( responses={ 200: "Pipeline run successfully", @@ -175,6 +209,11 @@ 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) @@ -220,6 +259,11 @@ class KnowledgebasePipelineFileUploadApi(DatasetApiResource): 415: "Unsupported file type", } ) + @service_api_ns.response( + 201, + "File uploaded successfully", + service_api_ns.models[PipelineUploadFileResponse.__name__], + ) def post(self, tenant_id: str): """Upload a file for use in conversations. diff --git a/api/controllers/service_api/workspace/models.py b/api/controllers/service_api/workspace/models.py index 5ac65fc4e6d..63806ab252f 100644 --- a/api/controllers/service_api/workspace/models.py +++ b/api/controllers/service_api/workspace/models.py @@ -1,12 +1,22 @@ from flask_login import current_user from flask_restx import Resource +from controllers.common.schema import register_response_schema_models from controllers.service_api import service_api_ns from controllers.service_api.wraps import validate_dataset_token +from fields.base import ResponseModel from graphon.model_runtime.utils.encoders import jsonable_encoder +from services.entities.model_provider_entities import ProviderWithModelsResponse from services.model_provider_service import ModelProviderService +class ProviderWithModelsListResponse(ResponseModel): + data: list[ProviderWithModelsResponse] + + +register_response_schema_models(service_api_ns, ProviderWithModelsListResponse) + + @service_api_ns.route("/workspaces/current/models/model-types/") class ModelProviderAvailableModelApi(Resource): @service_api_ns.doc("get_available_models") @@ -18,6 +28,11 @@ class ModelProviderAvailableModelApi(Resource): 401: "Unauthorized - invalid API token", } ) + @service_api_ns.response( + 200, + "Models retrieved successfully", + service_api_ns.models[ProviderWithModelsListResponse.__name__], + ) @validate_dataset_token def get(self, _, model_type: str): """Get available models by model type. diff --git a/api/controllers/web/app.py b/api/controllers/web/app.py index 6d05743bfee..d5722faf00d 100644 --- a/api/controllers/web/app.py +++ b/api/controllers/web/app.py @@ -8,7 +8,7 @@ from werkzeug.exceptions import Unauthorized from constants import HEADER_NAME_APP_CODE from controllers.common import fields -from controllers.common.schema import register_response_schema_models, register_schema_models +from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict from libs.passport import PassportService from libs.token import extract_webapp_passport @@ -32,9 +32,24 @@ class AppAccessModeQuery(BaseModel): app_code: str | None = Field(default=None, alias="appCode", description="Application code") -register_schema_models(web_ns, AppAccessModeQuery) +class AppPermissionQuery(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + app_id: str = Field(..., alias="appId", description="Application ID") + + +class AppMetaResponse(BaseModel): + tool_icons: dict[str, Any] = Field( + default_factory=dict, + description="Tool icon metadata keyed by tool name", + ) + + +register_schema_models(web_ns, AppAccessModeQuery, AppPermissionQuery) register_response_schema_models( web_ns, + fields.Parameters, + AppMetaResponse, fields.AccessModeResponse, fields.BooleanResultResponse, ) @@ -56,6 +71,7 @@ class AppParameterApi(WebApiResource): 500: "Internal Server Error", } ) + @web_ns.response(200, "Success", web_ns.models[fields.Parameters.__name__]) def get(self, app_model: App, end_user: EndUser): """Retrieve app parameters.""" if app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}: @@ -92,6 +108,7 @@ class AppMeta(WebApiResource): 500: "Internal Server Error", } ) + @web_ns.response(200, "Success", web_ns.models[AppMetaResponse.__name__]) def get(self, app_model: App, end_user: EndUser): """Get app meta""" return AppService().get_app_meta(app_model) @@ -101,12 +118,7 @@ class AppMeta(WebApiResource): class AppAccessMode(Resource): @web_ns.doc("Get App Access Mode") @web_ns.doc(description="Retrieve the access mode for a web application (public or restricted).") - @web_ns.doc( - params={ - "appId": {"description": "Application ID", "type": "string", "required": False}, - "appCode": {"description": "Application code", "type": "string", "required": False}, - } - ) + @web_ns.doc(params=query_params_from_model(AppAccessModeQuery)) @web_ns.doc( responses={ 200: "Success", @@ -139,7 +151,7 @@ class AppAccessMode(Resource): class AppWebAuthPermission(Resource): @web_ns.doc("Check App Permission") @web_ns.doc(description="Check if user has permission to access a web application.") - @web_ns.doc(params={"appId": {"description": "Application ID", "type": "string", "required": True}}) + @web_ns.doc(params=query_params_from_model(AppPermissionQuery)) @web_ns.doc( responses={ 200: "Success", diff --git a/api/controllers/web/audio.py b/api/controllers/web/audio.py index 8ddbc3abb8e..c762c914861 100644 --- a/api/controllers/web/audio.py +++ b/api/controllers/web/audio.py @@ -7,6 +7,7 @@ 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, @@ -32,7 +33,7 @@ from services.errors.audio import ( UnsupportedAudioTypeServiceError, ) -from ..common.schema import register_schema_models +from ..common.schema import register_response_schema_models, register_schema_models class TextToAudioPayload(TextToAudioPayloadBase): @@ -45,6 +46,7 @@ class TextToAudioPayload(TextToAudioPayloadBase): register_schema_models(web_ns, TextToAudioPayload) +register_response_schema_models(web_ns, AudioBinaryResponse, AudioTranscriptResponse) logger = logging.getLogger(__name__) @@ -69,6 +71,7 @@ class AudioApi(WebApiResource): 500: "Internal Server Error", } ) + @web_ns.response(200, "Success", web_ns.models[AudioTranscriptResponse.__name__]) def post(self, app_model: App, end_user: EndUser): """Convert audio to text""" file = request.files["file"] @@ -117,6 +120,7 @@ class TextApi(WebApiResource): 500: "Internal Server Error", } ) + @web_ns.response(200, "Success", web_ns.models[AudioBinaryResponse.__name__]) def post(self, app_model: App, end_user: EndUser): """Convert text to audio""" try: diff --git a/api/controllers/web/completion.py b/api/controllers/web/completion.py index d4c02b65921..2bb7db015ad 100644 --- a/api/controllers/web/completion.py +++ b/api/controllers/web/completion.py @@ -5,7 +5,7 @@ from pydantic import BaseModel, Field, field_validator from werkzeug.exceptions import BadRequest, InternalServerError, NotFound import services -from controllers.common.fields import SimpleResultResponse +from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.web import web_ns from controllers.web.error import ( @@ -47,9 +47,14 @@ def _resolve_agent_app_streaming(*, app_mode: AppMode, response_mode: str | None class CompletionMessagePayload(BaseModel): - inputs: dict[str, Any] = Field(description="Input variables for the completion") + inputs: dict[str, Any] = Field( + description="Input variables for the completion", + ) query: str = Field(default="", description="Query text for completion") - files: list[dict[str, Any]] | None = Field(default=None, description="Files to be processed") + files: list[dict[str, Any]] | None = Field( + default=None, + description="Files to be processed", + ) response_mode: Literal["blocking", "streaming"] | None = Field( default=None, description="Response mode: blocking or streaming" ) @@ -57,9 +62,14 @@ class CompletionMessagePayload(BaseModel): class ChatMessagePayload(BaseModel): - inputs: dict[str, Any] = Field(description="Input variables for the chat") + inputs: dict[str, Any] = Field( + description="Input variables for the chat", + ) query: str = Field(description="User query/message") - files: list[dict[str, Any]] | None = Field(default=None, description="Files to be processed") + files: list[dict[str, Any]] | None = Field( + default=None, + description="Files to be processed", + ) response_mode: Literal["blocking", "streaming"] | None = Field( default=None, description="Response mode: blocking or streaming" ) @@ -76,7 +86,7 @@ class ChatMessagePayload(BaseModel): register_schema_models(web_ns, CompletionMessagePayload, ChatMessagePayload) -register_response_schema_models(web_ns, SimpleResultResponse) +register_response_schema_models(web_ns, GeneratedAppResponse, SimpleResultResponse) # define completion api for user @@ -95,6 +105,7 @@ class CompletionApi(WebApiResource): 500: "Internal Server Error", } ) + @web_ns.response(200, "Success", web_ns.models[GeneratedAppResponse.__name__]) def post(self, app_model: App, end_user: EndUser): if app_model.mode != AppMode.COMPLETION: raise NotCompletionAppError() @@ -178,6 +189,7 @@ class ChatApi(WebApiResource): 500: "Internal Server Error", } ) + @web_ns.response(200, "Success", web_ns.models[GeneratedAppResponse.__name__]) 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}: diff --git a/api/controllers/web/conversation.py b/api/controllers/web/conversation.py index fd85922207f..73461b1a294 100644 --- a/api/controllers/web/conversation.py +++ b/api/controllers/web/conversation.py @@ -7,7 +7,7 @@ from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import NotFound from controllers.common.controller_schemas import ConversationRenamePayload -from controllers.common.schema import 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.web import web_ns from controllers.web.error import NotChatAppError from controllers.web.wraps import WebApiResource @@ -40,37 +40,14 @@ class ConversationListQuery(BaseModel): register_schema_models(web_ns, ConversationListQuery, ConversationRenamePayload) -register_response_schema_models(web_ns, ResultResponse) +register_response_schema_models(web_ns, ConversationInfiniteScrollPagination, ResultResponse, SimpleConversation) @web_ns.route("/conversations") class ConversationListApi(WebApiResource): @web_ns.doc("Get Conversation List") @web_ns.doc(description="Retrieve paginated list of conversations for a chat application.") - @web_ns.doc( - params={ - "last_id": {"description": "Last conversation ID for pagination", "type": "string", "required": False}, - "limit": { - "description": "Number of conversations to return (1-100)", - "type": "integer", - "required": False, - "default": 20, - }, - "pinned": { - "description": "Filter by pinned status", - "type": "string", - "enum": ["true", "false"], - "required": False, - }, - "sort_by": { - "description": "Sort order", - "type": "string", - "enum": ["created_at", "-created_at", "updated_at", "-updated_at"], - "required": False, - "default": "-updated_at", - }, - } - ) + @web_ns.doc(params=query_params_from_model(ConversationListQuery)) @web_ns.doc( responses={ 200: "Success", @@ -81,6 +58,7 @@ class ConversationListApi(WebApiResource): 500: "Internal Server Error", } ) + @web_ns.response(200, "Success", web_ns.models[ConversationInfiniteScrollPagination.__name__]) def get(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}: @@ -166,6 +144,8 @@ class ConversationRenameApi(WebApiResource): 500: "Internal Server Error", } ) + @web_ns.response(200, "Conversation renamed successfully", web_ns.models[SimpleConversation.__name__]) + @web_ns.expect(web_ns.models[ConversationRenamePayload.__name__]) def post(self, app_model: App, end_user: EndUser, c_id: UUID): app_mode = AppMode.value_of(app_model.mode) if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}: diff --git a/api/controllers/web/human_input_form.py b/api/controllers/web/human_input_form.py index 3065d57b5dd..14b982dd23b 100644 --- a/api/controllers/web/human_input_form.py +++ b/api/controllers/web/human_input_form.py @@ -9,7 +9,7 @@ from typing import Any, NotRequired, TypedDict from flask import Response, request from flask_restx import Resource -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict, Field from sqlalchemy import select from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import Forbidden @@ -17,7 +17,7 @@ from werkzeug.exceptions import Forbidden from configs import dify_config from controllers.common.errors import NotFoundError from controllers.common.human_input import HumanInputFormSubmitPayload, stringify_form_default_values -from controllers.common.schema import register_schema_models +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 @@ -38,7 +38,26 @@ class HumanInputUploadTokenResponse(BaseModel): expires_at: int -register_schema_models(web_ns, HumanInputUploadTokenResponse) +class HumanInputFormDefinitionResponse(BaseModel): + form_content: Any + inputs: Any + resolved_default_values: dict[str, str] + user_actions: Any + expiration_time: int + site: dict[str, Any] | None = Field(default=None) + + +class HumanInputFormSubmitResponse(BaseModel): + model_config = ConfigDict(extra="forbid") + + +register_schema_models(web_ns, HumanInputFormSubmitPayload) +register_response_schema_models( + web_ns, + HumanInputUploadTokenResponse, + HumanInputFormDefinitionResponse, + HumanInputFormSubmitResponse, +) _FORM_SUBMIT_RATE_LIMITER = RateLimiter( @@ -100,6 +119,7 @@ def _jsonify_form_definition( class HumanInputFormUploadTokenApi(Resource): """API for issuing HITL upload tokens for active human input forms.""" + @web_ns.response(200, "Success", web_ns.models[HumanInputUploadTokenResponse.__name__]) def post(self, form_token: str): """ Issue an upload token for a human input form. @@ -130,6 +150,7 @@ 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__]) def get(self, form_token: str): """ Get human input form definition by token. @@ -160,6 +181,8 @@ class HumanInputFormApi(Resource): ) # 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__]) def post(self, form_token: str): """ Submit human input form by token. diff --git a/api/controllers/web/login.py b/api/controllers/web/login.py index 2b9f8eac0f7..2d8c38f5507 100644 --- a/api/controllers/web/login.py +++ b/api/controllers/web/login.py @@ -14,7 +14,7 @@ from controllers.common.fields import ( SimpleResultDataResponse, SimpleResultResponse, ) -from controllers.common.schema import 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.auth.error import ( AuthenticationFailedError, EmailCodeError, @@ -62,7 +62,12 @@ class EmailCodeLoginVerifyPayload(BaseModel): token: str = Field(min_length=1) -register_schema_models(web_ns, LoginPayload, EmailCodeLoginSendPayload, EmailCodeLoginVerifyPayload) +class LoginStatusQuery(BaseModel): + app_code: str | None = Field(default=None, description="Web app code") + user_id: str | None = Field(default=None, description="End user session ID") + + +register_schema_models(web_ns, LoginPayload, EmailCodeLoginSendPayload, EmailCodeLoginVerifyPayload, LoginStatusQuery) register_response_schema_models( web_ns, AccessTokenResultResponse, @@ -122,6 +127,7 @@ class LoginStatusApi(Resource): @setup_required @web_ns.doc("web_app_login_status") @web_ns.doc(description="Check login status") + @web_ns.doc(params=query_params_from_model(LoginStatusQuery)) @web_ns.doc( responses={ 200: "Login status", diff --git a/api/controllers/web/message.py b/api/controllers/web/message.py index e40e57c4367..65ef02471a9 100644 --- a/api/controllers/web/message.py +++ b/api/controllers/web/message.py @@ -7,7 +7,8 @@ from pydantic import BaseModel, Field, TypeAdapter from werkzeug.exceptions import InternalServerError, NotFound from controllers.common.controller_schemas import MessageFeedbackPayload, MessageListQuery -from controllers.common.schema import register_response_schema_models, register_schema_models +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 ( AppMoreLikeThisDisabledError, @@ -48,29 +49,20 @@ class MessageMoreLikeThisQuery(BaseModel): register_schema_models(web_ns, MessageListQuery, MessageFeedbackPayload, MessageMoreLikeThisQuery) -register_response_schema_models(web_ns, ResultResponse, SuggestedQuestionsResponse) +register_response_schema_models( + web_ns, + GeneratedAppResponse, + ResultResponse, + SuggestedQuestionsResponse, + WebMessageInfiniteScrollPagination, +) @web_ns.route("/messages") class MessageListApi(WebApiResource): @web_ns.doc("Get Message List") @web_ns.doc(description="Retrieve paginated list of messages from a conversation in a chat application.") - @web_ns.doc( - params={ - "conversation_id": {"description": "Conversation UUID", "type": "string", "required": True}, - "first_id": { - "description": "First message ID for pagination", - "type": "string", - "required": False, - }, - "limit": { - "description": "Number of messages to return (1-100)", - "type": "integer", - "required": False, - "default": 20, - }, - } - ) + @web_ns.doc(params=query_params_from_model(MessageListQuery)) @web_ns.doc( responses={ 200: "Success", @@ -81,6 +73,7 @@ class MessageListApi(WebApiResource): 500: "Internal Server Error", } ) + @web_ns.response(200, "Success", web_ns.models[WebMessageInfiniteScrollPagination.__name__]) def get(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}: @@ -133,6 +126,7 @@ class MessageFeedbackApi(WebApiResource): } ) @web_ns.response(200, "Feedback submitted successfully", web_ns.models[ResultResponse.__name__]) + @web_ns.expect(web_ns.models[MessageFeedbackPayload.__name__]) def post(self, app_model: App, end_user: EndUser, message_id: UUID): message_id_str = str(message_id) @@ -156,7 +150,7 @@ class MessageFeedbackApi(WebApiResource): class MessageMoreLikeThisApi(WebApiResource): @web_ns.doc("Generate More Like This") @web_ns.doc(description="Generate a new completion similar to an existing message (completion apps only).") - @web_ns.expect(web_ns.models[MessageMoreLikeThisQuery.__name__]) + @web_ns.doc(params=query_params_from_model(MessageMoreLikeThisQuery)) @web_ns.doc( responses={ 200: "Success", @@ -167,6 +161,7 @@ class MessageMoreLikeThisApi(WebApiResource): 500: "Internal Server Error", } ) + @web_ns.response(200, "Success", web_ns.models[GeneratedAppResponse.__name__]) def get(self, app_model: App, end_user: EndUser, message_id: UUID): if app_model.mode != "completion": raise NotCompletionAppError() diff --git a/api/controllers/web/passport.py b/api/controllers/web/passport.py index 0293df74b08..00439ffca4e 100644 --- a/api/controllers/web/passport.py +++ b/api/controllers/web/passport.py @@ -4,11 +4,14 @@ from typing import Any from flask import make_response, request from flask_restx import Resource +from pydantic import BaseModel, Field from sqlalchemy import func, select from werkzeug.exceptions import NotFound, Unauthorized 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 @@ -19,12 +22,21 @@ from services.feature_service import FeatureService from services.webapp_auth_service import WebAppAuthService, WebAppAuthType +class PassportQuery(BaseModel): + user_id: str | None = Field(default=None, description="End user session ID") + + +register_schema_models(web_ns, PassportQuery) +register_response_schema_models(web_ns, AccessTokenData) + + @web_ns.route("/passport") class PassportResource(Resource): """Base resource for passport.""" @web_ns.doc("get_passport") @web_ns.doc(description="Get authentication passport for web application access") + @web_ns.doc(params=query_params_from_model(PassportQuery)) @web_ns.doc( responses={ 200: "Passport retrieved successfully", @@ -32,6 +44,7 @@ class PassportResource(Resource): 404: "Application or user not found", } ) + @web_ns.response(200, "Passport retrieved successfully", web_ns.models[AccessTokenData.__name__]) def get(self): system_features = FeatureService.get_system_features() app_code = request.headers.get(HEADER_NAME_APP_CODE) diff --git a/api/controllers/web/remote_files.py b/api/controllers/web/remote_files.py index 659d9aa6632..1772300b5cd 100644 --- a/api/controllers/web/remote_files.py +++ b/api/controllers/web/remote_files.py @@ -86,6 +86,7 @@ class RemoteFileUploadApi(WebApiResource): } ) @web_ns.response(201, "Remote file uploaded", web_ns.models[FileWithSignedUrl.__name__]) + @web_ns.expect(web_ns.models[RemoteFileUploadPayload.__name__]) def post(self, app_model: App, end_user: EndUser): """Upload a file from a remote URL. diff --git a/api/controllers/web/saved_message.py b/api/controllers/web/saved_message.py index 7ce72e56ab9..e3baa028e50 100644 --- a/api/controllers/web/saved_message.py +++ b/api/controllers/web/saved_message.py @@ -5,7 +5,7 @@ from pydantic import TypeAdapter from werkzeug.exceptions import NotFound from controllers.common.controller_schemas import SavedMessageCreatePayload, SavedMessageListQuery -from controllers.common.schema import 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.web import web_ns from controllers.web.error import NotCompletionAppError from controllers.web.wraps import WebApiResource @@ -16,24 +16,14 @@ from services.errors.message import MessageNotExistsError from services.saved_message_service import SavedMessageService register_schema_models(web_ns, SavedMessageListQuery, SavedMessageCreatePayload) -register_response_schema_models(web_ns, ResultResponse) +register_response_schema_models(web_ns, ResultResponse, SavedMessageInfiniteScrollPagination) @web_ns.route("/saved-messages") class SavedMessageListApi(WebApiResource): @web_ns.doc("Get Saved Messages") @web_ns.doc(description="Retrieve paginated list of saved messages for a completion application.") - @web_ns.doc( - params={ - "last_id": {"description": "Last message ID for pagination", "type": "string", "required": False}, - "limit": { - "description": "Number of messages to return (1-100)", - "type": "integer", - "required": False, - "default": 20, - }, - } - ) + @web_ns.doc(params=query_params_from_model(SavedMessageListQuery)) @web_ns.doc( responses={ 200: "Success", @@ -44,6 +34,7 @@ class SavedMessageListApi(WebApiResource): 500: "Internal Server Error", } ) + @web_ns.response(200, "Success", web_ns.models[SavedMessageInfiniteScrollPagination.__name__]) def get(self, app_model: App, end_user: EndUser): if app_model.mode != "completion": raise NotCompletionAppError() @@ -78,6 +69,7 @@ class SavedMessageListApi(WebApiResource): } ) @web_ns.response(200, "Message saved successfully", web_ns.models[ResultResponse.__name__]) + @web_ns.expect(web_ns.models[SavedMessageCreatePayload.__name__]) def post(self, app_model: App, end_user: EndUser): if app_model.mode != "completion": raise NotCompletionAppError() diff --git a/api/controllers/web/site.py b/api/controllers/web/site.py index 19b04b7acc6..5e0f8326517 100644 --- a/api/controllers/web/site.py +++ b/api/controllers/web/site.py @@ -1,19 +1,64 @@ from typing import Any, cast from flask_restx import fields, marshal, marshal_with +from pydantic import Field from sqlalchemy import select from werkzeug.exceptions import Forbidden from configs import dify_config +from controllers.common.schema import register_response_schema_models from controllers.web import web_ns 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 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 + chat_color_theme: str | None = None + chat_color_theme_inverted: bool | None = None + 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 + custom_disclaimer: str | None = None + default_language: str | None = None + prompt_public: bool | None = None + show_workflow_steps: bool | None = None + use_icon_as_answer_icon: bool | None = None + + +class AppSiteInfoResponse(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 + can_replace_logo: bool + custom_config: dict[str, Any] | None = Field(default=None) + + +register_response_schema_models(web_ns, AppSiteInfoResponse) + + @web_ns.route("/site") class AppSiteApi(WebApiResource): """Resource for app sites.""" @@ -69,6 +114,7 @@ class AppSiteApi(WebApiResource): 500: "Internal Server Error", } ) + @web_ns.response(200, "Success", web_ns.models[AppSiteInfoResponse.__name__]) @marshal_with(app_fields) def get(self, app_model: App, end_user: EndUser): """Retrieve app site info.""" diff --git a/api/controllers/web/workflow.py b/api/controllers/web/workflow.py index 00b34349221..06d9c02fedc 100644 --- a/api/controllers/web/workflow.py +++ b/api/controllers/web/workflow.py @@ -3,7 +3,7 @@ import logging from werkzeug.exceptions import InternalServerError from controllers.common.controller_schemas import WorkflowRunPayload -from controllers.common.fields import SimpleResultResponse +from controllers.common.fields import GeneratedAppResponse, 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 @@ from services.errors.llm import InvokeRateLimitError logger = logging.getLogger(__name__) register_schema_models(web_ns, WorkflowRunPayload) -register_response_schema_models(web_ns, SimpleResultResponse) +register_response_schema_models(web_ns, GeneratedAppResponse, SimpleResultResponse) @web_ns.route("/workflows/run") @@ -51,6 +51,7 @@ class WorkflowRunApi(WebApiResource): 500: "Internal Server Error", } ) + @web_ns.response(200, "Success", web_ns.models[GeneratedAppResponse.__name__]) def post(self, app_model: App, end_user: EndUser): """ Run workflow diff --git a/api/controllers/web/workflow_events.py b/api/controllers/web/workflow_events.py index b513ade58a9..48eba33f04c 100644 --- a/api/controllers/web/workflow_events.py +++ b/api/controllers/web/workflow_events.py @@ -9,7 +9,9 @@ from flask import Response, request from sqlalchemy.orm import sessionmaker from controllers.common.errors import InvalidArgumentError, NotFoundError -from controllers.web import api +from controllers.common.fields import EventStreamResponse +from controllers.common.schema import register_response_schema_model +from controllers.web import api, web_ns from controllers.web.wraps import WebApiResource from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator from core.app.apps.base_app_generator import BaseAppGenerator @@ -22,10 +24,13 @@ from models.model import App, AppMode, EndUser from repositories.factory import DifyAPIRepositoryFactory from services.workflow_event_snapshot_service import build_workflow_event_stream +register_response_schema_model(web_ns, EventStreamResponse) + class WorkflowEventsApi(WebApiResource): """API for getting workflow execution events after resume.""" + @web_ns.response(200, "SSE event stream", web_ns.models[EventStreamResponse.__name__]) def get(self, app_model: App, end_user: EndUser, task_id: str): """ Get workflow execution events stream after resume. diff --git a/api/core/agent/cot_agent_runner.py b/api/core/agent/cot_agent_runner.py index 9b8bf566c15..9c9fa1092f6 100644 --- a/api/core/agent/cot_agent_runner.py +++ b/api/core/agent/cot_agent_runner.py @@ -208,12 +208,13 @@ class CotAgentRunner(BaseAgentRunner, ABC): if scratchpad.action.action_name.lower() == "final answer": # action is final answer, return final answer directly try: - if isinstance(scratchpad.action.action_input, dict): - final_answer = json.dumps(scratchpad.action.action_input, ensure_ascii=False) - elif isinstance(scratchpad.action.action_input, str): - final_answer = scratchpad.action.action_input - else: - final_answer = f"{scratchpad.action.action_input}" + match scratchpad.action.action_input: + case dict(): + final_answer = json.dumps(scratchpad.action.action_input, ensure_ascii=False) + case str(): + final_answer = scratchpad.action.action_input + case _: + final_answer = f"{scratchpad.action.action_input}" except TypeError: final_answer = f"{scratchpad.action.action_input}" else: diff --git a/api/core/agent/cot_completion_agent_runner.py b/api/core/agent/cot_completion_agent_runner.py index fd46dbc2fa5..72d7831eb73 100644 --- a/api/core/agent/cot_completion_agent_runner.py +++ b/api/core/agent/cot_completion_agent_runner.py @@ -43,13 +43,14 @@ class CotCompletionAgentRunner(CotAgentRunner): case UserPromptMessage(): historic_prompt += f"Question: {message.content}\n\n" case AssistantPromptMessage(): - if isinstance(message.content, str): - historic_prompt += message.content + "\n\n" - elif isinstance(message.content, list): - for content in message.content: - if not isinstance(content, TextPromptMessageContent): - continue - historic_prompt += content.data + match message.content: + case str(): + historic_prompt += message.content + "\n\n" + case list(): + for content in message.content: + if not isinstance(content, TextPromptMessageContent): + continue + historic_prompt += content.data return historic_prompt diff --git a/api/core/app/apps/advanced_chat/generate_task_pipeline.py b/api/core/app/apps/advanced_chat/generate_task_pipeline.py index 6f8117ee590..d8ca19b5fc3 100644 --- a/api/core/app/apps/advanced_chat/generate_task_pipeline.py +++ b/api/core/app/apps/advanced_chat/generate_task_pipeline.py @@ -1,6 +1,5 @@ import json import logging -import re import time from collections.abc import Callable, Generator, Mapping from contextlib import contextmanager @@ -1010,11 +1009,7 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport): if message.status == MessageStatus.PAUSED: message.status = MessageStatus.NORMAL - # If there are assistant files, remove markdown image links from answer answer_text = self._task_state.answer - if self._recorded_files: - # Remove markdown image links since we're storing files separately - answer_text = re.sub(r"!\[.*?\]\(.*?\)", "", answer_text).strip() message.answer = answer_text message.updated_at = naive_utc_now() diff --git a/api/core/app/apps/agent_app/app_generator.py b/api/core/app/apps/agent_app/app_generator.py index 467afb891db..32ceecddf59 100644 --- a/api/core/app/apps/agent_app/app_generator.py +++ b/api/core/app/apps/agent_app/app_generator.py @@ -158,6 +158,110 @@ class AgentAppGenerator(MessageBasedAppGenerator): ) return AgentAppGenerateResponseConverter.convert(response=response, invoke_from=invoke_from) + def resume_after_form_submission( + self, + *, + app_model: App, + user: Account | EndUser, + conversation_id: str, + invoke_from: InvokeFrom, + ) -> None: + """Resume an Agent App conversation after a submitted ask_human HITL form. + + ENG-635: triggered by a background task (not an HTTP request). Runs one + blocking turn with no user query; the runner threads the human's reply + into the agent run as deferred_tool_results and the assistant answer is + persisted to the conversation. Live streaming to a reconnected client is + out of scope here — the message is persisted and can be re-fetched. + """ + agent, snapshot, agent_soul = self._resolve_agent(app_model) + conversation = ConversationService.get_conversation( + app_model=app_model, conversation_id=conversation_id, user=user + ) + + app_config = AgentAppConfigManager.get_app_config( + app_model=app_model, + agent_soul=agent_soul, + app_model_config=app_model.app_model_config, + conversation=conversation, + ) + model_conf = ModelConfigConverter.convert(app_config) + trace_manager = TraceQueueManager(app_model.id, user.id if isinstance(user, Account) else user.session_id) + + # ENG-638: the agent backend requires the resume composition's layer + # names to match the suspended snapshot, which includes the per-turn + # user-prompt layer. So re-send the original user message (the paused + # turn's query); the continuation is driven by deferred_tool_results and + # the restored snapshot, not by re-processing this prompt. A blank prompt + # would drop the user-prompt layer and fail the snapshot match. + paused_message = db.session.scalar( + select(Message) + .where(Message.conversation_id == conversation.id, Message.query != "") + .order_by(Message.created_at.desc()) + .limit(1) + ) + resume_query = paused_message.query if paused_message and paused_message.query else "(resumed)" + + application_generate_entity = AgentAppGenerateEntity( + task_id=str(uuid.uuid4()), + app_config=app_config, + model_conf=model_conf, + conversation_id=conversation.id, + # A resume carries no new user inputs; the human's answer is the + # submitted form, threaded in by the runner as deferred_tool_results. + # The query re-sends the paused turn's message (see above). + inputs={}, + query=resume_query, + files=[], + parent_message_id=UUID_NIL, + user_id=user.id, + stream=False, + invoke_from=invoke_from, + extras={"auto_generate_conversation_name": False}, + call_depth=0, + trace_manager=trace_manager, + agent_id=agent.id, + agent_config_snapshot_id=snapshot.id, + ) + + conversation, message = self._init_generate_records(application_generate_entity, conversation) + + queue_manager = MessageBasedAppQueueManager( + task_id=application_generate_entity.task_id, + user_id=application_generate_entity.user_id, + invoke_from=application_generate_entity.invoke_from, + conversation_id=conversation.id, + app_mode=conversation.mode, + message_id=message.id, + ) + + context = contextvars.copy_context() + worker_thread = threading.Thread( + target=self._generate_worker, + kwargs={ + "flask_app": current_app._get_current_object(), # type: ignore + "context": context, + "application_generate_entity": application_generate_entity, + "queue_manager": queue_manager, + "conversation_id": conversation.id, + "message_id": message.id, + "user_from": UserFrom.ACCOUNT if isinstance(user, Account) else UserFrom.END_USER, + # Resume continues a paused agent run; skip input guards (see _generate_worker). + "is_resume": True, + }, + ) + worker_thread.start() + + # Blocking: drive the chat task pipeline to persist the assistant answer. + self._handle_response( + application_generate_entity=application_generate_entity, + queue_manager=queue_manager, + conversation=conversation, + message=message, + user=user, + stream=False, + ) + def _generate_worker( self, *, @@ -168,6 +272,7 @@ class AgentAppGenerator(MessageBasedAppGenerator): conversation_id: str, message_id: str, user_from: UserFrom, + is_resume: bool = False, ) -> None: from libs.flask_utils import preserve_flask_contexts @@ -177,20 +282,30 @@ class AgentAppGenerator(MessageBasedAppGenerator): message = self._get_message(message_id) app_config = application_generate_entity.app_config - # Apply app-level input guards (content moderation + annotation - # reply) before reaching the Agent backend, mirroring the EasyUI - # chat / agent-chat runners. These can short-circuit the turn. - app_model = db.session.get(App, app_config.app_id) - if app_model is None: - raise AgentAppGeneratorError("App not found") - handled, query = self._run_input_guards( - application_generate_entity=application_generate_entity, - app_model=app_model, - message=message, - queue_manager=queue_manager, - ) - if handled: - return + if is_resume: + # ENG-638: a resume continues a paused agent run; the human's + # reply is threaded in by the runner as deferred_tool_results. + # The query is the replayed paused-turn message, kept only to + # match the suspended snapshot's layers — it is NOT new + # end-user input, so input guards must NOT run. Moderation or an + # annotation match on the replayed query would short-circuit the + # turn and drop the human reply, stranding the ask_human session. + query = application_generate_entity.query or "" + else: + # Apply app-level input guards (content moderation + annotation + # reply) before reaching the Agent backend, mirroring the EasyUI + # chat / agent-chat runners. These can short-circuit the turn. + app_model = db.session.get(App, app_config.app_id) + if app_model is None: + raise AgentAppGeneratorError("App not found") + handled, query = self._run_input_guards( + application_generate_entity=application_generate_entity, + app_model=app_model, + message=message, + queue_manager=queue_manager, + ) + if handled: + return dify_context = DifyRunContext( tenant_id=app_config.tenant_id, @@ -258,7 +373,7 @@ class AgentAppGenerator(MessageBasedAppGenerator): app_config = application_generate_entity.app_config model_name = application_generate_entity.model_conf.model - query = application_generate_entity.query + query = application_generate_entity.query or "" # content moderation (sensitive_word_avoidance); a blocked input yields a # preset answer, an "overridden" action returns a sanitized query. @@ -273,7 +388,7 @@ class AgentAppGenerator(MessageBasedAppGenerator): trace_manager=application_generate_entity.trace_manager, ) except ModerationError as e: - publish_text_answer(queue_manager=queue_manager, model_name=model_name, answer=str(e)) + publish_text_answer(queue_manager=queue_manager, model_name=model_name, answer=str(e), user_query=query) return True, query # annotation reply: a matching annotation answers the turn deterministically. @@ -290,7 +405,12 @@ class AgentAppGenerator(MessageBasedAppGenerator): QueueAnnotationReplyEvent(message_annotation_id=annotation_reply.id), PublishFrom.APPLICATION_MANAGER, ) - publish_text_answer(queue_manager=queue_manager, model_name=model_name, answer=annotation_reply.content) + publish_text_answer( + queue_manager=queue_manager, + model_name=model_name, + answer=annotation_reply.content, + user_query=query, + ) return True, query return False, query diff --git a/api/core/app/apps/agent_app/app_runner.py b/api/core/app/apps/agent_app/app_runner.py index 5f7d9f76249..03c6f3e410c 100644 --- a/api/core/app/apps/agent_app/app_runner.py +++ b/api/core/app/apps/agent_app/app_runner.py @@ -14,33 +14,57 @@ import json import logging from typing import Any +from dify_agent.layers.ask_human import AskHumanToolArgs +from dify_agent.protocol import DeferredToolResultsPayload from pydantic import JsonValue from clients.agent_backend import ( + AgentBackendDeferredToolCallInternalEvent, AgentBackendError, AgentBackendInternalEventType, AgentBackendRunClient, AgentBackendRunEventAdapter, AgentBackendRunSucceededInternalEvent, AgentBackendStreamInternalEvent, + extract_runtime_layer_specs, ) from core.app.apps.agent_app.runtime_request_builder import ( AgentAppRuntimeBuildContext, + AgentAppRuntimeRequest, AgentAppRuntimeRequestBuilder, ) -from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore, AgentAppSessionScope +from core.app.apps.agent_app.session_store import ( + AgentAppRuntimeSessionStore, + AgentAppSessionScope, + StoredAgentAppSession, +) from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom from core.app.apps.exc import GenerateTaskStoppedError from core.app.entities.app_invoke_entities import DifyRunContext from core.app.entities.queue_entities import QueueLLMChunkEvent, QueueMessageEndEvent +from core.repositories.human_input_repository import HumanInputFormRepository, HumanInputFormRepositoryImpl +from core.workflow.nodes.agent_v2.ask_human_hitl import AskHumanFormBuildError, create_ask_human_form +from core.workflow.nodes.agent_v2.ask_human_resume import build_deferred_tool_results, resolve_ask_human_form from graphon.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk, LLMResultChunkDelta, LLMUsage -from graphon.model_runtime.entities.message_entities import AssistantPromptMessage +from graphon.model_runtime.entities.message_entities import AssistantPromptMessage, PromptMessage, UserPromptMessage from models.agent_config_entities import AgentSoulConfig logger = logging.getLogger(__name__) -def publish_text_answer(*, queue_manager: AppQueueManager, model_name: str, answer: str) -> None: +def _prompt_messages_from_query(user_query: str | None) -> list[PromptMessage]: + if not user_query: + return [] + return [UserPromptMessage(content=user_query)] + + +def publish_text_answer( + *, + queue_manager: AppQueueManager, + model_name: str, + answer: str, + user_query: str | None = None, +) -> None: """Publish a complete assistant answer as one chunk + message-end. The EasyUI chat task pipeline consumes a QueueLLMChunkEvent stream followed @@ -48,9 +72,10 @@ def publish_text_answer(*, queue_manager: AppQueueManager, model_name: str, answ both the backend-produced answer and short-circuited answers (moderation / annotation reply) share the exact same persistence + SSE path. """ + prompt_messages = _prompt_messages_from_query(user_query) chunk = LLMResultChunk( model=model_name, - prompt_messages=[], + prompt_messages=prompt_messages, delta=LLMResultChunkDelta(index=0, message=AssistantPromptMessage(content=answer)), ) queue_manager.publish(QueueLLMChunkEvent(chunk=chunk), PublishFrom.APPLICATION_MANAGER) @@ -58,7 +83,7 @@ def publish_text_answer(*, queue_manager: AppQueueManager, model_name: str, answ QueueMessageEndEvent( llm_result=LLMResult( model=model_name, - prompt_messages=[], + prompt_messages=prompt_messages, message=AssistantPromptMessage(content=answer), usage=LLMUsage.empty_usage(), ), @@ -103,7 +128,13 @@ class AgentAppRunner: agent_id=agent_id, agent_config_snapshot_id=agent_config_snapshot_id, ) - session_snapshot = self._session_store.load_active_snapshot(scope) + # ENG-638: if a prior turn paused on ask_human and the form is now answered, + # resume by threading the human's reply into this run as deferred_tool_results. + stored = self._session_store.load_active_session(scope) + session_snapshot = stored.session_snapshot if stored is not None else None + deferred_tool_results = self._resolve_pending_ask_human( + stored=stored, dify_context=dify_context, message_id=message_id + ) runtime = self._request_builder.build( AgentAppRuntimeBuildContext( @@ -115,19 +146,131 @@ class AgentAppRunner: user_query=query, idempotency_key=message_id, session_snapshot=session_snapshot, + deferred_tool_results=deferred_tool_results, ) ) create_response = self._agent_backend_client.create_run(runtime.request) terminal = self._consume_stream(create_response.run_id, queue_manager=queue_manager) + if isinstance(terminal, AgentBackendDeferredToolCallInternalEvent): + # ENG-635: the agent asked a human. End this turn with the question and + # a conversation-owned HITL form; a form submission resumes the run. + self._pause_for_ask_human( + terminal=terminal, + scope=scope, + dify_context=dify_context, + agent_soul=agent_soul, + conversation_id=conversation_id, + message_id=message_id, + model_name=model_name, + runtime=runtime, + queue_manager=queue_manager, + query=query, + ) + return + if not isinstance(terminal, AgentBackendRunSucceededInternalEvent): error = getattr(terminal, "error", None) or "Agent backend run did not complete successfully." raise AgentBackendError(str(error)) answer = self._extract_answer(terminal.output) - self._publish_answer(queue_manager=queue_manager, model_name=model_name, answer=answer) - self._save_session(scope=scope, backend_run_id=terminal.run_id, snapshot=terminal.session_snapshot) + self._publish_answer(queue_manager=queue_manager, model_name=model_name, answer=answer, query=query) + self._save_session( + scope=scope, + backend_run_id=terminal.run_id, + snapshot=terminal.session_snapshot, + runtime_layer_specs=extract_runtime_layer_specs(runtime.request.composition), + ) + + def _pause_for_ask_human( + self, + *, + terminal: AgentBackendDeferredToolCallInternalEvent, + scope: AgentAppSessionScope, + dify_context: DifyRunContext, + agent_soul: AgentSoulConfig, + conversation_id: str, + message_id: str, + model_name: str, + runtime: AgentAppRuntimeRequest, + queue_manager: AppQueueManager, + query: str, + ) -> None: + """End the chat turn on a dify.ask_human call: create a conversation-owned + HITL form, persist the pause correlation, and surface the question.""" + try: + created = create_ask_human_form( + deferred_tool_call=terminal.deferred_tool_call, + # Chat forms have no workflow node; key by the turn's message id. + node_id=message_id, + default_node_title="Agent", + contacts=agent_soul.human.contacts, + repository=self._build_form_repository(dify_context), + conversation_id=conversation_id, + ) + except AskHumanFormBuildError as error: + raise AgentBackendError(f"Failed to build ask_human form for Agent App chat: {error}") from error + + # Persist the snapshot + correlation so a form submission can start the + # second run with the human's answer (ENG-637/638 columns, conversation owner). + self._save_session( + scope=scope, + backend_run_id=terminal.run_id, + snapshot=terminal.session_snapshot, + runtime_layer_specs=extract_runtime_layer_specs(runtime.request.composition), + pending_form_id=created.form_id, + pending_tool_call_id=terminal.deferred_tool_call.tool_call_id, + ) + + # The structured form is delivered via the HITL surface(s); the chat turn + # ends by echoing the agent's question so the conversation reflects the ask. + self._publish_answer( + queue_manager=queue_manager, + model_name=model_name, + answer=self._ask_human_message(created.args), + query=query, + ) + + def _resolve_pending_ask_human( + self, + *, + stored: StoredAgentAppSession | None, + dify_context: DifyRunContext, + message_id: str, + ) -> DeferredToolResultsPayload | None: + """Build deferred_tool_results when a pending ask_human form is answered.""" + if stored is None or stored.pending_form_id is None or stored.pending_tool_call_id is None: + return None + outcome = resolve_ask_human_form( + form_id=stored.pending_form_id, + tenant_id=dify_context.tenant_id, + node_id=message_id, + ) + if outcome is None or outcome.deferred_result is None: + # Form missing or still waiting — run a normal turn, no resume. + return None + return build_deferred_tool_results( + tool_call_id=stored.pending_tool_call_id, + result=outcome.deferred_result, + ) + + def _build_form_repository(self, dify_context: DifyRunContext) -> HumanInputFormRepository: + invoke_source = dify_context.invoke_from.value + return HumanInputFormRepositoryImpl( + tenant_id=dify_context.tenant_id, + app_id=dify_context.app_id, + workflow_execution_id=None, + invoke_source=invoke_source, + submission_actor_id=dify_context.user_id if invoke_source in {"debugger", "explore"} else None, + ) + + @staticmethod + def _ask_human_message(args: AskHumanToolArgs) -> str: + parts = [args.question] + if args.markdown: + parts.append(args.markdown) + return "\n\n".join(parts) def _consume_stream(self, run_id: str, *, queue_manager: AppQueueManager): terminal = None @@ -160,14 +303,32 @@ class AgentAppRunner: except Exception: logger.warning("Failed to cancel stopped Agent App backend run: run_id=%s", run_id, exc_info=True) - def _publish_answer(self, *, queue_manager: AppQueueManager, model_name: str, answer: str) -> None: + def _publish_answer( + self, *, queue_manager: AppQueueManager, model_name: str, answer: str, query: str | None + ) -> None: # MVP: emit the full answer as a single chunk + message-end. The chat # task pipeline streams the chunk over SSE and persists the message. - publish_text_answer(queue_manager=queue_manager, model_name=model_name, answer=answer) + publish_text_answer(queue_manager=queue_manager, model_name=model_name, answer=answer, user_query=query) - def _save_session(self, *, scope: AgentAppSessionScope, backend_run_id: str, snapshot: Any) -> None: + def _save_session( + self, + *, + scope: AgentAppSessionScope, + backend_run_id: str, + snapshot: Any, + runtime_layer_specs: Any, + pending_form_id: str | None = None, + pending_tool_call_id: str | None = None, + ) -> None: try: - self._session_store.save_active_snapshot(scope=scope, backend_run_id=backend_run_id, snapshot=snapshot) + self._session_store.save_active_snapshot( + scope=scope, + backend_run_id=backend_run_id, + snapshot=snapshot, + runtime_layer_specs=runtime_layer_specs, + pending_form_id=pending_form_id, + pending_tool_call_id=pending_tool_call_id, + ) except Exception: logger.warning( "Failed to persist Agent App conversation session snapshot: " diff --git a/api/core/app/apps/agent_app/runtime_request_builder.py b/api/core/app/apps/agent_app/runtime_request_builder.py index df1e161b641..71cc0385f97 100644 --- a/api/core/app/apps/agent_app/runtime_request_builder.py +++ b/api/core/app/apps/agent_app/runtime_request_builder.py @@ -18,7 +18,7 @@ from dify_agent.layers.execution_context import ( DifyExecutionContextLayerConfig, DifyExecutionContextUserFrom, ) -from dify_agent.protocol import CreateRunRequest +from dify_agent.protocol import CreateRunRequest, DeferredToolResultsPayload from clients.agent_backend import ( AgentBackendAgentAppRunInput, @@ -32,7 +32,12 @@ from core.workflow.nodes.agent_v2.plugin_tools_builder import ( WorkflowAgentPluginToolsBuilder, WorkflowAgentPluginToolsBuildError, ) -from core.workflow.nodes.agent_v2.runtime_request_builder import build_shell_layer_config +from core.workflow.nodes.agent_v2.runtime_request_builder import ( + append_runtime_warnings, + build_ask_human_layer_config, + build_drive_layer_config, + build_shell_layer_config, +) from models.agent_config_entities import AgentSoulConfig from models.provider_ids import ModelProviderID from services.agent.prompt_mentions import build_soul_mention_resolver, expand_prompt_mentions @@ -60,6 +65,8 @@ class AgentAppRuntimeBuildContext: user_query: str idempotency_key: str session_snapshot: CompositorSessionSnapshot | None = None + # ENG-638: set when resuming a chat turn after a submitted ask_human form. + deferred_tool_results: DeferredToolResultsPayload | None = None @dataclass(frozen=True, slots=True) @@ -112,6 +119,11 @@ class AgentAppRuntimeRequestBuilder: "cli_tool_count": len(agent_soul.tools.cli_tools), } + drive_config = None + if dify_config.AGENT_DRIVE_MANIFEST_ENABLED: + drive_config, drive_warnings = build_drive_layer_config(agent_soul, agent_id=context.agent_id) + append_runtime_warnings(metadata, drive_warnings) + request = self._request_builder.build_for_agent_app( AgentBackendAgentAppRunInput( model=AgentBackendModelConfig( @@ -144,9 +156,12 @@ class AgentAppRuntimeRequestBuilder: or None, user_prompt=context.user_query, tools=tools_layer, + drive_config=drive_config, + ask_human_config=build_ask_human_layer_config(agent_soul), include_shell=dify_config.AGENT_SHELL_ENABLED, shell_config=build_shell_layer_config(agent_soul), session_snapshot=context.session_snapshot, + deferred_tool_results=context.deferred_tool_results, idempotency_key=context.idempotency_key, metadata=metadata, ) diff --git a/api/core/app/apps/agent_app/session_store.py b/api/core/app/apps/agent_app/session_store.py index 62c14c33b95..8c68e218d1f 100644 --- a/api/core/app/apps/agent_app/session_store.py +++ b/api/core/app/apps/agent_app/session_store.py @@ -9,9 +9,11 @@ phase-2 concern and not modeled here. from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from agenton.compositor import CompositorSessionSnapshot +from dify_agent.protocol import RuntimeLayerSpec +from pydantic import TypeAdapter from sqlalchemy import select from core.db.session_factory import session_factory @@ -22,6 +24,18 @@ from models.agent import ( AgentRuntimeSessionStatus, ) +_RUNTIME_LAYER_SPECS_ADAPTER: TypeAdapter[list[RuntimeLayerSpec]] = TypeAdapter(list[RuntimeLayerSpec]) + + +def _serialize_runtime_layer_specs(specs: list[RuntimeLayerSpec]) -> str: + return _RUNTIME_LAYER_SPECS_ADAPTER.dump_json(specs).decode() + + +def _deserialize_runtime_layer_specs(value: str | None) -> list[RuntimeLayerSpec]: + if not value: + return [] + return _RUNTIME_LAYER_SPECS_ADAPTER.validate_json(value) + @dataclass(frozen=True, slots=True) class AgentAppSessionScope: @@ -34,24 +48,53 @@ class AgentAppSessionScope: agent_config_snapshot_id: str +@dataclass(frozen=True, slots=True) +class StoredAgentAppSession: + """Persisted Agent App conversation session with reusable runtime specs.""" + + scope: AgentAppSessionScope + session_snapshot: CompositorSessionSnapshot + backend_run_id: str | None + runtime_layer_specs: list[RuntimeLayerSpec] = field(default_factory=list) + # ENG-635: set while the conversation turn is paused on a dify.ask_human + # deferred call, awaiting a HITL form submission. + pending_form_id: str | None = None + pending_tool_call_id: str | None = None + + class AgentAppRuntimeSessionStore: """Persists Agent backend session snapshots for Agent App conversations.""" def load_active_snapshot(self, scope: AgentAppSessionScope) -> CompositorSessionSnapshot | None: + stored = self.load_active_session(scope) + return stored.session_snapshot if stored is not None else None + + def load_active_session(self, scope: AgentAppSessionScope) -> StoredAgentAppSession | None: with session_factory.create_session() as session: row = session.scalar(self._active_stmt(scope)) if row is None: return None - return CompositorSessionSnapshot.model_validate_json(row.session_snapshot) + return StoredAgentAppSession( + scope=scope, + session_snapshot=CompositorSessionSnapshot.model_validate_json(row.session_snapshot), + backend_run_id=row.backend_run_id, + runtime_layer_specs=_deserialize_runtime_layer_specs(row.composition_layer_specs), + pending_form_id=row.pending_form_id, + pending_tool_call_id=row.pending_tool_call_id, + ) - def load_active_snapshot_for_conversation( + def load_active_session_for_conversation( self, *, tenant_id: str, app_id: str, conversation_id: str - ) -> CompositorSessionSnapshot | None: - """Load a conversation's active snapshot without the agent/config scope. + ) -> StoredAgentAppSession | None: + """Load the latest ACTIVE session for one conversation-level sandbox lookup. - One Agent App conversation maps to one active session, so the workspace - inspector can resolve it from the conversation alone (it does not know - which agent config version a past turn ran under). + Sandbox inspection only knows the product locator + ``tenant_id + app_id + conversation_id``; it does not know which + ``agent_id`` or Agent Soul snapshot produced the active shell session. + This method therefore resolves the newest ACTIVE conversation-owned row + for that conversation and returns both the resumable snapshot and the + persisted non-sensitive runtime layer specs needed to build a + ``SandboxLocator``. """ stmt = ( select(AgentRuntimeSession) @@ -68,7 +111,18 @@ class AgentAppRuntimeSessionStore: row = session.scalar(stmt) if row is None: return None - return CompositorSessionSnapshot.model_validate_json(row.session_snapshot) + return StoredAgentAppSession( + scope=AgentAppSessionScope( + tenant_id=row.tenant_id, + app_id=row.app_id, + conversation_id=row.conversation_id or "", + agent_id=row.agent_id, + agent_config_snapshot_id=row.agent_config_snapshot_id or "", + ), + session_snapshot=CompositorSessionSnapshot.model_validate_json(row.session_snapshot), + backend_run_id=row.backend_run_id, + runtime_layer_specs=_deserialize_runtime_layer_specs(row.composition_layer_specs), + ) def save_active_snapshot( self, @@ -76,10 +130,14 @@ class AgentAppRuntimeSessionStore: scope: AgentAppSessionScope, backend_run_id: str, snapshot: CompositorSessionSnapshot | None, + runtime_layer_specs: list[RuntimeLayerSpec], + pending_form_id: str | None = None, + pending_tool_call_id: str | None = None, ) -> None: if snapshot is None: return snapshot_json = snapshot.model_dump_json() + runtime_layer_specs_json = _serialize_runtime_layer_specs(runtime_layer_specs) with session_factory.create_session() as session: row = session.scalar(self._scope_stmt(scope)) if row is None: @@ -92,15 +150,21 @@ class AgentAppRuntimeSessionStore: conversation_id=scope.conversation_id, backend_run_id=backend_run_id, session_snapshot=snapshot_json, - composition_layer_specs="[]", + composition_layer_specs=runtime_layer_specs_json, status=AgentRuntimeSessionStatus.ACTIVE, + pending_form_id=pending_form_id, + pending_tool_call_id=pending_tool_call_id, ) session.add(row) else: row.backend_run_id = backend_run_id row.session_snapshot = snapshot_json + row.composition_layer_specs = runtime_layer_specs_json row.status = AgentRuntimeSessionStatus.ACTIVE row.cleaned_at = None + # Set (or clear, when omitted) the ask_human pause correlation. + row.pending_form_id = pending_form_id + row.pending_tool_call_id = pending_tool_call_id session.flush() other_rows = session.scalars( select(AgentRuntimeSession).where( @@ -143,4 +207,4 @@ class AgentAppRuntimeSessionStore: return cls._scope_stmt(scope).where(AgentRuntimeSession.status == AgentRuntimeSessionStatus.ACTIVE) -__all__ = ["AgentAppRuntimeSessionStore", "AgentAppSessionScope"] +__all__ = ["AgentAppRuntimeSessionStore", "AgentAppSessionScope", "StoredAgentAppSession"] diff --git a/api/core/app/apps/base_app_queue_manager.py b/api/core/app/apps/base_app_queue_manager.py index b959987078e..9551a5e38c4 100644 --- a/api/core/app/apps/base_app_queue_manager.py +++ b/api/core/app/apps/base_app_queue_manager.py @@ -41,7 +41,7 @@ class AppQueueManager(ABC): self._invoke_from = invoke_from self.invoke_from = invoke_from # Public accessor for invoke_from - user_prefix = "account" if self._invoke_from in {InvokeFrom.EXPLORE, InvokeFrom.DEBUGGER} else "end-user" + user_prefix = "account" if self._invoke_from.runs_as_account() else "end-user" self._task_belong_cache_key = AppQueueManager._generate_task_belong_cache_key(self._task_id) redis_client.setex(self._task_belong_cache_key, 1800, f"{user_prefix}-{self._user_id}") diff --git a/api/core/app/apps/base_app_runner.py b/api/core/app/apps/base_app_runner.py index ea4a187a9c7..a89a0cf70db 100644 --- a/api/core/app/apps/base_app_runner.py +++ b/api/core/app/apps/base_app_runner.py @@ -301,31 +301,32 @@ class AppRunner: queue_manager.publish(QueueAgentMessageEvent(chunk=result), PublishFrom.APPLICATION_MANAGER) message = result.delta.message - if isinstance(message.content, str): - text += message.content - elif isinstance(message.content, list): - for content in message.content: - match content: - case str(): - text += content - case TextPromptMessageContent(): - text += content.data - case ImagePromptMessageContent(): - if message_id and user_id and tenant_id: - try: - self._handle_multimodal_image_content( - content=content, - message_id=message_id, - user_id=user_id, - tenant_id=tenant_id, - queue_manager=queue_manager, - ) - except Exception: - _logger.exception("Failed to handle multimodal image output") - else: - _logger.warning("Received multimodal output but missing required parameters") - case _: - text += content.data if hasattr(content, "data") else str(content) + match message.content: + case str(): + text += message.content + case list(): + for content in message.content: + match content: + case str(): + text += content + case TextPromptMessageContent(): + text += content.data + case ImagePromptMessageContent(): + if message_id and user_id and tenant_id: + try: + self._handle_multimodal_image_content( + content=content, + message_id=message_id, + user_id=user_id, + tenant_id=tenant_id, + queue_manager=queue_manager, + ) + except Exception: + _logger.exception("Failed to handle multimodal image output") + else: + _logger.warning("Received multimodal output but missing required parameters") + case _: + text += content.data if hasattr(content, "data") else str(content) if not model: model = result.model @@ -430,9 +431,7 @@ class AppRunner: url=f"/files/tools/{tool_file.id}", upload_file_id=tool_file.id, created_by_role=( - CreatorUserRole.ACCOUNT - if queue_manager.invoke_from in {InvokeFrom.DEBUGGER, InvokeFrom.EXPLORE} - else CreatorUserRole.END_USER + CreatorUserRole.ACCOUNT if queue_manager.invoke_from.runs_as_account() else CreatorUserRole.END_USER ), created_by=user_id, ) diff --git a/api/core/app/apps/chat/app_config_manager.py b/api/core/app/apps/chat/app_config_manager.py index 5f087f60663..35507d65ab9 100644 --- a/api/core/app/apps/chat/app_config_manager.py +++ b/api/core/app/apps/chat/app_config_manager.py @@ -81,7 +81,7 @@ class ChatAppConfigManager(BaseAppConfigManager): return app_config @classmethod - def config_validate(cls, tenant_id: str, config: dict) -> AppModelConfigDict: + def config_validate(cls, tenant_id: str, config: dict[str, Any]) -> AppModelConfigDict: """ Validate for chat app model config diff --git a/api/core/app/apps/completion/app_config_manager.py b/api/core/app/apps/completion/app_config_manager.py index f49e7b8b5e2..fcfb38e8c80 100644 --- a/api/core/app/apps/completion/app_config_manager.py +++ b/api/core/app/apps/completion/app_config_manager.py @@ -68,7 +68,7 @@ class CompletionAppConfigManager(BaseAppConfigManager): return app_config @classmethod - def config_validate(cls, tenant_id: str, config: dict) -> AppModelConfigDict: + def config_validate(cls, tenant_id: str, config: dict[str, Any]) -> AppModelConfigDict: """ Validate for completion app model config diff --git a/api/core/app/apps/workflow_app_runner.py b/api/core/app/apps/workflow_app_runner.py index 944860ee39c..69f6c5b69b7 100644 --- a/api/core/app/apps/workflow_app_runner.py +++ b/api/core/app/apps/workflow_app_runner.py @@ -104,7 +104,7 @@ class WorkflowBasedAppRunner: @staticmethod def _resolve_user_from(invoke_from: InvokeFrom) -> UserFrom: - if invoke_from in {InvokeFrom.EXPLORE, InvokeFrom.DEBUGGER}: + if invoke_from.runs_as_account(): return UserFrom.ACCOUNT return UserFrom.END_USER diff --git a/api/core/app/entities/app_invoke_entities.py b/api/core/app/entities/app_invoke_entities.py index 08ecc2097b3..2153289e0e6 100644 --- a/api/core/app/entities/app_invoke_entities.py +++ b/api/core/app/entities/app_invoke_entities.py @@ -47,6 +47,14 @@ class InvokeFrom(StrEnum): } return source_mapping.get(self, "dev") + def runs_as_account(self) -> bool: + """Whether a run from this entry point is attributed to a workspace + Account rather than an end user. Console contexts (debugger/explore) + run as the signed-in Account; webapp/service-api/trigger run as an + EndUser. Single source of truth for the created-by-role / user-type + split shared by the app runners and MCP identity forwarding.""" + return self in (InvokeFrom.DEBUGGER, InvokeFrom.EXPLORE) + class DifyRunContext(BaseModel): tenant_id: str diff --git a/api/core/app/layers/trigger_post_layer.py b/api/core/app/layers/trigger_post_layer.py index 65b8af67065..b30603b860d 100644 --- a/api/core/app/layers/trigger_post_layer.py +++ b/api/core/app/layers/trigger_post_layer.py @@ -7,7 +7,13 @@ from pydantic import TypeAdapter from core.db.session_factory import session_factory from core.workflow.system_variables import SystemVariableKey, get_system_text from graphon.graph_engine.layers import GraphEngineLayer -from graphon.graph_events import GraphEngineEvent, GraphRunFailedEvent, GraphRunPausedEvent, GraphRunSucceededEvent +from graphon.graph_events import ( + GraphEngineEvent, + GraphRunAbortedEvent, + GraphRunFailedEvent, + GraphRunPausedEvent, + GraphRunSucceededEvent, +) from models.enums import WorkflowTriggerStatus from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWorkflowTriggerLogRepository from tasks.workflow_cfs_scheduler.cfs_scheduler import AsyncWorkflowCFSPlanEntity @@ -23,6 +29,7 @@ class TriggerPostLayer(GraphEngineLayer): _STATUS_MAP: ClassVar[dict[type[GraphEngineEvent], WorkflowTriggerStatus]] = { GraphRunSucceededEvent: WorkflowTriggerStatus.SUCCEEDED, GraphRunFailedEvent: WorkflowTriggerStatus.FAILED, + GraphRunAbortedEvent: WorkflowTriggerStatus.FAILED, GraphRunPausedEvent: WorkflowTriggerStatus.PAUSED, } @@ -73,6 +80,8 @@ class TriggerPostLayer(GraphEngineLayer): trigger_log.status = self._STATUS_MAP[type(event)] trigger_log.workflow_run_id = workflow_run_id trigger_log.outputs = TypeAdapter(dict[str, Any]).dump_json(outputs).decode() + if isinstance(event, GraphRunAbortedEvent): + trigger_log.error = event.reason or "Workflow execution aborted" if trigger_log.elapsed_time is None: trigger_log.elapsed_time = elapsed_time diff --git a/api/core/app/llm/model_access.py b/api/core/app/llm/model_access.py index 5631caa1a59..765268f7a0e 100644 --- a/api/core/app/llm/model_access.py +++ b/api/core/app/llm/model_access.py @@ -4,6 +4,7 @@ from copy import deepcopy from typing import Any from core.app.entities.app_invoke_entities import DifyRunContext, ModelConfigWithCredentialsEntity +from core.entities.model_entities import ModelWithProviderEntity from core.errors.error import ProviderTokenNotInitError from core.model_manager import ModelInstance, ModelManager from core.plugin.impl.model_runtime_factory import create_plugin_provider_manager @@ -19,6 +20,10 @@ class DifyCredentialsProvider: Fetched credentials are stored in :attr:`credentials_cache` and reused for subsequent ``fetch`` calls for the same ``(provider_name, model_name)``. + The matching validated provider model is cached alongside the credentials so + follow-up workflow startup checks can reuse the earlier provider lookup + instead of resolving the same model metadata a second time. + Because of that cache, a single instance can return stale credentials after the tenant or provider configuration changes (e.g. API key rotation). @@ -30,6 +35,7 @@ class DifyCredentialsProvider: tenant_id: str provider_manager: ProviderManager credentials_cache: dict[tuple[str, str], dict[str, Any]] + provider_model_cache: dict[tuple[str, str], ModelWithProviderEntity] def __init__( self, @@ -45,12 +51,21 @@ class DifyCredentialsProvider: ) self.provider_manager = provider_manager self.credentials_cache = {} + self.provider_model_cache = {} + + def get_cached_provider_model(self, provider_name: str, model_name: str) -> ModelWithProviderEntity | None: + provider_model = self.provider_model_cache.get((provider_name, model_name)) + if provider_model is None: + return None + + return provider_model.model_copy(deep=True) def fetch(self, provider_name: str, model_name: str) -> dict[str, Any]: if (provider_name, model_name) in self.credentials_cache: return deepcopy(self.credentials_cache[(provider_name, model_name)]) provider_configurations = self.provider_manager.get_configurations(self.tenant_id) + provider_configuration = provider_configurations.get(provider_name) if not provider_configuration: raise ValueError(f"Provider {provider_name} does not exist.") @@ -65,6 +80,7 @@ class DifyCredentialsProvider: raise ProviderTokenNotInitError(f"Model {model_name} credentials is not initialized.") self.credentials_cache[(provider_name, model_name)] = deepcopy(credentials) + self.provider_model_cache[(provider_name, model_name)] = provider_model.model_copy(deep=True) return credentials @@ -142,13 +158,21 @@ def fetch_model_config( model_instance = model_factory.init_model_instance(node_data_model.provider, node_data_model.name) provider_model_bundle = model_instance.provider_model_bundle - provider_model = provider_model_bundle.configuration.get_provider_model( - model=node_data_model.name, - model_type=ModelType.LLM, - ) + provider_model = None + if isinstance(credentials_provider, DifyCredentialsProvider): + provider_model = credentials_provider.get_cached_provider_model( + provider_name=node_data_model.provider, + model_name=node_data_model.name, + ) + if provider_model is None: - raise ModelNotExistError(f"Model {node_data_model.name} does not exist.") - provider_model.raise_for_status() + provider_model = provider_model_bundle.configuration.get_provider_model( + model=node_data_model.name, + model_type=ModelType.LLM, + ) + if provider_model is None: + raise ModelNotExistError(f"Model {node_data_model.name} does not exist.") + provider_model.raise_for_status() model_schema = model_instance.model_type_instance.get_model_schema(node_data_model.name, credentials) if model_schema is None: diff --git a/api/core/app/task_pipeline/based_generate_task_pipeline.py b/api/core/app/task_pipeline/based_generate_task_pipeline.py index f75dbe88b21..b1419b3adfa 100644 --- a/api/core/app/task_pipeline/based_generate_task_pipeline.py +++ b/api/core/app/task_pipeline/based_generate_task_pipeline.py @@ -24,14 +24,19 @@ from models.model import Message logger = logging.getLogger(__name__) -class BasedGenerateTaskPipeline: +class BasedGenerateTaskPipeline[AppGenerateEntityT: AppGenerateEntity]: """ BasedGenerateTaskPipeline is a class that generate stream output and state management for Application. + + The type parameter preserves the concrete application generate entity for + subclasses after the shared initializer stores it on ``_application_generate_entity``. """ + _application_generate_entity: AppGenerateEntityT + def __init__( self, - application_generate_entity: AppGenerateEntity, + application_generate_entity: AppGenerateEntityT, queue_manager: AppQueueManager, stream: bool, ): diff --git a/api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py b/api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py index bea50ea2696..a728069eede 100644 --- a/api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py +++ b/api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py @@ -65,19 +65,20 @@ from models.model import AppMode, Conversation, Message, MessageAgentThought, Me logger = logging.getLogger(__name__) +type EasyUIAppGenerateEntity = ChatAppGenerateEntity | CompletionAppGenerateEntity | AgentChatAppGenerateEntity -class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline): + +class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerateEntity]): """ EasyUIBasedGenerateTaskPipeline is a class that generate stream output and state management for Application. """ _task_state: EasyUITaskState - _application_generate_entity: ChatAppGenerateEntity | CompletionAppGenerateEntity | AgentChatAppGenerateEntity _precomputed_event_type: StreamEvent | None = None def __init__( self, - application_generate_entity: ChatAppGenerateEntity | CompletionAppGenerateEntity | AgentChatAppGenerateEntity, + application_generate_entity: EasyUIAppGenerateEntity, queue_manager: AppQueueManager, conversation: Conversation, message: Message, @@ -310,12 +311,13 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline): yield response case QueueLLMChunkEvent() | QueueAgentMessageEvent(): chunk = event.chunk - delta_text = chunk.delta.message.content - if delta_text is None: + delta_content = chunk.delta.message.content + if delta_content is None: continue - if isinstance(chunk.delta.message.content, list): + if isinstance(delta_content, list): + # EasyUI streams text only; structured multimodal chunks contribute their text parts. delta_text = "" - for content in chunk.delta.message.content: + for content in delta_content: logger.debug( "The content type %s in LLM chunk delta message content.: %r", type(content), content ) @@ -331,17 +333,19 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline): content, ) continue + else: + delta_text = delta_content if not self._task_state.llm_result.prompt_messages: self._task_state.llm_result.prompt_messages = chunk.prompt_messages # handle output moderation chunk - should_direct_answer = self._handle_output_moderation_chunk(cast(str, delta_text)) + should_direct_answer = self._handle_output_moderation_chunk(delta_text) if should_direct_answer: continue current_content = cast(str, self._task_state.llm_result.message.content) - current_content += cast(str, delta_text) + current_content += delta_text self._task_state.llm_result.message.content = current_content match event: @@ -352,13 +356,13 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline): message_id=self._message_id ) yield self._message_cycle_manager.message_to_stream_response( - answer=cast(str, delta_text), + answer=delta_text, message_id=self._message_id, event_type=self._precomputed_event_type, ) case _: yield self._agent_message_to_stream_response( - answer=cast(str, delta_text), + answer=delta_text, message_id=self._message_id, ) case QueueMessageReplaceEvent(): @@ -389,9 +393,10 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline): if not conversation: raise ValueError(f"Conversation {self._conversation_id} not found") - message.message = PromptMessageUtil.prompt_messages_to_prompt_for_saving( + saved_prompt = PromptMessageUtil.prompt_messages_to_prompt_for_saving( self._model_config.mode, self._task_state.llm_result.prompt_messages ) + object.__setattr__(message, "message", saved_prompt) message.message_tokens = usage.prompt_tokens message.message_unit_price = usage.prompt_unit_price message.message_price_unit = usage.prompt_price_unit diff --git a/api/core/entities/model_entities.py b/api/core/entities/model_entities.py index e99a131500f..3b50ab0d483 100644 --- a/api/core/entities/model_entities.py +++ b/api/core/entities/model_entities.py @@ -1,10 +1,11 @@ from collections.abc import Sequence from enum import StrEnum, auto +from typing import Any from pydantic import BaseModel, ConfigDict from graphon.model_runtime.entities.common_entities import I18nObject -from graphon.model_runtime.entities.model_entities import ModelType, ProviderModel +from graphon.model_runtime.entities.model_entities import ModelPropertyKey, ModelType, ProviderModel from graphon.model_runtime.entities.provider_entities import ProviderEntity @@ -52,6 +53,7 @@ class ProviderModelWithStatusEntity(ProviderModel): Model class for model response. """ + model_properties: dict[ModelPropertyKey, Any] status: ModelStatus load_balancing_enabled: bool = False has_invalid_load_balancing_configs: bool = False diff --git a/api/core/entities/provider_configuration.py b/api/core/entities/provider_configuration.py index 91c46d07a80..95375a8cbb2 100644 --- a/api/core/entities/provider_configuration.py +++ b/api/core/entities/provider_configuration.py @@ -69,6 +69,11 @@ class ProviderConfiguration(BaseModel): nested schema and model lookups reuse the caller scope that was already resolved by the composition layer. + The ``provider`` field already contains the resolved provider schema that + was used to build this configuration. Reuse that schema for nested model + lookups instead of refetching the full provider catalog from the runtime on + every request-scoped lookup. + TODO: lots of logic in a BaseModel entity should be separated, the exceptions should be classified """ @@ -83,15 +88,19 @@ class ProviderConfiguration(BaseModel): # pydantic configs model_config = ConfigDict(protected_namespaces=()) _bound_model_runtime: ModelRuntime | None = PrivateAttr(default=None) + _cached_provider_schema: ProviderEntity | None = PrivateAttr(default=None) + _original_provider_configurate_methods: tuple[ConfigurateMethod, ...] = PrivateAttr(default_factory=tuple) @model_validator(mode="after") def _(self): + self._original_provider_configurate_methods = tuple(self.provider.configurate_methods) + if self.provider.provider not in original_provider_configurate_methods: original_provider_configurate_methods[self.provider.provider] = [] for configurate_method in self.provider.configurate_methods: original_provider_configurate_methods[self.provider.provider].append(configurate_method) - if original_provider_configurate_methods[self.provider.provider] == [ConfigurateMethod.CUSTOMIZABLE_MODEL]: + if list(self._original_provider_configurate_methods) == [ConfigurateMethod.CUSTOMIZABLE_MODEL]: if ( any( len(quota_configuration.restrict_models) > 0 @@ -105,6 +114,29 @@ class ProviderConfiguration(BaseModel): def bind_model_runtime(self, model_runtime: ModelRuntime) -> None: """Attach the already-composed runtime for request-bound call chains.""" self._bound_model_runtime = model_runtime + self._cached_provider_schema = self.provider + + def _get_original_provider_configurate_methods(self) -> list[ConfigurateMethod]: + return list(self._original_provider_configurate_methods) + + def _get_provider_schema(self, *, model_provider_factory: ModelProviderFactory | None = None) -> ProviderEntity: + """Resolve the provider schema lazily while preserving bound-runtime reuse.""" + if self._cached_provider_schema is None: + if self.provider.models: + self._cached_provider_schema = self.provider + else: + provider_factory = model_provider_factory or self.get_model_provider_factory() + self._cached_provider_schema = provider_factory.get_provider_schema(provider=self.provider.provider) + + return self._cached_provider_schema + + def _get_model_runtime(self) -> ModelRuntime: + """Return the runtime aligned with this request-scoped configuration.""" + if self._bound_model_runtime is not None: + return self._bound_model_runtime + + model_assembly = create_plugin_model_assembly(tenant_id=self.tenant_id) + return model_assembly.model_runtime def _get_runtime_and_provider_factory(self) -> tuple[ModelRuntime, ModelProviderFactory]: """Resolve a provider factory that stays aligned with the runtime used by the caller.""" @@ -153,7 +185,6 @@ class ProviderConfiguration(BaseModel): and restrict_model.base_model_name ): copy_credentials["base_model_name"] = restrict_model.base_model_name - return copy_credentials else: credentials = None @@ -189,7 +220,6 @@ class ProviderConfiguration(BaseModel): provider=self.provider.provider, credential_type=PluginCredentialType.MODEL, ) - return credentials def get_system_configuration_status(self) -> SystemConfigurationStatus | None: @@ -1399,8 +1429,13 @@ class ProviderConfiguration(BaseModel): :param model_type: model type :return: """ - model_runtime, model_provider_factory = self._get_runtime_and_provider_factory() - provider_schema = model_provider_factory.get_provider_schema(provider=self.provider.provider) + if self._bound_model_runtime is not None: + model_runtime = self._bound_model_runtime + else: + model_runtime, _ = self._get_runtime_and_provider_factory() + + provider_schema = self._cached_provider_schema or self.provider + return create_model_type_instance( runtime=model_runtime, provider_schema=provider_schema, @@ -1410,12 +1445,13 @@ class ProviderConfiguration(BaseModel): def get_model_schema( self, model_type: ModelType, model: str, credentials: dict[str, Any] | None ) -> AIModelEntity | None: - """ - Get model schema - """ - model_provider_factory = self.get_model_provider_factory() - return model_provider_factory.get_model_schema( - provider=self.provider.provider, model_type=model_type, model=model, credentials=credentials + """Get model schema with the request-bound runtime and canonical provider id.""" + model_runtime = self._get_model_runtime() + return model_runtime.get_model_schema( + provider=self.provider.provider, + model_type=model_type, + model=model, + credentials=credentials or {}, ) def switch_preferred_provider_type(self, provider_type: ProviderType, session: Session | None = None): @@ -1515,8 +1551,7 @@ class ProviderConfiguration(BaseModel): :param model: model name :return: """ - model_provider_factory = self.get_model_provider_factory() - provider_schema = model_provider_factory.get_provider_schema(self.provider.provider) + provider_schema = self._get_provider_schema() model_types: list[ModelType] = [] if model_type: @@ -1531,7 +1566,10 @@ class ProviderConfiguration(BaseModel): if self.using_provider_type == ProviderType.SYSTEM: provider_models = self._get_system_provider_models( - model_types=model_types, provider_schema=provider_schema, model_setting_map=model_setting_map + model_types=model_types, + provider_schema=provider_schema, + model_setting_map=model_setting_map, + model=model, ) else: provider_models = self._get_custom_provider_models( @@ -1573,6 +1611,7 @@ class ProviderConfiguration(BaseModel): model_types: Sequence[ModelType], provider_schema: ProviderEntity, model_setting_map: dict[ModelType, dict[str, ModelSettings]], + model: str | None = None, ) -> list[ModelWithProviderEntity]: """ Get system provider models. @@ -1587,6 +1626,8 @@ class ProviderConfiguration(BaseModel): for m in provider_schema.models: if m.model_type != model_type: continue + if model and m.model != model: + continue status = ModelStatus.ACTIVE if m.model_type in model_setting_map and m.model in model_setting_map[m.model_type]: @@ -1608,13 +1649,9 @@ class ProviderConfiguration(BaseModel): ) ) - if self.provider.provider not in original_provider_configurate_methods: - original_provider_configurate_methods[self.provider.provider] = [] - for configurate_method in provider_schema.configurate_methods: - original_provider_configurate_methods[self.provider.provider].append(configurate_method) - + original_configurate_methods = self._get_original_provider_configurate_methods() should_use_custom_model = False - if original_provider_configurate_methods[self.provider.provider] == [ConfigurateMethod.CUSTOMIZABLE_MODEL]: + if original_configurate_methods == [ConfigurateMethod.CUSTOMIZABLE_MODEL]: should_use_custom_model = True for quota_configuration in self.system_configuration.quota_configurations: @@ -1626,11 +1663,12 @@ class ProviderConfiguration(BaseModel): break if should_use_custom_model: - if original_provider_configurate_methods[self.provider.provider] == [ - ConfigurateMethod.CUSTOMIZABLE_MODEL - ]: + if original_configurate_methods == [ConfigurateMethod.CUSTOMIZABLE_MODEL]: # only customizable model for restrict_model in restrict_models: + if model and restrict_model.model != model: + continue + copy_credentials = ( self.system_configuration.credentials.copy() if self.system_configuration.credentials @@ -1680,11 +1718,11 @@ class ProviderConfiguration(BaseModel): # if llm name not in restricted llm list, remove it restrict_model_names = [rm.model for rm in restrict_models] - for model in provider_models: - if model.model_type == ModelType.LLM and model.model not in restrict_model_names: - model.status = ModelStatus.NO_PERMISSION + for provider_model in provider_models: + if provider_model.model_type == ModelType.LLM and provider_model.model not in restrict_model_names: + provider_model.status = ModelStatus.NO_PERMISSION elif not quota_configuration.is_valid: - model.status = ModelStatus.QUOTA_EXCEEDED + provider_model.status = ModelStatus.QUOTA_EXCEEDED return provider_models @@ -1709,6 +1747,13 @@ class ProviderConfiguration(BaseModel): if self.custom_configuration.provider: credentials = self.custom_configuration.provider.credentials + requested_predefined_model = False + if model: + requested_predefined_model = any( + predefined_model.model_type in model_types and predefined_model.model == model + for predefined_model in provider_schema.models + ) + for model_type in model_types: if model_type not in self.provider.supported_model_types: continue @@ -1716,6 +1761,8 @@ class ProviderConfiguration(BaseModel): for m in provider_schema.models: if m.model_type != model_type: continue + if requested_predefined_model and model and m.model != model: + continue status = ModelStatus.ACTIVE if credentials else ModelStatus.NO_CONFIGURE load_balancing_enabled = False diff --git a/api/core/entities/provider_entities.py b/api/core/entities/provider_entities.py index 72b29c2277f..ad9a1f4a02a 100644 --- a/api/core/entities/provider_entities.py +++ b/api/core/entities/provider_entities.py @@ -88,7 +88,7 @@ class SystemConfiguration(BaseModel): enabled: bool current_quota_type: ProviderQuotaType | None = None quota_configurations: list[QuotaConfiguration] = [] - credentials: dict[str, Any] | None = None + credentials: dict[str, Any] | None = Field(default=None) class CustomProviderConfiguration(BaseModel): diff --git a/api/core/helper/marketplace.py b/api/core/helper/marketplace.py index d7b6e820625..2c7d520250c 100644 --- a/api/core/helper/marketplace.py +++ b/api/core/helper/marketplace.py @@ -12,6 +12,8 @@ from extensions.ext_redis import redis_client marketplace_api_url = URL(str(dify_config.MARKETPLACE_API_URL)) logger = logging.getLogger(__name__) +MARKETPLACE_TIMEOUT = 30 + def get_plugin_pkg_url(plugin_unique_identifier: str) -> str: return str((marketplace_api_url / "api/v1/plugins/download").with_query(unique_identifier=plugin_unique_identifier)) @@ -26,7 +28,12 @@ def batch_fetch_plugin_manifests(plugin_ids: list[str]) -> Sequence[MarketplaceP return [] url = str(marketplace_api_url / "api/v1/plugins/batch") - response = httpx.post(url, json={"plugin_ids": plugin_ids}, headers={"X-Dify-Version": dify_config.project.version}) + response = httpx.post( + url, + json={"plugin_ids": plugin_ids}, + headers={"X-Dify-Version": dify_config.project.version}, + timeout=MARKETPLACE_TIMEOUT, + ) response.raise_for_status() return [MarketplacePluginDeclaration.model_validate(plugin) for plugin in response.json()["data"]["plugins"]] @@ -37,7 +44,12 @@ def batch_fetch_plugin_by_ids(plugin_ids: list[str]) -> list[dict]: return [] url = str(marketplace_api_url / "api/v1/plugins/batch") - response = httpx.post(url, json={"plugin_ids": plugin_ids}, headers={"X-Dify-Version": dify_config.project.version}) + response = httpx.post( + url, + json={"plugin_ids": plugin_ids}, + headers={"X-Dify-Version": dify_config.project.version}, + timeout=MARKETPLACE_TIMEOUT, + ) response.raise_for_status() data = response.json() @@ -46,7 +58,7 @@ def batch_fetch_plugin_by_ids(plugin_ids: list[str]) -> list[dict]: def record_install_plugin_event(plugin_unique_identifier: str): url = str(marketplace_api_url / "api/v1/stats/plugins/install_count") - response = httpx.post(url, json={"unique_identifier": plugin_unique_identifier}) + response = httpx.post(url, json={"unique_identifier": plugin_unique_identifier}, timeout=MARKETPLACE_TIMEOUT) response.raise_for_status() @@ -64,7 +76,7 @@ def fetch_global_plugin_manifest(cache_key_prefix: str, cache_ttl: int) -> None: Exception: If any other error occurs during fetching or caching """ url = str(marketplace_api_url / "api/v1/dist/plugins/manifest.json") - response = httpx.get(url, headers={"X-Dify-Version": dify_config.project.version}, timeout=30) + response = httpx.get(url, headers={"X-Dify-Version": dify_config.project.version}, timeout=MARKETPLACE_TIMEOUT) response.raise_for_status() raw_json = response.json() diff --git a/api/core/llm_generator/entities.py b/api/core/llm_generator/entities.py index 3bb8d2c8993..81aa9f0698d 100644 --- a/api/core/llm_generator/entities.py +++ b/api/core/llm_generator/entities.py @@ -7,7 +7,11 @@ from core.app.app_config.entities import ModelConfig class RuleGeneratePayload(BaseModel): instruction: str = Field(..., description="Rule generation instruction") - model_config_data: ModelConfig = Field(..., alias="model_config", description="Model configuration") + model_config_data: ModelConfig = Field( + ..., + alias="model_config", + description="Model configuration", + ) no_variable: bool = Field(default=False, description="Whether to exclude variables") @@ -17,4 +21,8 @@ class RuleCodeGeneratePayload(RuleGeneratePayload): class RuleStructuredOutputPayload(BaseModel): instruction: str = Field(..., description="Structured output generation instruction") - model_config_data: ModelConfig = Field(..., alias="model_config", description="Model configuration") + model_config_data: ModelConfig = Field( + ..., + alias="model_config", + description="Model configuration", + ) diff --git a/api/core/llm_generator/llm_generator.py b/api/core/llm_generator/llm_generator.py index 30b9523146f..b2073716d13 100644 --- a/api/core/llm_generator/llm_generator.py +++ b/api/core/llm_generator/llm_generator.py @@ -107,7 +107,7 @@ class LLMGenerator: tenant_id=tenant_id, model_type=ModelType.LLM, ) - prompts = [UserPromptMessage(content=prompt)] + prompts: list[PromptMessage] = [UserPromptMessage(content=prompt)] with measure_time() as timer: response: LLMResult = model_instance.invoke_llm( @@ -201,11 +201,13 @@ class LLMGenerator: except InvokeAuthorizationError: return [] - prompt_messages = [UserPromptMessage(content=prompt)] + prompt_messages: list[PromptMessage] = [UserPromptMessage(content=prompt)] questions: Sequence[str] = [] try: + model_parameters: dict[str, object] + stop: list[str] configured_completion_params = configured_model.get("completion_params") if use_configured_model and isinstance(configured_completion_params, dict): model_parameters, stop = _normalize_completion_params(configured_completion_params) @@ -253,7 +255,7 @@ class LLMGenerator: remove_template_variables=False, ) - prompt_messages = [UserPromptMessage(content=prompt_generate)] + no_variable_prompt_messages: list[PromptMessage] = [UserPromptMessage(content=prompt_generate)] model_manager = ModelManager.for_tenant(tenant_id=tenant_id) @@ -266,7 +268,7 @@ class LLMGenerator: try: response: LLMResult = model_instance.invoke_llm( - prompt_messages=list(prompt_messages), model_parameters=model_parameters, stream=False + prompt_messages=list(no_variable_prompt_messages), model_parameters=model_parameters, stream=False ) rule_config["prompt"] = response.message.get_text_content() @@ -299,7 +301,7 @@ class LLMGenerator: }, remove_template_variables=False, ) - prompt_messages = [UserPromptMessage(content=prompt_generate_prompt)] + prompt_generate_messages: list[PromptMessage] = [UserPromptMessage(content=prompt_generate_prompt)] # get model instance model_manager = ModelManager.for_tenant(tenant_id=tenant_id) @@ -314,7 +316,7 @@ class LLMGenerator: try: # the first step to generate the task prompt prompt_content: LLMResult = model_instance.invoke_llm( - prompt_messages=list(prompt_messages), model_parameters=model_parameters, stream=False + prompt_messages=list(prompt_generate_messages), model_parameters=model_parameters, stream=False ) except InvokeError as e: error = str(e) @@ -331,7 +333,7 @@ class LLMGenerator: }, remove_template_variables=False, ) - parameter_messages = [UserPromptMessage(content=parameter_generate_prompt)] + parameter_messages: list[PromptMessage] = [UserPromptMessage(content=parameter_generate_prompt)] # the second step to generate the task_parameter and task_statement statement_generate_prompt = statement_template.format( @@ -341,7 +343,7 @@ class LLMGenerator: }, remove_template_variables=False, ) - statement_messages = [UserPromptMessage(content=statement_generate_prompt)] + statement_messages: list[PromptMessage] = [UserPromptMessage(content=statement_generate_prompt)] try: parameter_content: LLMResult = model_instance.invoke_llm( @@ -397,7 +399,7 @@ class LLMGenerator: model=args.model_config_data.name, ) - prompt_messages = [UserPromptMessage(content=prompt)] + prompt_messages: list[PromptMessage] = [UserPromptMessage(content=prompt)] model_parameters = args.model_config_data.completion_params try: response: LLMResult = model_instance.invoke_llm( @@ -455,7 +457,7 @@ class LLMGenerator: model=args.model_config_data.name, ) - prompt_messages = [ + prompt_messages: list[PromptMessage] = [ SystemPromptMessage(content=SYSTEM_STRUCTURED_OUTPUT_GENERATE), UserPromptMessage(content=args.instruction), ] @@ -634,7 +636,7 @@ class LLMGenerator: system_prompt = LLM_MODIFY_CODE_SYSTEM case _: system_prompt = LLM_MODIFY_PROMPT_SYSTEM - prompt_messages = [ + prompt_messages: list[PromptMessage] = [ SystemPromptMessage(content=system_prompt), UserPromptMessage( content=json.dumps( diff --git a/api/core/llm_generator/output_parser/structured_output.py b/api/core/llm_generator/output_parser/structured_output.py index 6cba4fbdf66..f2e98244197 100644 --- a/api/core/llm_generator/output_parser/structured_output.py +++ b/api/core/llm_generator/output_parser/structured_output.py @@ -166,12 +166,13 @@ def invoke_llm_with_structured_output( prompt_messages = event.prompt_messages system_fingerprint = event.system_fingerprint - if isinstance(event.delta.message.content, str): - result_text += event.delta.message.content - elif isinstance(event.delta.message.content, list): - for item in event.delta.message.content: - if isinstance(item, TextPromptMessageContent): - result_text += item.data + match event.delta.message.content: + case str(): + result_text += event.delta.message.content + case list(): + for item in event.delta.message.content: + if isinstance(item, TextPromptMessageContent): + result_text += item.data yield LLMResultChunkWithStructuredOutput( model=model_schema.model, diff --git a/api/core/mcp/client/sse_client.py b/api/core/mcp/client/sse_client.py index 19d977c8e58..28ecf290c32 100644 --- a/api/core/mcp/client/sse_client.py +++ b/api/core/mcp/client/sse_client.py @@ -211,12 +211,13 @@ class SSETransport: except queue.Empty: raise ValueError("failed to get endpoint URL") - if isinstance(status, _StatusReady): - return status.endpoint_url - elif isinstance(status, _StatusError): - raise status.exc - else: - raise ValueError("failed to get endpoint URL") + match status: + case _StatusReady(): + return status.endpoint_url + case _StatusError(): + raise status.exc + case _: + raise ValueError("failed to get endpoint URL") def connect( self, diff --git a/api/core/mcp/session/client_session.py b/api/core/mcp/session/client_session.py index f91295a4323..1f1f574afab 100644 --- a/api/core/mcp/session/client_session.py +++ b/api/core/mcp/session/client_session.py @@ -41,10 +41,11 @@ class MessageHandlerFnT(Protocol): def _default_message_handler( message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, ): - if isinstance(message, Exception): - raise ValueError(str(message)) - elif isinstance(message, (types.ServerNotification | RequestResponder)): - pass + match message: + case Exception(): + raise ValueError(str(message)) + case types.ServerNotification() | RequestResponder(): + pass def _default_sampling_callback( diff --git a/api/core/ops/entities/trace_entity.py b/api/core/ops/entities/trace_entity.py index 98e87a0ceb0..e183ad7f340 100644 --- a/api/core/ops/entities/trace_entity.py +++ b/api/core/ops/entities/trace_entity.py @@ -62,15 +62,16 @@ class BaseTraceInfo(BaseModel): parent_span_id_source is the outer node_execution_id. """ parent_ctx = self.metadata.get("parent_trace_context") - if isinstance(parent_ctx, ParentTraceContext): - context = parent_ctx - elif isinstance(parent_ctx, Mapping): - try: - context = ParentTraceContext.model_validate(parent_ctx) - except ValueError: + match parent_ctx: + case ParentTraceContext(): + context = parent_ctx + case Mapping(): + try: + context = ParentTraceContext.model_validate(parent_ctx) + except ValueError: + return None, None + case _: return None, None - else: - return None, None return ( context.parent_workflow_run_id, context.parent_node_execution_id, diff --git a/api/core/ops/utils.py b/api/core/ops/utils.py index a6f10c09acc..50cccd9d088 100644 --- a/api/core/ops/utils.py +++ b/api/core/ops/utils.py @@ -38,18 +38,19 @@ def measure_time(): def replace_text_with_content(data): - if isinstance(data, dict): - new_data = {} - for key, value in data.items(): - if key == "text": - new_data["content"] = value - else: - new_data[key] = replace_text_with_content(value) - return new_data - elif isinstance(data, list): - return [replace_text_with_content(item) for item in data] - else: - return data + match data: + case dict(): + new_data = {} + for key, value in data.items(): + if key == "text": + new_data["content"] = value + else: + new_data[key] = replace_text_with_content(value) + return new_data + case list(): + return [replace_text_with_content(item) for item in data] + case _: + return data def generate_dotted_order(run_id: str, start_time: Union[str, datetime], parent_dotted_order: str | None = None) -> str: diff --git a/api/core/plugin/entities/parameters.py b/api/core/plugin/entities/parameters.py index ce5813a2944..ba305690664 100644 --- a/api/core/plugin/entities/parameters.py +++ b/api/core/plugin/entities/parameters.py @@ -132,13 +132,14 @@ def cast_parameter_value(typ: StrEnum, value: Any, /): return value if isinstance(value, bool) else bool(value) case PluginParameterType.NUMBER: - if isinstance(value, int | float): - return value - elif isinstance(value, str) and value: - if "." in value: - return float(value) - else: - return int(value) + match value: + case int() | float(): + return value + case str() if value: + if "." in value: + return float(value) + else: + return int(value) case PluginParameterType.SYSTEM_FILES | PluginParameterType.FILES: if not isinstance(value, list): return [value] diff --git a/api/core/plugin/entities/plugin_daemon.py b/api/core/plugin/entities/plugin_daemon.py index 257638ad77c..507a6ea5cd3 100644 --- a/api/core/plugin/entities/plugin_daemon.py +++ b/api/core/plugin/entities/plugin_daemon.py @@ -168,6 +168,7 @@ class PluginInstallTask(BasePluginEntity): class PluginInstallTaskStartResponse(BaseModel): all_installed: bool = Field(description="Whether all plugins are installed.") task_id: str = Field(description="The ID of the install task.") + task: PluginInstallTask | None = Field(default=None, description="The install task.") class PluginVerification(BaseModel): @@ -206,6 +207,11 @@ class PluginListResponse(BaseModel): total: int +class PluginListWithoutTotalResponse(BaseModel): + list: list[PluginEntity] + has_more: bool + + class PluginDynamicSelectOptionsResponse(BaseModel): options: Sequence[PluginParameterOption] = Field(description="The options of the dynamic select.") diff --git a/api/core/plugin/impl/base.py b/api/core/plugin/impl/base.py index c034662cf4f..7a74b89cf51 100644 --- a/api/core/plugin/impl/base.py +++ b/api/core/plugin/impl/base.py @@ -45,12 +45,13 @@ _plugin_daemon_timeout_config = cast( getattr(dify_config, "PLUGIN_DAEMON_TIMEOUT", 600.0), ) plugin_daemon_request_timeout: httpx.Timeout | None -if _plugin_daemon_timeout_config is None: - plugin_daemon_request_timeout = None -elif isinstance(_plugin_daemon_timeout_config, httpx.Timeout): - plugin_daemon_request_timeout = _plugin_daemon_timeout_config -else: - plugin_daemon_request_timeout = httpx.Timeout(_plugin_daemon_timeout_config) +match _plugin_daemon_timeout_config: + case None: + plugin_daemon_request_timeout = None + case httpx.Timeout(): + plugin_daemon_request_timeout = _plugin_daemon_timeout_config + case _: + plugin_daemon_request_timeout = httpx.Timeout(_plugin_daemon_timeout_config) logger = logging.getLogger(__name__) diff --git a/api/core/plugin/impl/plugin.py b/api/core/plugin/impl/plugin.py index 8a7175bb51f..34e8d315d86 100644 --- a/api/core/plugin/impl/plugin.py +++ b/api/core/plugin/impl/plugin.py @@ -6,6 +6,7 @@ from requests import HTTPError from core.plugin.entities.bundle import PluginBundleDependency from core.plugin.entities.plugin import ( MissingPluginDependency, + PluginCategory, PluginDeclaration, PluginEntity, PluginInstallation, @@ -16,6 +17,7 @@ from core.plugin.entities.plugin_daemon import ( PluginInstallTask, PluginInstallTaskStartResponse, PluginListResponse, + PluginListWithoutTotalResponse, PluginReadmeResponse, ) from core.plugin.impl.base import BasePluginClient @@ -74,6 +76,16 @@ class PluginInstaller(BasePluginClient): params={"page": page, "page_size": page_size, "response_type": "paged"}, ) + def list_plugins_by_category( + self, tenant_id: str, category: PluginCategory, page: int, page_size: int + ) -> PluginListWithoutTotalResponse: + return self._request_with_plugin_daemon_response( + "GET", + f"plugin/{tenant_id}/management/{category.value}/list", + PluginListWithoutTotalResponse, + params={"page": page, "page_size": page_size, "response_type": "paged"}, + ) + def upload_pkg( self, tenant_id: str, diff --git a/api/core/plugin/plugin_service.py b/api/core/plugin/plugin_service.py index 50b35afbcd0..2ab3f87db72 100644 --- a/api/core/plugin/plugin_service.py +++ b/api/core/plugin/plugin_service.py @@ -13,8 +13,10 @@ metadata. """ import logging +import time from collections.abc import Mapping, Sequence from mimetypes import guess_type +from typing import ClassVar from pydantic import BaseModel, TypeAdapter, ValidationError from redis import RedisError @@ -29,6 +31,7 @@ from core.helper.marketplace import download_plugin_pkg from core.helper.model_provider_cache import ProviderCredentialsCache, ProviderCredentialsCacheType from core.plugin.entities.bundle import PluginBundleDependency from core.plugin.entities.plugin import ( + PluginCategory, PluginDeclaration, PluginEntity, PluginInstallation, @@ -39,6 +42,7 @@ from core.plugin.entities.plugin_daemon import ( PluginInstallTask, PluginInstallTaskStatus, PluginListResponse, + PluginListWithoutTotalResponse, PluginModelProviderEntity, PluginVerification, ) @@ -64,6 +68,8 @@ _provider_entities_adapter: TypeAdapter[list[ProviderEntity]] = TypeAdapter(list class PluginService: + _plugin_model_providers_memory_cache: ClassVar[dict[str, tuple[int, float, tuple[ProviderEntity, ...]]]] = {} + class LatestPluginCache(BaseModel): plugin_id: str version: str @@ -75,14 +81,22 @@ class PluginService: REDIS_KEY_PREFIX = "plugin_service:latest_plugin:" REDIS_TTL = 60 * 5 # 5 minutes PLUGIN_MODEL_PROVIDERS_REDIS_KEY_PREFIX = "plugin_model_providers:tenant_id:" + PLUGIN_MODEL_PROVIDERS_GENERATION_REDIS_KEY_PREFIX = "plugin_model_providers_generation:tenant_id:" PLUGIN_INSTALL_TASK_TERMINAL_STATUSES = (PluginInstallTaskStatus.Success, PluginInstallTaskStatus.Failed) # Mirror the detail-panel endpoint query size so list reconciliation and # the visible endpoint drawer exercise the same daemon pagination path. ENDPOINT_RECONCILIATION_PAGE_SIZE = 100 @classmethod - def _get_plugin_model_providers_cache_key(cls, tenant_id: str) -> str: - return f"{cls.PLUGIN_MODEL_PROVIDERS_REDIS_KEY_PREFIX}{tenant_id}" + def _get_plugin_model_providers_cache_key(cls, tenant_id: str, generation: int | None = None) -> str: + if generation is None: + return f"{cls.PLUGIN_MODEL_PROVIDERS_REDIS_KEY_PREFIX}{tenant_id}" + + return f"{cls.PLUGIN_MODEL_PROVIDERS_REDIS_KEY_PREFIX}{tenant_id}:generation:{generation}" + + @classmethod + def _get_plugin_model_providers_generation_cache_key(cls, tenant_id: str) -> str: + return f"{cls.PLUGIN_MODEL_PROVIDERS_GENERATION_REDIS_KEY_PREFIX}{tenant_id}" @staticmethod def _get_provider_short_name_alias(provider: PluginModelProviderEntity) -> str: @@ -115,29 +129,133 @@ class PluginService: return declaration @classmethod - def _load_cached_plugin_model_providers(cls, tenant_id: str) -> tuple[ProviderEntity, ...] | None: - cache_key = cls._get_plugin_model_providers_cache_key(tenant_id) + def _copy_provider_entities(cls, providers: Sequence[ProviderEntity]) -> tuple[ProviderEntity, ...]: + return tuple(provider.model_copy(deep=True) for provider in providers) + + @classmethod + def _load_plugin_model_providers_generation(cls, tenant_id: str) -> int | None: + cache_key = cls._get_plugin_model_providers_generation_cache_key(tenant_id) try: - cached_providers = redis_client.get(cache_key) + cached_generation = redis_client.get(cache_key) + except (RedisError, RuntimeError): + logger.warning("Failed to read plugin model provider generation for tenant %s.", tenant_id, exc_info=True) + return None + + if cached_generation is None: + return 0 + + try: + return int(cached_generation) + except (TypeError, ValueError): + logger.warning( + "Invalid plugin model provider generation for tenant %s; deleting cache marker.", + tenant_id, + exc_info=True, + ) + try: + redis_client.delete(cache_key) + except (RedisError, RuntimeError): + logger.warning( + "Failed to delete invalid plugin model provider generation for tenant %s.", + tenant_id, + exc_info=True, + ) + return None + + @classmethod + def _load_in_memory_plugin_model_providers( + cls, memory_cache_key: str, generation: int + ) -> tuple[ProviderEntity, ...] | None: + cached_entry = cls._plugin_model_providers_memory_cache.get(memory_cache_key) + if cached_entry is None: + return None + + cached_generation, expires_at, providers = cached_entry + if cached_generation != generation or time.monotonic() >= expires_at: + cls._plugin_model_providers_memory_cache.pop(memory_cache_key, None) + return None + + return cls._copy_provider_entities(providers) + + @classmethod + def _store_in_memory_plugin_model_providers( + cls, memory_cache_key: str, generation: int, providers: Sequence[ProviderEntity] + ) -> None: + ttl = dify_config.PLUGIN_MODEL_PROVIDERS_CACHE_TTL + if ttl <= 0: + cls._plugin_model_providers_memory_cache.pop(memory_cache_key, None) + return + + cls._plugin_model_providers_memory_cache[memory_cache_key] = ( + generation, + time.monotonic() + ttl, + cls._copy_provider_entities(providers), + ) + + @classmethod + def _load_cached_plugin_model_providers( + cls, tenant_id: str, *, client: PluginModelClient | None = None + ) -> tuple[ProviderEntity, ...] | None: + generation = cls._load_plugin_model_providers_generation(tenant_id) + if generation is not None: + in_memory_cached_providers = cls._load_in_memory_plugin_model_providers(tenant_id, generation) + if in_memory_cached_providers is not None: + return in_memory_cached_providers + + cache_keys = [] + if generation is not None: + cache_keys.append(cls._get_plugin_model_providers_cache_key(tenant_id, generation)) + if generation == 0: + cache_keys.append(cls._get_plugin_model_providers_cache_key(tenant_id)) + + if not cache_keys: + return None + + try: + cached_provider_entries = redis_client.mget(cache_keys) except (RedisError, RuntimeError): logger.warning("Failed to read cached plugin model providers for tenant %s.", tenant_id, exc_info=True) return None - if not cached_providers: + if len(cached_provider_entries) != len(cache_keys): + logger.warning( + "Unexpected cached plugin model providers response size for tenant %s.", + tenant_id, + ) return None - try: - return tuple(_provider_entities_adapter.validate_json(cached_providers)) - except (TypeError, ValueError, ValidationError): - logger.warning( - "Invalid cached plugin model providers for tenant %s; deleting cache.", tenant_id, exc_info=True - ) - cls.invalidate_plugin_model_providers_cache(tenant_id) - return None + for cache_key, cached_providers in zip(cache_keys, cached_provider_entries): + if not cached_providers: + continue + + try: + providers = tuple(_provider_entities_adapter.validate_json(cached_providers)) + if generation is not None: + cls._store_in_memory_plugin_model_providers(tenant_id, generation, providers) + return providers + except (TypeError, ValueError, ValidationError): + logger.warning( + "Invalid cached plugin model providers for tenant %s; deleting cache key %s.", + tenant_id, + cache_key, + exc_info=True, + ) + try: + redis_client.delete(cache_key) + except (RedisError, RuntimeError): + logger.warning( + "Failed to delete invalid cached plugin model providers for tenant %s.", + tenant_id, + exc_info=True, + ) + + return None @classmethod - def _store_cached_plugin_model_providers(cls, tenant_id: str, providers: Sequence[ProviderEntity]) -> None: - cache_key = cls._get_plugin_model_providers_cache_key(tenant_id) + def _store_cached_plugin_model_providers( + cls, tenant_id: str, generation: int, providers: Sequence[ProviderEntity] + ) -> None: + cache_key = cls._get_plugin_model_providers_cache_key(tenant_id, generation) try: payload = _provider_entities_adapter.dump_json(list(providers)).decode("utf-8") redis_client.setex(cache_key, dify_config.PLUGIN_MODEL_PROVIDERS_CACHE_TTL, payload) @@ -146,9 +264,15 @@ class PluginService: @classmethod def invalidate_plugin_model_providers_cache(cls, tenant_id: str) -> None: - """Delete the tenant-scoped plugin model provider list cache.""" + """Invalidate tenant-scoped provider metadata across Redis and worker-local mirrors.""" + cls._plugin_model_providers_memory_cache.pop(tenant_id, None) + cache_key = cls._get_plugin_model_providers_cache_key(tenant_id) + generation_key = cls._get_plugin_model_providers_generation_cache_key(tenant_id) try: - redis_client.delete(cls._get_plugin_model_providers_cache_key(tenant_id)) + pipe = redis_client.pipeline(transaction=False) + pipe.delete(cache_key) + pipe.incr(generation_key) + pipe.execute() except (RedisError, RuntimeError): logger.warning("Failed to invalidate plugin model providers cache for tenant %s.", tenant_id, exc_info=True) @@ -163,7 +287,7 @@ class PluginService: are intentionally owned by this service so tenant isolation and cache expiry are handled in one place. """ - cached_providers = cls._load_cached_plugin_model_providers(tenant_id) + cached_providers = cls._load_cached_plugin_model_providers(tenant_id, client=client) if cached_providers is not None: return cached_providers @@ -171,7 +295,12 @@ class PluginService: providers = tuple( cls._to_provider_entity(provider) for provider in model_client.fetch_model_providers(tenant_id) ) - cls._store_cached_plugin_model_providers(tenant_id, providers) + if not providers: + return providers + generation = cls._load_plugin_model_providers_generation(tenant_id) + if generation is not None: + cls._store_in_memory_plugin_model_providers(tenant_id, generation, providers) + cls._store_cached_plugin_model_providers(tenant_id, generation, providers) return providers @staticmethod @@ -310,6 +439,19 @@ class PluginService: PluginService._reconcile_endpoint_counts(tenant_id, user_id, plugins.list) return plugins + @staticmethod + def list_by_category( + tenant_id: str, category: PluginCategory, page: int, page_size: int + ) -> PluginListWithoutTotalResponse: + """ + List plugins in one category with a has-more cursor signal and without calculating total. + + The daemon scans tenant installations in the existing list order and stops once it finds one extra match. + This keeps pagination usable before category is persisted on installation rows. + """ + manager = PluginInstaller() + return manager.list_plugins_by_category(tenant_id, category, page, page_size) + @staticmethod def _normalize_endpoint_count(value: object) -> int: """Convert daemon endpoint counters to safe non-negative integers. diff --git a/api/core/plugin/utils/converter.py b/api/core/plugin/utils/converter.py index 12d8e282b2a..1cd1745f92d 100644 --- a/api/core/plugin/utils/converter.py +++ b/api/core/plugin/utils/converter.py @@ -6,16 +6,13 @@ from graphon.file import File def convert_parameters_to_plugin_format(parameters: dict[str, Any]) -> dict[str, Any]: for parameter_name, parameter in parameters.items(): - if isinstance(parameter, File): - parameters[parameter_name] = parameter.to_plugin_parameter() - elif isinstance(parameter, list) and all(isinstance(p, File) for p in parameter): - parameters[parameter_name] = [] - for p in parameter: - parameters[parameter_name].append(p.to_plugin_parameter()) - elif isinstance(parameter, ToolSelector): - parameters[parameter_name] = parameter.to_plugin_parameter() - elif isinstance(parameter, list) and all(isinstance(p, ToolSelector) for p in parameter): - parameters[parameter_name] = [] - for p in parameter: - parameters[parameter_name].append(p.to_plugin_parameter()) + match parameter: + case File(): + parameters[parameter_name] = parameter.to_plugin_parameter() + case [*items] if all(isinstance(p, File) for p in items): + parameters[parameter_name] = [p.to_plugin_parameter() for p in items] + case ToolSelector(): + parameters[parameter_name] = parameter.to_plugin_parameter() + case [*items] if all(isinstance(p, ToolSelector) for p in items): + parameters[parameter_name] = [p.to_plugin_parameter() for p in items] return parameters diff --git a/api/core/prompt/advanced_prompt_transform.py b/api/core/prompt/advanced_prompt_transform.py index 5a9914e6e4c..e7c88811fe5 100644 --- a/api/core/prompt/advanced_prompt_transform.py +++ b/api/core/prompt/advanced_prompt_transform.py @@ -50,32 +50,33 @@ class AdvancedPromptTransform(PromptTransform): ) -> list[PromptMessage]: prompt_messages = [] - if isinstance(prompt_template, CompletionModelPromptTemplate): - prompt_messages = self._get_completion_model_prompt_messages( - prompt_template=prompt_template, - inputs=inputs, - query=query, - files=files, - context=context, - memory_config=memory_config, - memory=memory, - model_config=model_config, - model_instance=model_instance, - image_detail_config=image_detail_config, - ) - elif isinstance(prompt_template, list) and all(isinstance(item, ChatModelMessage) for item in prompt_template): - prompt_messages = self._get_chat_model_prompt_messages( - prompt_template=prompt_template, - inputs=inputs, - query=query, - files=files, - context=context, - memory_config=memory_config, - memory=memory, - model_config=model_config, - model_instance=model_instance, - image_detail_config=image_detail_config, - ) + match prompt_template: + case CompletionModelPromptTemplate(): + prompt_messages = self._get_completion_model_prompt_messages( + prompt_template=prompt_template, + inputs=inputs, + query=query, + files=files, + context=context, + memory_config=memory_config, + memory=memory, + model_config=model_config, + model_instance=model_instance, + image_detail_config=image_detail_config, + ) + case list() if all(isinstance(item, ChatModelMessage) for item in prompt_template): + prompt_messages = self._get_chat_model_prompt_messages( + prompt_template=prompt_template, + inputs=inputs, + query=query, + files=files, + context=context, + memory_config=memory_config, + memory=memory, + model_config=model_config, + model_instance=model_instance, + image_detail_config=image_detail_config, + ) return prompt_messages diff --git a/api/core/prompt/utils/prompt_message_util.py b/api/core/prompt/utils/prompt_message_util.py index 11414832e38..9dba193f751 100644 --- a/api/core/prompt/utils/prompt_message_util.py +++ b/api/core/prompt/utils/prompt_message_util.py @@ -1,5 +1,5 @@ from collections.abc import Sequence -from typing import Any, cast +from typing import NotRequired, TypedDict, cast from core.prompt.simple_prompt_transform import ModelMode from graphon.model_runtime.entities import ( @@ -13,19 +13,46 @@ from graphon.model_runtime.entities import ( ) +class SavedPromptFile(TypedDict): + type: str + data: str + detail: NotRequired[str] + format: NotRequired[str] + + +class SavedPromptToolCallFunction(TypedDict): + name: str + arguments: str + + +class SavedPromptToolCall(TypedDict): + id: str + type: str + function: SavedPromptToolCallFunction + + +class SavedPrompt(TypedDict): + role: str + text: str + files: NotRequired[list[SavedPromptFile]] + tool_calls: NotRequired[list[SavedPromptToolCall]] + + class PromptMessageUtil: @staticmethod - def prompt_messages_to_prompt_for_saving(model_mode: str, prompt_messages: Sequence[PromptMessage]): + def prompt_messages_to_prompt_for_saving( + model_mode: str, prompt_messages: Sequence[PromptMessage] + ) -> list[SavedPrompt]: """ Prompt messages to prompt for saving. :param model_mode: model mode :param prompt_messages: prompt messages :return: """ - prompts = [] + prompts: list[SavedPrompt] = [] if model_mode == ModelMode.CHAT: - tool_calls = [] for prompt_message in prompt_messages: + tool_calls: list[SavedPromptToolCall] = [] if prompt_message.role == PromptMessageRole.USER: role = "user" elif prompt_message.role == PromptMessageRole.ASSISTANT: @@ -50,7 +77,7 @@ class PromptMessageUtil: continue text = "" - files = [] + files: list[SavedPromptFile] = [] if isinstance(prompt_message.content, list): for content in prompt_message.content: match content: @@ -77,7 +104,7 @@ class PromptMessageUtil: else: text = cast(str, prompt_message.content) - prompt = {"role": role, "text": text, "files": files} + prompt: SavedPrompt = {"role": role, "text": text, "files": files} if tool_calls: prompt["tool_calls"] = tool_calls @@ -86,14 +113,14 @@ class PromptMessageUtil: else: prompt_message = prompt_messages[0] text = "" - files = [] + prompt_files: list[SavedPromptFile] = [] if isinstance(prompt_message.content, list): for content in prompt_message.content: if content.type == PromptMessageContentType.TEXT: text += content.data else: content = cast(ImagePromptMessageContent, content) - files.append( + prompt_files.append( { "type": "image", "data": content.data[:10] + "...[TRUNCATED]..." + content.data[-10:], @@ -103,13 +130,13 @@ class PromptMessageUtil: else: text = cast(str, prompt_message.content) - params: dict[str, Any] = { + params: SavedPrompt = { "role": "user", "text": text, } - if files: - params["files"] = files + if prompt_files: + params["files"] = prompt_files prompts.append(params) diff --git a/api/core/provider_manager.py b/api/core/provider_manager.py index 5cb61d0c7bf..20f117c96a4 100644 --- a/api/core/provider_manager.py +++ b/api/core/provider_manager.py @@ -816,7 +816,7 @@ class ProviderManager: return [ { "model": model_key[0], - "model_type": ModelType.value_of(model_key[1]), + "model_type": ModelType(model_key[1]), "available_model_credentials": [ CredentialConfiguration(credential_id=cred.id, credential_name=cred.credential_name) for cred in creds diff --git a/api/core/rag/datasource/retrieval_service.py b/api/core/rag/datasource/retrieval_service.py index 21eac29f218..85eb06045ac 100644 --- a/api/core/rag/datasource/retrieval_service.py +++ b/api/core/rag/datasource/retrieval_service.py @@ -1,10 +1,12 @@ import concurrent.futures +import functools import logging -from collections.abc import Sequence +from collections.abc import Callable, Sequence from concurrent.futures import ThreadPoolExecutor from typing import Any, NotRequired, TypedDict from flask import Flask, current_app +from opentelemetry import context as otel_context from sqlalchemy import select from sqlalchemy.orm import Session, load_only @@ -25,6 +27,7 @@ from core.rag.rerank.rerank_type import RerankMode from core.rag.retrieval.retrieval_methods import RetrievalMethod from core.tools.signature import sign_upload_file_preview_url from extensions.ext_database import db +from extensions.otel import trace_span from graphon.model_runtime.entities.model_entities import ModelType from models.dataset import ( ChildChunk, @@ -90,9 +93,24 @@ default_retrieval_model: DefaultRetrievalModelDict = { logger = logging.getLogger(__name__) +def _propagate_otel_context[**P, R](func: Callable[P, R]) -> Callable[P, R]: + captured_context = otel_context.get_current() + + @functools.wraps(func) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + token = otel_context.attach(captured_context) + try: + return func(*args, **kwargs) + finally: + otel_context.detach(token) + + return wrapper + + class RetrievalService: # Cache precompiled regular expressions to avoid repeated compilation @classmethod + @trace_span() def retrieve( cls, retrieval_method: RetrievalMethod, @@ -122,7 +140,7 @@ class RetrievalService: if query: futures.append( executor.submit( - retrieval_service._retrieve, + _propagate_otel_context(retrieval_service._retrieve), flask_app=current_app._get_current_object(), # type: ignore retrieval_method=retrieval_method, dataset=dataset, @@ -142,7 +160,7 @@ class RetrievalService: for attachment_id in attachment_ids: futures.append( executor.submit( - retrieval_service._retrieve, + _propagate_otel_context(retrieval_service._retrieve), flask_app=current_app._get_current_object(), # type: ignore retrieval_method=retrieval_method, dataset=dataset, @@ -264,6 +282,7 @@ class RetrievalService: return session.scalar(select(Dataset).where(Dataset.id == dataset_id).limit(1)) @classmethod + @trace_span() def keyword_search( cls, flask_app: Flask, @@ -291,6 +310,7 @@ class RetrievalService: exceptions.append(str(e)) @classmethod + @trace_span() def embedding_search( cls, flask_app: Flask, @@ -392,6 +412,7 @@ class RetrievalService: exceptions.append(str(e)) @classmethod + @trace_span() def full_text_index_search( cls, flask_app: Flask, @@ -754,6 +775,7 @@ class RetrievalService: db.session.rollback() raise e + @trace_span() def _retrieve( self, flask_app: Flask, @@ -780,7 +802,7 @@ class RetrievalService: if retrieval_method == RetrievalMethod.KEYWORD_SEARCH and query: futures.append( executor.submit( - self.keyword_search, + _propagate_otel_context(self.keyword_search), flask_app=current_app._get_current_object(), # type: ignore dataset_id=dataset.id, query=query, @@ -794,7 +816,7 @@ class RetrievalService: if query: futures.append( executor.submit( - self.embedding_search, + _propagate_otel_context(self.embedding_search), flask_app=current_app._get_current_object(), # type: ignore dataset_id=dataset.id, query=query, @@ -811,7 +833,7 @@ class RetrievalService: if attachment_id: futures.append( executor.submit( - self.embedding_search, + _propagate_otel_context(self.embedding_search), flask_app=current_app._get_current_object(), # type: ignore dataset_id=dataset.id, query=attachment_id, @@ -828,7 +850,7 @@ class RetrievalService: if RetrievalMethod.is_support_fulltext_search(retrieval_method) and query: futures.append( executor.submit( - self.full_text_index_search, + _propagate_otel_context(self.full_text_index_search), flask_app=current_app._get_current_object(), # type: ignore dataset_id=dataset.id, query=query, diff --git a/api/core/rag/datasource/vdb/vector_factory.py b/api/core/rag/datasource/vdb/vector_factory.py index cd73bb9b1ac..4d65951d9a9 100644 --- a/api/core/rag/datasource/vdb/vector_factory.py +++ b/api/core/rag/datasource/vdb/vector_factory.py @@ -18,6 +18,7 @@ from core.rag.models.document import Document from extensions.ext_database import db from extensions.ext_redis import redis_client from extensions.ext_storage import storage +from extensions.otel import trace_span from graphon.model_runtime.entities.model_entities import ModelType from models.dataset import Dataset, Whitelist from models.model import UploadFile @@ -244,6 +245,10 @@ class Vector: def search_by_vector(self, query: str, **kwargs: Any) -> list[Document]: query_vector = self._embeddings.embed_query(query) + return self._search_by_vector_traced(query_vector, **kwargs) + + @trace_span() + def _search_by_vector_traced(self, query_vector: list[float], **kwargs) -> list[Document]: return self._vector_processor.search_by_vector(query_vector, **kwargs) def search_by_file(self, file_id: str, **kwargs: Any) -> list[Document]: @@ -260,7 +265,7 @@ class Vector: "file_id": file_id, } ) - return self._vector_processor.search_by_vector(multimodal_vector, **kwargs) + return self._search_by_vector_traced(multimodal_vector, **kwargs) def search_by_full_text(self, query: str, **kwargs: Any) -> list[Document]: return self._vector_processor.search_by_full_text(query, **kwargs) diff --git a/api/core/rag/extractor/watercrawl/client.py b/api/core/rag/extractor/watercrawl/client.py index d1ce142dbdd..1f4adc0d418 100644 --- a/api/core/rag/extractor/watercrawl/client.py +++ b/api/core/rag/extractor/watercrawl/client.py @@ -118,16 +118,18 @@ class WaterCrawlAPIClient(BaseAPIClient): response.raise_for_status() if response.status_code == 204: return None - if response.headers.get("Content-Type") == "application/json": + content_type = response.headers.get("Content-Type", "") + media_type = content_type.split(";", 1)[0].strip().lower() + if media_type == "application/json": return response.json() or {} - if response.headers.get("Content-Type") == "application/octet-stream": + if media_type == "application/octet-stream": return response.content - if response.headers.get("Content-Type") == "text/event-stream": + if media_type == "text/event-stream": return self.process_eventstream(response) - raise Exception(f"Unknown response type: {response.headers.get('Content-Type')}") + raise Exception(f"Unknown response type: {content_type}") def get_crawl_requests_list(self, page: int | None = None, page_size: int | None = None): query_params = {"page": page or 1, "page_size": page_size or 10} @@ -217,7 +219,7 @@ class WaterCrawlAPIClient(BaseAPIClient): return event_data["data"] def download_result(self, result_object: dict[str, Any]): - response = httpx.get(result_object["result"], timeout=None) + response = httpx.get(result_object["result"], timeout=30) try: response.raise_for_status() result_object["result"] = response.json() diff --git a/api/core/rag/extractor/watercrawl/provider.py b/api/core/rag/extractor/watercrawl/provider.py index ae7bebcb9bb..1f129b8ed96 100644 --- a/api/core/rag/extractor/watercrawl/provider.py +++ b/api/core/rag/extractor/watercrawl/provider.py @@ -105,6 +105,8 @@ class WaterCrawlProvider: def scrape_url(self, url: str) -> WatercrawlDocumentData: response = self.client.scrape_url(url=url, sync=True, prefetched=True) + if not isinstance(response, dict): + raise ValueError("Invalid scrape response. Expected a JSON dictionary.") return self._structure_data(response) def _structure_data(self, result_object: dict[str, Any]) -> WatercrawlDocumentData: diff --git a/api/core/rag/extractor/word_extractor.py b/api/core/rag/extractor/word_extractor.py index db38990c146..c2edc7c4a7b 100644 --- a/api/core/rag/extractor/word_extractor.py +++ b/api/core/rag/extractor/word_extractor.py @@ -15,6 +15,8 @@ from urllib.parse import urlparse from docx import Document as DocxDocument from docx.oxml.ns import qn +from docx.table import Table +from docx.text.paragraph import Paragraph from docx.text.run import Run from configs import dify_config @@ -286,10 +288,10 @@ class WordExtractor(BaseExtractor): return "".join(paragraph_content).strip() - def parse_docx(self, docx_path): + def parse_docx(self, docx_path: str) -> str: doc = DocxDocument(docx_path) - content = [] + content: list[str] = [] image_map = self._extract_images_from_docx(doc) @@ -445,18 +447,11 @@ class WordExtractor(BaseExtractor): process_hyperlink(child, paragraph_content) return "".join(paragraph_content) if paragraph_content else "" - paragraphs = doc.paragraphs.copy() - tables = doc.tables.copy() - for element in doc.element.body: - if hasattr(element, "tag"): - if isinstance(element.tag, str) and element.tag.endswith("p"): # paragraph - para = paragraphs.pop(0) - parsed_paragraph = parse_paragraph(para) - if parsed_paragraph.strip(): - content.append(parsed_paragraph) - else: - content.append("\n") - elif isinstance(element.tag, str) and element.tag.endswith("tbl"): # table - table = tables.pop(0) - content.append(self._table_to_markdown(table, image_map)) + for block in doc.iter_inner_content(): + match block: + case Paragraph(): + parsed_paragraph = parse_paragraph(block) + content.append(parsed_paragraph if parsed_paragraph.strip() else "\n") + case Table(): + content.append(self._table_to_markdown(block, image_map)) return "\n".join(content) diff --git a/api/core/rag/index_processor/index_processor_base.py b/api/core/rag/index_processor/index_processor_base.py index b0e66c83fe2..8da401b226a 100644 --- a/api/core/rag/index_processor/index_processor_base.py +++ b/api/core/rag/index_processor/index_processor_base.py @@ -16,11 +16,9 @@ from sqlalchemy import select from configs import dify_config from core.entities.knowledge_entities import PreviewDetail from core.file import remote_fetcher -from core.rag.data_post_processor.data_post_processor import RerankingModelDict from core.rag.extractor.entity.extract_setting import ExtractSetting from core.rag.index_processor.constant.doc_type import DocType from core.rag.models.document import AttachmentDocument, Document -from core.rag.retrieval.retrieval_methods import RetrievalMethod from core.rag.splitter.fixed_text_splitter import ( EnhanceRecursiveCharacterTextSplitter, FixedRecursiveCharacterTextSplitter, @@ -99,18 +97,6 @@ class BaseIndexProcessor(ABC): def format_preview(self, chunks: Any) -> Mapping[str, Any]: raise NotImplementedError - @abstractmethod - def retrieve( - self, - retrieval_method: RetrievalMethod, - query: str, - dataset: Dataset, - top_k: int, - score_threshold: float, - reranking_model: RerankingModelDict, - ) -> list[Document]: - raise NotImplementedError - def _get_splitter( self, processing_rule_mode: str, diff --git a/api/core/rag/index_processor/processor/paragraph_index_processor.py b/api/core/rag/index_processor/processor/paragraph_index_processor.py index 7c7e8ab09da..f68e5a4e6b3 100644 --- a/api/core/rag/index_processor/processor/paragraph_index_processor.py +++ b/api/core/rag/index_processor/processor/paragraph_index_processor.py @@ -5,6 +5,8 @@ import re import uuid from typing import Any, TypedDict, cast, override +from sqlalchemy.orm import scoped_session + logger = logging.getLogger(__name__) from sqlalchemy import select @@ -16,9 +18,7 @@ from core.llm_generator.prompts import DEFAULT_GENERATOR_SUMMARY_PROMPT from core.model_manager import ModelInstance from core.plugin.impl.model_runtime_factory import create_plugin_provider_manager from core.rag.cleaner.clean_processor import CleanProcessor -from core.rag.data_post_processor.data_post_processor import RerankingModelDict from core.rag.datasource.keyword.keyword_factory import Keyword -from core.rag.datasource.retrieval_service import RetrievalService from core.rag.datasource.vdb.vector_factory import Vector from core.rag.docstore.dataset_docstore import DatasetDocumentStore from core.rag.entities import Rule @@ -28,7 +28,6 @@ from core.rag.index_processor.constant.doc_type import DocType from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType from core.rag.index_processor.index_processor_base import BaseIndexProcessor, SummaryIndexSettingDict from core.rag.models.document import AttachmentDocument, Document, MultimodalGeneralStructureChunk -from core.rag.retrieval.retrieval_methods import RetrievalMethod from core.tools.utils.text_processing_utils import remove_leading_symbols from core.workflow.file_reference import build_file_reference from extensions.ext_database import db @@ -182,35 +181,6 @@ class ParagraphIndexProcessor(BaseIndexProcessor): else: keyword.delete() - @override - def retrieve( - self, - retrieval_method: RetrievalMethod, - query: str, - dataset: Dataset, - top_k: int, - score_threshold: float, - reranking_model: RerankingModelDict, - ) -> list[Document]: - # Set search parameters. - results = RetrievalService.retrieve( - retrieval_method=retrieval_method, - dataset_id=dataset.id, - query=query, - top_k=top_k, - score_threshold=score_threshold, - reranking_model=reranking_model, - ) - # Organize results. - docs = [] - for result in results: - metadata = result.metadata - metadata["score"] = result.score - if result.score >= score_threshold: - doc = Document(page_content=result.page_content, metadata=metadata) - docs.append(doc) - return docs - @override def index(self, dataset: Dataset, document: DatasetDocument, chunks: Any) -> None: documents: list[Any] = [] @@ -443,11 +413,13 @@ class ParagraphIndexProcessor(BaseIndexProcessor): if supports_vision: # First, try to get images from SegmentAttachmentBinding (preferred method) if segment_id: - image_files = ParagraphIndexProcessor._extract_images_from_segment_attachments(tenant_id, segment_id) + image_files = ParagraphIndexProcessor._extract_images_from_segment_attachments( + tenant_id, segment_id, db.session + ) # If no images from attachments, fall back to extracting from text if not image_files: - image_files = ParagraphIndexProcessor._extract_images_from_text(tenant_id, text) + image_files = ParagraphIndexProcessor._extract_images_from_text(tenant_id, text, db.session) # Build prompt messages prompt_messages = [] @@ -501,7 +473,7 @@ class ParagraphIndexProcessor(BaseIndexProcessor): return summary_content, usage @staticmethod - def _extract_images_from_text(tenant_id: str, text: str) -> list[File]: + def _extract_images_from_text(tenant_id: str, text: str, session: scoped_session) -> list[File]: """ Extract images from markdown text and convert them to File objects. @@ -550,7 +522,7 @@ class ParagraphIndexProcessor(BaseIndexProcessor): # Get unique IDs for database query unique_upload_file_ids = list(set(upload_file_id_list)) - upload_files = db.session.scalars( + upload_files = session.scalars( select(UploadFile).where(UploadFile.id.in_(unique_upload_file_ids), UploadFile.tenant_id == tenant_id) ).all() @@ -581,7 +553,9 @@ class ParagraphIndexProcessor(BaseIndexProcessor): return file_objects @staticmethod - def _extract_images_from_segment_attachments(tenant_id: str, segment_id: str) -> list[File]: + def _extract_images_from_segment_attachments( + tenant_id: str, segment_id: str, session: scoped_session + ) -> list[File]: """ Extract images from SegmentAttachmentBinding table (preferred method). This matches how DatasetRetrieval gets segment attachments. @@ -596,7 +570,7 @@ class ParagraphIndexProcessor(BaseIndexProcessor): from sqlalchemy import select # Query attachments from SegmentAttachmentBinding table - attachments_with_bindings = db.session.execute( + attachments_with_bindings = session.execute( select(SegmentAttachmentBinding, UploadFile) .join(UploadFile, UploadFile.id == SegmentAttachmentBinding.attachment_id) .where( diff --git a/api/core/rag/index_processor/processor/parent_child_index_processor.py b/api/core/rag/index_processor/processor/parent_child_index_processor.py index bf9145def1d..9c186a9f046 100644 --- a/api/core/rag/index_processor/processor/parent_child_index_processor.py +++ b/api/core/rag/index_processor/processor/parent_child_index_processor.py @@ -12,8 +12,6 @@ from core.db.session_factory import session_factory from core.entities.knowledge_entities import PreviewDetail from core.model_manager import ModelInstance from core.rag.cleaner.clean_processor import CleanProcessor -from core.rag.data_post_processor.data_post_processor import RerankingModelDict -from core.rag.datasource.retrieval_service import RetrievalService from core.rag.datasource.vdb.vector_factory import Vector from core.rag.docstore.dataset_docstore import DatasetDocumentStore from core.rag.entities import ParentMode, Rule @@ -23,7 +21,6 @@ from core.rag.index_processor.constant.doc_type import DocType from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType from core.rag.index_processor.index_processor_base import BaseIndexProcessor, SummaryIndexSettingDict from core.rag.models.document import AttachmentDocument, ChildDocument, Document, ParentChildStructureChunk -from core.rag.retrieval.retrieval_methods import RetrievalMethod from extensions.ext_database import db from libs import helper from models import Account @@ -223,35 +220,6 @@ class ParentChildIndexProcessor(BaseIndexProcessor): ) db.session.commit() - @override - def retrieve( - self, - retrieval_method: RetrievalMethod, - query: str, - dataset: Dataset, - top_k: int, - score_threshold: float, - reranking_model: RerankingModelDict, - ) -> list[Document]: - # Set search parameters. - results = RetrievalService.retrieve( - retrieval_method=retrieval_method, - dataset_id=dataset.id, - query=query, - top_k=top_k, - score_threshold=score_threshold, - reranking_model=reranking_model, - ) - # Organize results. - docs = [] - for result in results: - metadata = result.metadata - metadata["score"] = result.score - if result.score >= score_threshold: - doc = Document(page_content=result.page_content, metadata=metadata) - docs.append(doc) - return docs - def _split_child_nodes( self, document_node: Document, diff --git a/api/core/rag/index_processor/processor/qa_index_processor.py b/api/core/rag/index_processor/processor/qa_index_processor.py index 7d1e7333a8b..253acebc2c6 100644 --- a/api/core/rag/index_processor/processor/qa_index_processor.py +++ b/api/core/rag/index_processor/processor/qa_index_processor.py @@ -15,8 +15,6 @@ from core.db.session_factory import session_factory from core.entities.knowledge_entities import PreviewDetail from core.llm_generator.llm_generator import LLMGenerator from core.rag.cleaner.clean_processor import CleanProcessor -from core.rag.data_post_processor.data_post_processor import RerankingModelDict -from core.rag.datasource.retrieval_service import RetrievalService from core.rag.datasource.vdb.vector_factory import Vector from core.rag.docstore.dataset_docstore import DatasetDocumentStore from core.rag.entities import Rule @@ -25,7 +23,6 @@ from core.rag.extractor.extract_processor import ExtractProcessor from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType from core.rag.index_processor.index_processor_base import BaseIndexProcessor, SummaryIndexSettingDict from core.rag.models.document import AttachmentDocument, Document, QAStructureChunk -from core.rag.retrieval.retrieval_methods import RetrievalMethod from core.tools.utils.text_processing_utils import remove_leading_symbols from libs import helper from models.account import Account @@ -187,35 +184,6 @@ class QAIndexProcessor(BaseIndexProcessor): else: vector.delete() - @override - def retrieve( - self, - retrieval_method: RetrievalMethod, - query: str, - dataset: Dataset, - top_k: int, - score_threshold: float, - reranking_model: RerankingModelDict, - ): - # Set search parameters. - results = RetrievalService.retrieve( - retrieval_method=retrieval_method, - dataset_id=dataset.id, - query=query, - top_k=top_k, - score_threshold=score_threshold, - reranking_model=reranking_model, - ) - # Organize results. - docs = [] - for result in results: - metadata = result.metadata - metadata["score"] = result.score - if result.score >= score_threshold: - doc = Document(page_content=result.page_content, metadata=metadata) - docs.append(doc) - return docs - @override def index(self, dataset: Dataset, document: DatasetDocument, chunks: Any) -> None: qa_chunks = QAStructureChunk.model_validate(chunks) diff --git a/api/core/rag/retrieval/dataset_retrieval.py b/api/core/rag/retrieval/dataset_retrieval.py index f3fd2d4d8ff..f4e850d34ed 100644 --- a/api/core/rag/retrieval/dataset_retrieval.py +++ b/api/core/rag/retrieval/dataset_retrieval.py @@ -609,7 +609,7 @@ class DatasetRetrieval: metadata_filter_document_ids: dict[str, list[str]] | None = None, metadata_condition: MetadataFilteringCondition | None = None, ): - tools = [] + tools: list[PromptMessageTool] = [] for dataset in available_datasets: description = dataset.description if not description: @@ -1162,7 +1162,7 @@ class DatasetRetrieval: :param invoke_from: invoke from :param hit_callback: hit callback """ - tools = [] + tools: list[DatasetRetrieverBaseTool] = [] available_datasets = [] for dataset_id in dataset_ids: # get dataset from dataset id diff --git a/api/core/rbac/__init__.py b/api/core/rbac/__init__.py new file mode 100644 index 00000000000..495ac90f492 --- /dev/null +++ b/api/core/rbac/__init__.py @@ -0,0 +1,3 @@ +from core.rbac.entities import RBACPermission, RBACResourceScope + +__all__ = ["RBACPermission", "RBACResourceScope"] diff --git a/api/core/rbac/entities.py b/api/core/rbac/entities.py new file mode 100644 index 00000000000..5389e16eefc --- /dev/null +++ b/api/core/rbac/entities.py @@ -0,0 +1,37 @@ +from enum import StrEnum + + +class RBACResourceScope(StrEnum): + """Resource scopes accepted by the ``rbac_permission_required`` decorator. + + ``WORKSPACE`` denotes a workspace-level check that carries no concrete + resource id; ``APP`` and ``DATASET`` are resource-scoped checks. + """ + + APP = "app" + DATASET = "dataset" + WORKSPACE = "workspace" + + +class RBACPermission(StrEnum): + """Permission points (RBAC scenes) checked by ``rbac_permission_required``. + + Each member's value is the scene name forwarded to the RBAC + ``check-access`` endpoint. + """ + + APP_VIEW_LAYOUT = "app_view_layout" + APP_TEST_AND_RUN = "app_test_and_run" + APP_CREATE_AND_MANAGEMENT = "app_create_and_management" + APP_RELEASE_AND_VERSION = "app_release_and_version" + APP_IMPORT_EXPORT_DSL = "app_import_export_dsl" + APP_MONITOR = "app_monitor" + APP_DELETE = "app_delete" + + DATASET_READONLY = "dataset_readonly" + DATASET_EDIT = "dataset_edit" + DATASET_CREATE_AND_MANAGEMENT = "dataset_create_and_management" + DATASET_PIPELINE_TEST = "dataset_pipeline_test" + DATASET_DOCUMENT_DOWNLOAD = "dataset_document_download" + + WORKSPACE_ROLE_MANAGE = "workspace_role_manage" diff --git a/api/core/repositories/celery_workflow_execution_repository.py b/api/core/repositories/celery_workflow_execution_repository.py index d65c71abc81..bf7c8d48f8e 100644 --- a/api/core/repositories/celery_workflow_execution_repository.py +++ b/api/core/repositories/celery_workflow_execution_repository.py @@ -61,14 +61,15 @@ class CeleryWorkflowExecutionRepository(WorkflowExecutionRepository): triggered_from: Source of the execution trigger (DEBUGGING or APP_RUN) """ # Store session factory for fallback operations - if isinstance(session_factory, Engine): - self._session_factory = sessionmaker(bind=session_factory, expire_on_commit=False) - elif isinstance(session_factory, sessionmaker): - self._session_factory = session_factory - else: - raise ValueError( - f"Invalid session_factory type {type(session_factory).__name__}; expected sessionmaker or Engine" - ) + match session_factory: + case Engine(): + self._session_factory = sessionmaker(bind=session_factory, expire_on_commit=False) + case sessionmaker(): + self._session_factory = session_factory + case _: + raise ValueError( + f"Invalid session_factory type {type(session_factory).__name__}; expected sessionmaker or Engine" + ) # Extract tenant_id from user tenant_id = extract_tenant_id(user) diff --git a/api/core/repositories/celery_workflow_node_execution_repository.py b/api/core/repositories/celery_workflow_node_execution_repository.py index dc2588b489f..f48d92f8797 100644 --- a/api/core/repositories/celery_workflow_node_execution_repository.py +++ b/api/core/repositories/celery_workflow_node_execution_repository.py @@ -68,14 +68,15 @@ class CeleryWorkflowNodeExecutionRepository(WorkflowNodeExecutionRepository): triggered_from: Source of the execution trigger (SINGLE_STEP or WORKFLOW_RUN) """ # Store session factory for fallback operations - if isinstance(session_factory, Engine): - self._session_factory = sessionmaker(bind=session_factory, expire_on_commit=False) - elif isinstance(session_factory, sessionmaker): - self._session_factory = session_factory - else: - raise ValueError( - f"Invalid session_factory type {type(session_factory).__name__}; expected sessionmaker or Engine" - ) + match session_factory: + case Engine(): + self._session_factory = sessionmaker(bind=session_factory, expire_on_commit=False) + case sessionmaker(): + self._session_factory = session_factory + case _: + raise ValueError( + f"Invalid session_factory type {type(session_factory).__name__}; expected sessionmaker or Engine" + ) # Extract tenant_id from user tenant_id = extract_tenant_id(user) diff --git a/api/core/repositories/human_input_repository.py b/api/core/repositories/human_input_repository.py index 7a97b328388..599cc643ea4 100644 --- a/api/core/repositories/human_input_repository.py +++ b/api/core/repositories/human_input_repository.py @@ -63,6 +63,10 @@ class FormCreateParams: display_in_ui: bool resolved_default_values: Mapping[str, Any] form_kind: HumanInputFormKind = HumanInputFormKind.RUNTIME + # ENG-635: the conversation this form belongs to. Set together with + # workflow_execution_id for chatflow runs; set alone (workflow_execution_id None) + # for Agent v2 chat ask_human forms, which have no workflow run. + conversation_id: str | None = None class HumanInputFormRecipientEntity(Protocol): @@ -217,6 +221,9 @@ class HumanInputFormRecord: recipient_id: str | None recipient_type: RecipientType | None access_token: str | None + # ENG-635: Agent v2 chat owner (NULL for workflow-owned forms). Trailing + + # defaulted so existing record constructions stay source-compatible. + conversation_id: str | None = None @property def submitted(self) -> bool: @@ -232,6 +239,7 @@ class HumanInputFormRecord: return cls( form_id=form_model.id, workflow_run_id=form_model.workflow_run_id, + conversation_id=form_model.conversation_id, node_id=form_model.node_id, tenant_id=form_model.tenant_id, app_id=form_model.app_id, @@ -288,24 +296,25 @@ class HumanInputFormRepositoryImpl: channel_payload=delivery_method.model_dump_json(), ) recipients: list[HumanInputFormRecipient] = [] - if isinstance(delivery_method, InteractiveSurfaceDeliveryMethod): - recipient_model = HumanInputFormRecipient( - form_id=form_id, - delivery_id=delivery_id, - recipient_type=RecipientType.STANDALONE_WEB_APP, - recipient_payload=StandaloneWebAppRecipientPayload().model_dump_json(), - ) - recipients.append(recipient_model) - elif isinstance(delivery_method, EmailDeliveryMethod): - email_recipients_config = delivery_method.config.recipients - recipients.extend( - self._build_email_recipients( - session=session, + match delivery_method: + case InteractiveSurfaceDeliveryMethod(): + recipient_model = HumanInputFormRecipient( form_id=form_id, delivery_id=delivery_id, - recipients_config=email_recipients_config, + recipient_type=RecipientType.STANDALONE_WEB_APP, + recipient_payload=StandaloneWebAppRecipientPayload().model_dump_json(), + ) + recipients.append(recipient_model) + case EmailDeliveryMethod(): + email_recipients_config = delivery_method.config.recipients + recipients.extend( + self._build_email_recipients( + session=session, + form_id=form_id, + delivery_id=delivery_id, + recipients_config=email_recipients_config, + ) ) - ) return _DeliveryAndRecipients(delivery=delivery_model, recipients=recipients) @@ -432,8 +441,15 @@ class HumanInputFormRepositoryImpl: if not app_id: raise ValueError("app_id is required to create a human input form") workflow_execution_id = params.workflow_execution_id or self._workflow_execution_id - if params.form_kind == HumanInputFormKind.RUNTIME and workflow_execution_id is None: - raise ValueError("workflow_execution_id is required for runtime human input forms") + # A RUNTIME form must be owned by at least one of: a workflow run (workflow / + # Human-Input / agent node) or a conversation turn (ENG-635: Agent v2 chat + # ask_human; chatflow runs set both — workflow_run_id and conversation_id). + if ( + params.form_kind == HumanInputFormKind.RUNTIME + and workflow_execution_id is None + and params.conversation_id is None + ): + raise ValueError("a runtime human input form requires a workflow_execution_id or conversation_id") with session_factory.create_session() as session, session.begin(): # Generate unique form ID @@ -455,6 +471,7 @@ class HumanInputFormRepositoryImpl: tenant_id=self._tenant_id, app_id=app_id, workflow_run_id=workflow_execution_id, + conversation_id=params.conversation_id, form_kind=params.form_kind, node_id=params.node_id, form_definition=form_definition.model_dump_json(), diff --git a/api/core/repositories/sqlalchemy_workflow_execution_repository.py b/api/core/repositories/sqlalchemy_workflow_execution_repository.py index 4bca8b34e87..0e9f842731d 100644 --- a/api/core/repositories/sqlalchemy_workflow_execution_repository.py +++ b/api/core/repositories/sqlalchemy_workflow_execution_repository.py @@ -54,14 +54,15 @@ class SQLAlchemyWorkflowExecutionRepository(WorkflowExecutionRepository): triggered_from: Source of the execution trigger (DEBUGGING or APP_RUN) """ # If an engine is provided, create a sessionmaker from it - if isinstance(session_factory, Engine): - self._session_factory = sessionmaker(bind=session_factory, expire_on_commit=False) - elif isinstance(session_factory, sessionmaker): - self._session_factory = session_factory - else: - raise ValueError( - f"Invalid session_factory type {type(session_factory).__name__}; expected sessionmaker or Engine" - ) + match session_factory: + case Engine(): + self._session_factory = sessionmaker(bind=session_factory, expire_on_commit=False) + case sessionmaker(): + self._session_factory = session_factory + case _: + raise ValueError( + f"Invalid session_factory type {type(session_factory).__name__}; expected sessionmaker or Engine" + ) # Extract tenant_id from user tenant_id = extract_tenant_id(user) diff --git a/api/core/repositories/sqlalchemy_workflow_node_execution_repository.py b/api/core/repositories/sqlalchemy_workflow_node_execution_repository.py index 7eda458f85b..65324028f82 100644 --- a/api/core/repositories/sqlalchemy_workflow_node_execution_repository.py +++ b/api/core/repositories/sqlalchemy_workflow_node_execution_repository.py @@ -77,14 +77,15 @@ class SQLAlchemyWorkflowNodeExecutionRepository(WorkflowNodeExecutionRepository) triggered_from: Source of the execution trigger (SINGLE_STEP or WORKFLOW_RUN) """ # If an engine is provided, create a sessionmaker from it - if isinstance(session_factory, Engine): - self._session_factory = sessionmaker(bind=session_factory, expire_on_commit=False) - elif isinstance(session_factory, sessionmaker): - self._session_factory = session_factory - else: - raise ValueError( - f"Invalid session_factory type {type(session_factory).__name__}; expected sessionmaker or Engine" - ) + match session_factory: + case Engine(): + self._session_factory = sessionmaker(bind=session_factory, expire_on_commit=False) + case sessionmaker(): + self._session_factory = session_factory + case _: + raise ValueError( + f"Invalid session_factory type {type(session_factory).__name__}; expected sessionmaker or Engine" + ) # Extract tenant_id from user tenant_id = extract_tenant_id(user) diff --git a/api/core/schemas/resolver.py b/api/core/schemas/resolver.py index e267c1abd9a..cd86aebc060 100644 --- a/api/core/schemas/resolver.py +++ b/api/core/schemas/resolver.py @@ -125,10 +125,11 @@ class SchemaResolver: def _process_queue_item(self, queue: deque, item: QueueItem) -> None: """Process a single queue item""" - if isinstance(item.current, dict): - self._process_dict(queue, item) - elif isinstance(item.current, list): - self._process_list(queue, item) + match item.current: + case dict(): + self._process_dict(queue, item) + case list(): + self._process_list(queue, item) def _process_dict(self, queue: deque, item: QueueItem) -> None: """Process a dictionary item""" diff --git a/api/core/tools/__base/tool.py b/api/core/tools/__base/tool.py index 4d784b5f232..b219ba49575 100644 --- a/api/core/tools/__base/tool.py +++ b/api/core/tools/__base/tool.py @@ -66,20 +66,21 @@ class Tool(ABC): message_id=message_id, ) - if isinstance(result, ToolInvokeMessage): + match result: + case ToolInvokeMessage(): - def single_generator() -> Generator[ToolInvokeMessage, None, None]: - yield result + def single_generator() -> Generator[ToolInvokeMessage, None, None]: + yield result - return single_generator() - elif isinstance(result, list): + return single_generator() + case list(): - def generator() -> Generator[ToolInvokeMessage, None, None]: - yield from result + def generator() -> Generator[ToolInvokeMessage, None, None]: + yield from result - return generator() - else: - return result + return generator() + case _: + return result def _transform_tool_parameters_type(self, tool_parameters: dict[str, Any]) -> dict[str, Any]: """ diff --git a/api/core/tools/__base/tool_provider.py b/api/core/tools/__base/tool_provider.py index 49cbf703787..70e4fe1ff73 100644 --- a/api/core/tools/__base/tool_provider.py +++ b/api/core/tools/__base/tool_provider.py @@ -11,8 +11,10 @@ from core.tools.entities.tool_entities import ( from core.tools.errors import ToolProviderCredentialValidationError -class ToolProviderController(ABC): - def __init__(self, entity: ToolProviderEntity): +class ToolProviderController[ToolProviderEntityT: ToolProviderEntity, ToolProviderToolT: Tool | None](ABC): + entity: ToolProviderEntityT + + def __init__(self, entity: ToolProviderEntityT): self.entity = entity def get_credentials_schema(self) -> list[ProviderConfig]: @@ -24,7 +26,7 @@ class ToolProviderController(ABC): return deepcopy(self.entity.credentials_schema) @abstractmethod - def get_tool(self, tool_name: str) -> Tool: + def get_tool(self, tool_name: str) -> ToolProviderToolT: """ returns a tool that the provider can provide diff --git a/api/core/tools/builtin_tool/provider.py b/api/core/tools/builtin_tool/provider.py index 52d86f06484..a01dbdbeed6 100644 --- a/api/core/tools/builtin_tool/provider.py +++ b/api/core/tools/builtin_tool/provider.py @@ -21,7 +21,7 @@ from core.tools.errors import ( from core.tools.utils.yaml_utils import load_yaml_file_cached -class BuiltinToolProviderController(ToolProviderController): +class BuiltinToolProviderController(ToolProviderController[ToolProviderEntity, BuiltinTool | None]): tools: list[BuiltinTool] def __init__(self, **data: Any): @@ -163,7 +163,8 @@ class BuiltinToolProviderController(ToolProviderController): """ return self._get_builtin_tools() - def get_tool(self, tool_name: str) -> BuiltinTool | None: # type: ignore + @override + def get_tool(self, tool_name: str) -> BuiltinTool | None: """ returns the tool that the provider can provide """ diff --git a/api/core/tools/custom_tool/provider.py b/api/core/tools/custom_tool/provider.py index 520a55dbd30..ade5b894f95 100644 --- a/api/core/tools/custom_tool/provider.py +++ b/api/core/tools/custom_tool/provider.py @@ -24,7 +24,7 @@ from extensions.ext_database import db from models.tools import ApiToolProvider -class ApiToolProviderController(ToolProviderController): +class ApiToolProviderController(ToolProviderController[ToolProviderEntity, ApiTool]): provider_id: str tenant_id: str tools: list[ApiTool] = Field(default_factory=list) diff --git a/api/core/tools/mcp_tool/provider.py b/api/core/tools/mcp_tool/provider.py index 52414153b89..86486121720 100644 --- a/api/core/tools/mcp_tool/provider.py +++ b/api/core/tools/mcp_tool/provider.py @@ -18,7 +18,7 @@ from models.tools import MCPToolProvider from services.tools.tools_transform_service import ToolTransformService -class MCPToolProviderController(ToolProviderController): +class MCPToolProviderController(ToolProviderController[ToolProviderEntityWithPlugin, MCPTool]): def __init__( self, entity: ToolProviderEntityWithPlugin, diff --git a/api/core/tools/mcp_tool/tool.py b/api/core/tools/mcp_tool/tool.py index b0f1f7a5f26..7a1553a4b15 100644 --- a/api/core/tools/mcp_tool/tool.py +++ b/api/core/tools/mcp_tool/tool.py @@ -30,7 +30,7 @@ logger = logging.getLogger(__name__) # Custom header used to carry the forwarded SSO access token. Picked to avoid # stomping on the workspace-scoped Authorization header (provider OAuth / # user-supplied custom credentials), which would silently break those flows. -FORWARDED_IDENTITY_HEADER = "X-Dify-SSO-Access-Token" +FORWARDED_IDENTITY_HEADER = "X-Dify-SSO-Token" class MCPTool(Tool): @@ -305,7 +305,7 @@ class MCPTool(Tool): # Forwarded identity rides in a custom header so workspace-scoped # provider credentials (Authorization / custom Headers) keep working - # untouched. The MCP server is expected to read X-Dify-SSO-Access-Token + # untouched. The MCP server is expected to read X-Dify-SSO-Token # when identity forwarding is configured. forward_identity_active = False if self._forwarding_requested and user_id: @@ -338,7 +338,7 @@ class MCPTool(Tool): audience: str, ) -> None: """Call the enterprise IssueMCPToken endpoint and stamp the issued - token into X-Dify-SSO-Access-Token. + token into X-Dify-SSO-Token. A custom header is used (rather than Authorization) so it composes with workspace-scoped provider credentials — the user may have OAuth @@ -358,7 +358,17 @@ class MCPTool(Tool): tenant_id=self.tenant_id, app_id=app_id, audience=audience, + user_type=self._resolve_user_type(), ) except MCPTokenError as e: raise ToolInvokeError(f"Failed to obtain forwarded identity token: {e}") from e headers[FORWARDED_IDENTITY_HEADER] = token + + def _resolve_user_type(self) -> str: + """Return "account" for console-authenticated callers (debugger/explore), + "end_user" for webapp / service-api / trigger callers — so the enterprise + side routes to the console store vs the published-webapp store.""" + invoke_from = self.runtime.invoke_from + if invoke_from is not None and invoke_from.runs_as_account(): + return "account" + return "end_user" diff --git a/api/core/tools/plugin_tool/provider.py b/api/core/tools/plugin_tool/provider.py index fc6ec14284d..526b3680b07 100644 --- a/api/core/tools/plugin_tool/provider.py +++ b/api/core/tools/plugin_tool/provider.py @@ -9,7 +9,9 @@ from core.tools.plugin_tool.tool import PluginTool class PluginToolProviderController(BuiltinToolProviderController): - entity: ToolProviderEntityWithPlugin + # TODO: Split the credential/schema helpers from BuiltinToolProviderController + # so plugin providers do not need to inherit builtin tool-loading behavior. + entity: ToolProviderEntityWithPlugin # pyrefly: ignore[bad-override-mutable-attribute] tenant_id: str plugin_id: str plugin_unique_identifier: str @@ -46,7 +48,8 @@ class PluginToolProviderController(BuiltinToolProviderController): ): raise ToolProviderCredentialValidationError("Invalid credentials") - def get_tool(self, tool_name: str) -> PluginTool: # type: ignore + @override + def get_tool(self, tool_name: str) -> PluginTool: # type: ignore[override] # pyrefly: ignore[bad-override] """ return tool with given name """ @@ -65,7 +68,8 @@ class PluginToolProviderController(BuiltinToolProviderController): plugin_unique_identifier=self.plugin_unique_identifier, ) - def get_tools(self) -> list[PluginTool]: # type: ignore + @override + def get_tools(self) -> list[PluginTool]: # type: ignore[override] # pyrefly: ignore[bad-override] """ get all tools """ diff --git a/api/core/tools/tool_manager.py b/api/core/tools/tool_manager.py index 99be960a200..850571c3f19 100644 --- a/api/core/tools/tool_manager.py +++ b/api/core/tools/tool_manager.py @@ -398,6 +398,8 @@ class ToolManager: user_id: str | None = None, invoke_from: InvokeFrom = InvokeFrom.DEBUGGER, variable_pool: "VariablePool | None" = None, + allow_file_parameters: bool = False, + use_default_for_missing_form_parameters: bool = False, ) -> Tool: """ get the agent tool runtime @@ -415,7 +417,12 @@ class ToolManager: runtime_parameters: dict[str, Any] = {} parameters = tool_entity.get_merged_runtime_parameters() runtime_parameters = cls._convert_tool_parameters_type( - parameters, variable_pool, agent_tool.tool_parameters, typ="agent" + parameters, + variable_pool, + agent_tool.tool_parameters, + typ="agent", + allow_file_parameters=allow_file_parameters, + use_default_for_missing_form_parameters=use_default_for_missing_form_parameters, ) # decrypt runtime parameters encryption_manager = ToolParameterConfigurationManager( @@ -1063,6 +1070,8 @@ class ToolManager: variable_pool: "VariablePool | None", tool_configurations: Mapping[str, Any], typ: Literal["agent", "workflow", "tool"] = "workflow", + allow_file_parameters: bool = False, + use_default_for_missing_form_parameters: bool = False, ) -> dict[str, Any]: """ Convert tool parameters type @@ -1081,6 +1090,7 @@ class ToolManager: } and parameter.required and typ == "agent" + and not allow_file_parameters ): raise ValueError(f"file type parameter {parameter.name} not supported in agent") # save tool parameter to tool entity memory @@ -1117,7 +1127,19 @@ class ToolManager: runtime_parameters[parameter.name] = parameter_value else: - value = parameter.init_frontend_parameter(tool_configurations.get(parameter.name)) + parameter_value = tool_configurations.get(parameter.name) + if use_default_for_missing_form_parameters and parameter_value is None: + if parameter.default is not None: + parameter_value = parameter.default + elif ( + parameter.required + and parameter.type == ToolParameter.ToolParameterType.SELECT + and parameter.options + ): + parameter_value = parameter.options[0].value + else: + continue + value = parameter.init_frontend_parameter(parameter_value) runtime_parameters[parameter.name] = value return runtime_parameters diff --git a/api/core/tools/workflow_as_tool/provider.py b/api/core/tools/workflow_as_tool/provider.py index 41212bcec8d..1611cd1d63a 100644 --- a/api/core/tools/workflow_as_tool/provider.py +++ b/api/core/tools/workflow_as_tool/provider.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Mapping from typing import override -from pydantic import Field from sqlalchemy import select from sqlalchemy.orm import Session @@ -43,13 +42,14 @@ VARIABLE_TO_PARAMETER_TYPE_MAPPING = { } -class WorkflowToolProviderController(ToolProviderController): +class WorkflowToolProviderController(ToolProviderController[ToolProviderEntity, WorkflowTool | None]): provider_id: str - tools: list[WorkflowTool] = Field(default_factory=list) + tools: list[WorkflowTool] | None def __init__(self, entity: ToolProviderEntity, provider_id: str): super().__init__(entity=entity) self.provider_id = provider_id + self.tools = None @classmethod def from_db(cls, db_provider: WorkflowToolProvider) -> WorkflowToolProviderController: @@ -241,7 +241,8 @@ class WorkflowToolProviderController(ToolProviderController): return self.tools - def get_tool(self, tool_name: str) -> WorkflowTool | None: # type: ignore + @override + def get_tool(self, tool_name: str) -> WorkflowTool | None: """ get tool by name diff --git a/api/core/workflow/generator/prompts/builder_prompts.py b/api/core/workflow/generator/prompts/builder_prompts.py index b4f7cec6d74..9aaf75bddbd 100644 --- a/api/core/workflow/generator/prompts/builder_prompts.py +++ b/api/core/workflow/generator/prompts/builder_prompts.py @@ -526,13 +526,77 @@ Now emit the complete workflow graph JSON. """ +# Node wrapper fields that carry no meaning the builder needs: pure canvas / +# selection state, plus geometry the runner's postprocess recomputes anyway. +# Stripping them out of the refine prompt cuts its size roughly in half on +# hand-edited graphs — fewer tokens in, and (because the builder echoes +# untouched nodes verbatim) far fewer tokens out, which is where the latency +# lives. +_PRUNED_NODE_KEYS = frozenset( + { + "positionAbsolute", + "sourcePosition", + "targetPosition", + "selected", + "dragging", + "measured", + } +) + +# Additionally pruned from TOP-LEVEL nodes only: the layered auto-layout +# recomputes their position and size defaults, so the builder never needs to +# reproduce them. Container children keep ``position`` (relative to the +# parent, which we cannot recompute) and containers keep ``width`` / +# ``height`` (their canvas size is real config, not a default). +_PRUNED_TOP_LEVEL_NODE_KEYS = _PRUNED_NODE_KEYS | {"position", "width", "height"} + +_CONTAINER_DATA_TYPES = frozenset({"iteration", "loop"}) + +# Edge fields the builder must echo; everything else (ids, zIndex, +# sourceType / targetType, isInIteration / isInLoop markers) is recomputed +# by the runner's postprocess from the node topology. +_KEPT_EDGE_KEYS = ("source", "target", "sourceHandle", "targetHandle") + + +def compact_graph_for_builder(current_graph: dict) -> dict: + """ + Strip canvas noise out of a draft graph before prompt injection. + + Keeps everything semantically meaningful — ids, wrapper ``type``, + ``parentId``, the full ``data`` config, child positions, container + sizes — and drops geometry / selection state the postprocess pass + recomputes. The builder echoes untouched nodes verbatim, so every byte + removed here is removed twice (prompt AND completion). + """ + nodes_out: list[dict] = [] + for node in current_graph.get("nodes") or []: + if not isinstance(node, dict): + continue + is_child = bool(node.get("parentId")) + is_container = isinstance(node.get("data"), dict) and node["data"].get("type") in _CONTAINER_DATA_TYPES + pruned = _PRUNED_NODE_KEYS if (is_child or is_container) else _PRUNED_TOP_LEVEL_NODE_KEYS + compact = {k: v for k, v in node.items() if k not in pruned} + if is_container: + # Container position is still recomputed by the layout pass. + compact.pop("position", None) + nodes_out.append(compact) + edges_out = [ + {k: edge[k] for k in _KEPT_EDGE_KEYS if k in edge} + for edge in (current_graph.get("edges") or []) + if isinstance(edge, dict) + ] + return {"nodes": nodes_out, "edges": edges_out} + + def format_builder_existing_graph_section(current_graph: dict | None) -> str: """ - Refine mode: give the builder the FULL existing graph JSON so it can keep + Refine mode: give the builder the existing graph JSON so it can keep every node and edge the user's change does not touch byte-for-byte — same ids, same config, same prompt templates. Without the full config the builder would regenerate untouched nodes from scratch and silently drop - the user's hand-tuned settings. + the user's hand-tuned settings. Canvas-only fields are stripped first + (see ``compact_graph_for_builder``) — they're recomputed in postprocess, + so carrying them only slows the call down. Returns an empty string in create mode (no ``current_graph``); the builder then behaves exactly as before, constructing the graph purely from the @@ -540,7 +604,7 @@ def format_builder_existing_graph_section(current_graph: dict | None) -> str: """ if not current_graph: return "" - graph_json = json.dumps(current_graph, ensure_ascii=False, separators=(",", ":")) + graph_json = json.dumps(compact_graph_for_builder(current_graph), ensure_ascii=False, separators=(",", ":")) return ( "# Existing graph to refine (JSON)\n\n" "You are REFINING this existing graph, NOT building from scratch. Apply " diff --git a/api/core/workflow/generator/runner.py b/api/core/workflow/generator/runner.py index 2897166b240..0a5477231bb 100644 --- a/api/core/workflow/generator/runner.py +++ b/api/core/workflow/generator/runner.py @@ -70,6 +70,10 @@ logger = logging.getLogger(__name__) _NODE_X_OFFSET = 80 _NODE_X_STEP = 320 _NODE_Y = 280 +# Vertical gap between lanes when two branches share the same topological +# depth (e.g. the two arms of an if-else). Default node height is 100, so +# 160 leaves clear air between stacked nodes. +_NODE_Y_STEP = 160 _DEFAULT_VIEWPORT: GraphViewportDict = {"x": 0.0, "y": 0.0, "zoom": 0.7} _DEFAULT_NODE_WIDTH = 244 _DEFAULT_NODE_HEIGHT = 100 @@ -575,34 +579,32 @@ class WorkflowGenerator: # Defensive ID remap: Dify's run-time placeholder regex only accepts # ``[a-zA-Z0-9_]`` in the node-id slot, so anything the LLM emits with - # hyphens (``node-1``, ``node-Kstart``, etc.) would break every - # placeholder pointing at it. Strip hyphens out of every id + every + # hyphens, dots, or spaces (``node-1``, ``node.2``, etc.) would break + # every placeholder pointing at it. Sanitize every id + every # cross-reference (edges' ``source`` / ``target``, ``parentId``, # ``start_node_id`` / ``iteration_id`` / ``loop_id`` on data, and the # ``{{#…#}}`` and ``["node-id", "var"]`` references) BEFORE the rest # of the postprocess pass touches them. - cls._strip_hyphens_from_node_ids(nodes=nodes, edges=edges) + cls._sanitize_node_ids(nodes=nodes, edges=edges) # Container-child nodes carry their own relative positions inside the # parent and have a special ``type`` (custom-iteration-start / # custom-loop-start). We must not override their positions or wrapper - # ``type``; only top-level (parentId-less) nodes get the left-to-right - # auto layout. - top_level_index = 0 + # ``type``; only top-level (parentId-less) nodes get the layered + # auto layout (x = topological depth, y = lane within the layer). + cls._layout_top_level_nodes(nodes=nodes, edges=edges) for node in nodes: cls._fill_node_defaults(node) if node.get("parentId"): # Inner node — keep whatever the LLM emitted; only fill the # absolutely-required defaults so the canvas can render it. - node.setdefault("position", {"x": 0.0, "y": 0.0}) node.setdefault("zIndex", 1002) node.setdefault("extent", "parent") - else: - node["position"] = { - "x": float(_NODE_X_OFFSET + _NODE_X_STEP * top_level_index), - "y": float(_NODE_Y), - } - top_level_index += 1 + # Inner nodes keep their LLM-emitted relative position; top-level + # nodes were positioned by the layered layout. The setdefault only + # fires for inner nodes without a position and for a (broken) + # id-less node the layout pass couldn't see. + node.setdefault("position", {"x": 0.0, "y": 0.0}) node.setdefault("positionAbsolute", dict(node["position"])) node.setdefault("width", _DEFAULT_NODE_WIDTH) node.setdefault("height", _DEFAULT_NODE_HEIGHT) @@ -620,6 +622,12 @@ class WorkflowGenerator: if n.get("id") in inner_node_to_parent.values(): parent_type[n["id"]] = n.get("data", {}).get("type", "") + # Branch nodes (if-else / question-classifier) emit one handle per + # case; an edge leaving them on the default "source" handle dangles + # off a handle that doesn't exist on the canvas. Repair the + # unambiguous cases before edge ids are computed from the handles. + cls._repair_branch_edge_handles(nodes=nodes, edges=edges) + # Dedupe edges (LLMs sometimes emit the same edge twice). seen: set[tuple[str, str, str, str]] = set() deduped_edges = [] @@ -712,14 +720,19 @@ class WorkflowGenerator: r"\{\{#([a-zA-Z0-9_]{1,50})\.([a-zA-Z_][a-zA-Z0-9_]{0,29}(?:\.[a-zA-Z_][a-zA-Z0-9_]{0,29}){0,9})#\}\}" ) - # Lenient sibling used only by the defensive hyphen-strip pass — it - # allows hyphens in the node-id slot so we can rewrite the LLM's - # ``{{#node-1.var#}}`` outputs BEFORE the strict walker sees them. + # Lenient sibling used only by the defensive id-sanitize pass — it + # accepts ANY character in the node-id slot (except the ``.`` separator + # and ``#`` terminator) so we can rewrite the LLM's ``{{#node-1.var#}}`` + # / ``{{#node 2.var#}}`` outputs BEFORE the strict walker sees them. # Never use this for validation, only for rewriting. - _LENIENT_VAR_REF_RE: ClassVar = re.compile(r"\{\{#([A-Za-z0-9_-]+)\.([^#]+)#\}\}") + _LENIENT_VAR_REF_RE: ClassVar = re.compile(r"\{\{#([^#.{}]+)\.([^#]+)#\}\}") + + # Characters the run-time placeholder regex rejects in the node-id slot. + # Anything matching this in a node id must be sanitized away. + _INVALID_ID_CHARS_RE: ClassVar = re.compile(r"[^a-zA-Z0-9_]") # Strings inside ``data`` that look like node-id slugs and need - # remapping when we defensively strip hyphens out of LLM-emitted ids. + # remapping when we defensively sanitize LLM-emitted ids. _ID_FIELDS: ClassVar = frozenset({"start_node_id", "iteration_id", "loop_id", "parentId"}) # ``data`` keys whose value is a plain string list, never a @@ -860,34 +873,51 @@ class WorkflowGenerator: return False @classmethod - def _strip_hyphens_from_node_ids(cls, *, nodes: list[dict[str, Any]], edges: list[dict[str, Any]]) -> None: + def _sanitize_node_ids(cls, *, nodes: list[dict[str, Any]], edges: list[dict[str, Any]]) -> None: """ - Strip ``-`` out of every node id and rewrite every cross-reference. + Rewrite every node id to ``[a-zA-Z0-9_]`` and fix every cross-reference. Dify's run-time ``VARIABLE_PATTERN`` accepts only ``[a-zA-Z0-9_]`` in the node-id slot of ``{{#…#}}`` placeholders. The builder LLM often - emits ``node-1`` style ids; left unfixed those make every placeholder - silently fail at run time, the literal ``{{#node-1.var#}}`` survives - into the prompt, and the LLM at run time echoes it back as the user's - output — the bug we are here to kill. + emits ``node-1`` style ids (and occasionally dots or spaces); left + unfixed those make every placeholder silently fail at run time, the + literal ``{{#node-1.var#}}`` survives into the prompt, and the LLM at + run time echoes it back as the user's output — the bug we are here + to kill. - Approach: build a one-to-one ``old → new`` map by removing hyphens, - then rewrite (a) every node ``id``, (b) every edge ``source`` / - ``target``, (c) every ``parentId`` / ``start_node_id`` / - ``iteration_id`` / ``loop_id`` inside ``data``, (d) every - ``{{#…#}}`` reference in any string, (e) every ``["node-id", "var"]`` - value-selector list. We do NOT rename variable names — only ids. + Approach: build a one-to-one ``old → new`` map by dropping the invalid + characters — collision-safe: when the sanitized id is already taken + (e.g. the builder emitted BOTH ``node-1`` and ``node1``) a numeric + suffix keeps the two distinct instead of silently merging every + reference onto one node. Then rewrite (a) every node ``id``, (b) every + edge ``source`` / ``target``, (c) every ``parentId`` / + ``start_node_id`` / ``iteration_id`` / ``loop_id`` inside ``data``, + (d) every ``{{#…#}}`` reference in any string, (e) every + ``["node-id", "var"]`` value-selector list. We do NOT rename variable + names — only ids. """ - # Build id rewrite map. Collision-safe because we just strip a single - # character class — two different hyphenated ids ``node-1`` and - # ``node1`` would collide, but the builder LLM has been instructed - # to pick one style so in practice it's one or the other. id_map: dict[str, str] = {} + # Ids that are already valid are reserved up front so a sanitized id + # can never collide with an untouched sibling. + used: set[str] = { + n["id"] for n in nodes if isinstance(n.get("id"), str) and not cls._INVALID_ID_CHARS_RE.search(n["id"]) + } + fallback_seq = 0 for node in nodes: old = node.get("id") - if not isinstance(old, str) or "-" not in old: + if not isinstance(old, str) or not cls._INVALID_ID_CHARS_RE.search(old): continue - new = old.replace("-", "") + base = cls._INVALID_ID_CHARS_RE.sub("", old) + if not base: + # Id was nothing but invalid characters (e.g. "节点", "--"). + fallback_seq += 1 + base = f"node_{fallback_seq}" + new = base + suffix = 2 + while new in used: + new = f"{base}_{suffix}" + suffix += 1 + used.add(new) id_map[old] = new node["id"] = new if not id_map: @@ -901,10 +931,12 @@ class WorkflowGenerator: edge[key] = id_map[v] # Also rewrite the edge id if the builder emitted one referencing # the old ids; the dedupe pass later recomputes it anyway, but - # rewriting here keeps logs sane. + # rewriting here keeps logs sane. Longest-first so an id that is + # a substring of another (``node-1`` in ``node-12``) can't corrupt + # the longer match. eid = edge.get("id") if isinstance(eid, str): - for old, new in id_map.items(): + for old, new in sorted(id_map.items(), key=lambda kv: -len(kv[0])): eid = eid.replace(old, new) edge["id"] = eid @@ -955,6 +987,116 @@ class WorkflowGenerator: new_id = id_map.get(node_id, node_id) return f"{{{{#{new_id}.{rest}#}}}}" + @classmethod + def _repair_branch_edge_handles(cls, *, nodes: list[dict[str, Any]], edges: list[dict[str, Any]]) -> None: + """ + Re-home edges that leave a branch node on the default "source" handle. + + if-else exposes one source handle per ``case_id`` plus the implicit + "false" (ELSE) handle; question-classifier exposes one per class id. + The builder prompt documents this, but LLMs still emit the default + handle, which renders as an edge hanging off a handle that doesn't + exist and the branch silently never runs. + + Repair only when unambiguous: default-handle edges are assigned to the + node's UNUSED branch handles in declaration order, and only when there + are at least as many unused handles as edges to fix. Anything + ambiguous is left alone — a wrong guess that swaps the IF and ELSE + arms is worse than a visible dangling edge. + """ + for node in nodes: + data = node.get("data") or {} + node_type = data.get("type") + if node_type == BuiltinNodeTypes.IF_ELSE: + branch_handles = [ + str(case["case_id"]) + for case in (data.get("cases") or []) + if isinstance(case, dict) and case.get("case_id") + ] + # ELSE is implicit — it has a handle even though no case + # declares it. + branch_handles.append("false") + elif node_type == BuiltinNodeTypes.QUESTION_CLASSIFIER: + branch_handles = [ + str(klass["id"]) + for klass in (data.get("classes") or []) + if isinstance(klass, dict) and klass.get("id") + ] + else: + continue + + node_id = node.get("id") + outgoing = [e for e in edges if e.get("source") == node_id] + taken = {e.get("sourceHandle") for e in outgoing if e.get("sourceHandle") in branch_handles} + unused = [h for h in branch_handles if h not in taken] + defaulted = [e for e in outgoing if e.get("sourceHandle") in (None, "", "source")] + if not defaulted or len(defaulted) > len(unused): + continue + for edge, handle in zip(defaulted, unused): + edge["sourceHandle"] = handle + logger.info( + "Workflow generator: re-homed default-handle edge %s -> %s onto branch handle %r", + node_id, + edge.get("target"), + handle, + ) + + @classmethod + def _layout_top_level_nodes(cls, *, nodes: list[dict[str, Any]], edges: list[dict[str, Any]]) -> None: + """ + Lay out top-level nodes by graph topology instead of array order. + + x = longest-path depth from the entry layer, y = lane within the + layer — so an if-else's two arms render as two parallel rows instead + of overlapping on one line, and a builder that emits nodes out of + execution order still gets a left-to-right canvas. Longest-path (not + BFS) layering keeps a join node (variable-aggregator, end) to the + right of its deepest branch. + + Cycle-safe: Kahn's algorithm simply never reaches nodes on a cycle, + and those get parked one layer past the deepest laid-out node in + declaration order — the cycle itself is flagged by the structural + validator afterwards. + """ + top_level = [n for n in nodes if not n.get("parentId") and isinstance(n.get("id"), str) and n.get("id")] + id_set = {n["id"] for n in top_level} + + succs: dict[str, list[str]] = {node_id: [] for node_id in id_set} + indegree: dict[str, int] = dict.fromkeys(id_set, 0) + seen_pairs: set[tuple[str, str]] = set() + for edge in edges: + src, tgt = edge.get("source"), edge.get("target") + if not isinstance(src, str) or not isinstance(tgt, str): + continue + if src not in id_set or tgt not in id_set or src == tgt or (src, tgt) in seen_pairs: + continue + seen_pairs.add((src, tgt)) + succs[src].append(tgt) + indegree[tgt] += 1 + + depth: dict[str, int] = {} + queue = [n["id"] for n in top_level if indegree[n["id"]] == 0] + for node_id in queue: + depth[node_id] = 0 + while queue: + cur = queue.pop(0) + for nxt in succs[cur]: + depth[nxt] = max(depth.get(nxt, 0), depth[cur] + 1) + indegree[nxt] -= 1 + if indegree[nxt] == 0: + queue.append(nxt) + + overflow_depth = (max(depth.values()) + 1) if depth else 0 + lanes: dict[int, int] = {} + for node in top_level: + d = depth.get(node["id"], overflow_depth) + lane = lanes.get(d, 0) + lanes[d] = lane + 1 + node["position"] = { + "x": float(_NODE_X_OFFSET + _NODE_X_STEP * d), + "y": float(_NODE_Y + _NODE_Y_STEP * lane), + } + @classmethod def _inject_start_variable(cls, start_node: dict[str, Any], var: str) -> None: """Add a default ``paragraph`` input so ``{{#start.#}}`` resolves.""" @@ -1122,6 +1264,24 @@ class WorkflowGenerator: errors.append(_err(WorkflowGenerateErrorCode.INVALID_SCHEMA, "Generated graph has no nodes")) return errors + # Duplicate ids make every cross-reference ambiguous (edges, variable + # placeholders, parentId all resolve to "whichever node wins"), so a + # graph with them is unusable no matter how the canvas renders it. + id_counts: dict[str, int] = {} + for node in nodes: + node_id = node.get("id", "") + if node_id: + id_counts[node_id] = id_counts.get(node_id, 0) + 1 + for node_id, count in id_counts.items(): + if count > 1: + errors.append( + _err( + WorkflowGenerateErrorCode.DUPLICATE_NODE_ID, + f"Duplicate node id {node_id!r} ({count} nodes share it)", + node_id=node_id, + ) + ) + types = [node.get("data", {}).get("type", "") for node in nodes] starts = [t for t in types if t == BuiltinNodeTypes.START] if len(starts) != 1: @@ -1156,6 +1316,11 @@ class WorkflowGenerator: if tgt not in known_ids: errors.append(_err(WorkflowGenerateErrorCode.DANGLING_EDGE, f"Edge target node not found: {tgt!r}")) + # Workflow graphs must be DAGs — a directed cycle hangs or errors the + # run, and nothing downstream of the cycle ever executes. (A "loop" + # container is the sanctioned way to iterate; its edges are internal.) + errors.extend(cls._collect_edge_cycle_errors(graph=graph, known_ids=known_ids)) + # Dangling node-id references in node ``data`` (parentId, start_node_id, iteration_id, loop_id). errors.extend(cls._collect_dangling_id_refs(nodes=nodes, known_ids=known_ids)) @@ -1175,6 +1340,54 @@ class WorkflowGenerator: return errors + @classmethod + def _collect_edge_cycle_errors(cls, *, graph: GraphDict, known_ids: set[str]) -> list[WorkflowGenerateErrorDict]: + """ + Flag directed cycles among the graph's edges (Kahn's algorithm). + + Self-loops are reported per node; a longer cycle is reported once, + naming every node Kahn's peeling never reaches (cycle members plus + anything downstream of them). Edges into unknown ids are ignored + here — the dangling-edge check already covers those. + """ + out: list[WorkflowGenerateErrorDict] = [] + succs: dict[str, list[str]] = {node_id: [] for node_id in known_ids} + indegree: dict[str, int] = dict.fromkeys(known_ids, 0) + for edge in graph.get("edges", []): + src, tgt = edge.get("source"), edge.get("target") + if src not in known_ids or tgt not in known_ids: + continue + if src == tgt: + out.append( + _err( + WorkflowGenerateErrorCode.GRAPH_CYCLE, + f"Node {src!r} has an edge pointing at itself", + node_id=src, + ) + ) + continue + succs[src].append(tgt) + indegree[tgt] += 1 + + queue = [node_id for node_id, deg in indegree.items() if deg == 0] + visited = 0 + while queue: + cur = queue.pop() + visited += 1 + for nxt in succs[cur]: + indegree[nxt] -= 1 + if indegree[nxt] == 0: + queue.append(nxt) + if visited < len(known_ids): + trapped = sorted(node_id for node_id, deg in indegree.items() if deg > 0) + out.append( + _err( + WorkflowGenerateErrorCode.GRAPH_CYCLE, + f"Workflow graph contains a cycle; affected nodes: {', '.join(trapped)}", + ) + ) + return out + @classmethod def _collect_dangling_id_refs( cls, *, nodes: list[dict[str, Any]], known_ids: set[str] diff --git a/api/core/workflow/generator/types.py b/api/core/workflow/generator/types.py index c62dc7a3f03..ddb2ba7ce62 100644 --- a/api/core/workflow/generator/types.py +++ b/api/core/workflow/generator/types.py @@ -19,6 +19,9 @@ class WorkflowGenerateErrorCode: INVALID_JSON: Final = "INVALID_JSON" INVALID_SCHEMA: Final = "INVALID_SCHEMA" EMPTY_INSTRUCTION: Final = "EMPTY_INSTRUCTION" + INSTRUCTION_TOO_LONG: Final = "INSTRUCTION_TOO_LONG" + DUPLICATE_NODE_ID: Final = "DUPLICATE_NODE_ID" + GRAPH_CYCLE: Final = "GRAPH_CYCLE" EMPTY_PLAN: Final = "EMPTY_PLAN" UNKNOWN_NODE_REFERENCE: Final = "UNKNOWN_NODE_REFERENCE" INVALID_CONTAINER: Final = "INVALID_CONTAINER" diff --git a/api/core/workflow/node_factory.py b/api/core/workflow/node_factory.py index bf85fc19188..54c6c55949e 100644 --- a/api/core/workflow/node_factory.py +++ b/api/core/workflow/node_factory.py @@ -334,6 +334,7 @@ class DifyNodeFactory(NodeFactory): self.graph_runtime_state.variable_pool, SystemVariableKey.WORKFLOW_EXECUTION_ID, ), + conversation_id_getter=self._conversation_id, ) self._tool_runtime = DifyToolNodeRuntime(self._dify_context) self._http_request_file_manager = file_manager diff --git a/api/core/workflow/node_runtime.py b/api/core/workflow/node_runtime.py index 8c7d5c157ca..4eced02cd10 100644 --- a/api/core/workflow/node_runtime.py +++ b/api/core/workflow/node_runtime.py @@ -710,10 +710,12 @@ class DifyHumanInputNodeRuntime(HumanInputNodeRuntimeProtocol): run_context: Mapping[str, Any] | DifyRunContext, *, workflow_execution_id_getter: Callable[[], str | None] | None = None, + conversation_id_getter: Callable[[], str | None] | None = None, form_repository: HumanInputFormRepository | None = None, ) -> None: self._run_context = resolve_dify_run_context(run_context) self._workflow_execution_id_getter = workflow_execution_id_getter + self._conversation_id_getter = conversation_id_getter self._form_repository = form_repository self._file_reference_factory = DifyFileReferenceFactory(self._run_context) @@ -762,6 +764,7 @@ class DifyHumanInputNodeRuntime(HumanInputNodeRuntimeProtocol): return DifyHumanInputNodeRuntime( self._run_context, workflow_execution_id_getter=self._workflow_execution_id_getter, + conversation_id_getter=self._conversation_id_getter, form_repository=form_repository, ) @@ -799,6 +802,9 @@ class DifyHumanInputNodeRuntime(HumanInputNodeRuntimeProtocol): repo = self.build_form_repository() params = FormCreateParams( workflow_execution_id=self._workflow_execution_id_getter() if self._workflow_execution_id_getter else None, + # A chatflow (advanced-chat) run carries a conversation; tag the form with + # it too so it is queryable per conversation. None for a pure workflow run. + conversation_id=self._conversation_id_getter() if self._conversation_id_getter else None, node_id=node_id, form_config=node_data, rendered_content=rendered_content, diff --git a/api/core/workflow/nodes/agent_v2/agent_node.py b/api/core/workflow/nodes/agent_v2/agent_node.py index bdcbb776267..8adb27240c7 100644 --- a/api/core/workflow/nodes/agent_v2/agent_node.py +++ b/api/core/workflow/nodes/agent_v2/agent_node.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any, override from agenton.compositor import CompositorSessionSnapshot from clients.agent_backend import ( + AgentBackendDeferredToolCallInternalEvent, AgentBackendError, AgentBackendHTTPError, AgentBackendInternalEventType, @@ -14,23 +15,25 @@ from clients.agent_backend import ( AgentBackendRunClient, AgentBackendRunEventAdapter, AgentBackendRunFailedInternalEvent, - AgentBackendRunPausedInternalEvent, AgentBackendRunSucceededInternalEvent, AgentBackendStreamError, AgentBackendStreamInternalEvent, AgentBackendTransportError, AgentBackendValidationError, - CleanupLayerSpec, - extract_cleanup_layer_specs, + RuntimeLayerSpec, + extract_runtime_layer_specs, ) from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext +from core.repositories.human_input_repository import HumanInputFormRepository, HumanInputFormRepositoryImpl from core.workflow.system_variables import SystemVariableKey, get_system_text -from graphon.entities.pause_reason import SchedulingPause +from graphon.entities.pause_reason import HumanInputRequired, SchedulingPause from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus from graphon.node_events import NodeEventBase, NodeRunResult, PauseRequestedEvent, StreamCompletedEvent from graphon.nodes.base.node import Node -from models.agent_config_entities import WorkflowNodeJobConfig +from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig +from .ask_human_hitl import AskHumanFormBuildError, build_ask_human_pause_reason +from .ask_human_resume import build_deferred_tool_results, resolve_ask_human_form from .binding_resolver import WorkflowAgentBindingError, WorkflowAgentBindingResolver from .entities import DifyAgentNodeData from .output_adapter import WorkflowAgentOutputAdapter @@ -62,7 +65,7 @@ _TerminalAgentBackendEvent = ( AgentBackendRunSucceededInternalEvent | AgentBackendRunFailedInternalEvent | AgentBackendRunCancelledInternalEvent - | AgentBackendRunPausedInternalEvent + | AgentBackendDeferredToolCallInternalEvent ) @@ -117,6 +120,12 @@ class DifyAgentNode(Node[DifyAgentNodeData]): self.graph_runtime_state.variable_pool, SystemVariableKey.WORKFLOW_EXECUTION_ID, ) + # Set on chatflow (advanced-chat) runs; None for a pure workflow run. Lets an + # ask_human form be tagged with its conversation in addition to workflow_run_id. + conversation_id = get_system_text( + self.graph_runtime_state.variable_pool, + SystemVariableKey.CONVERSATION_ID, + ) inputs: dict[str, Any] = {} process_data: dict[str, Any] = {} metadata: dict[str, Any] = { @@ -168,6 +177,33 @@ class DifyAgentNode(Node[DifyAgentNodeData]): ) outputs_by_name = {o.name: o for o in effective_outputs} + # ──── ENG-638: resume after a submitted/timed-out ask_human form ──── + # graphon re-executes this _run when the outer workflow resumes. If a + # pending ask_human form is now terminal, thread the human's answer into + # the second Agent run as deferred_tool_results; if it is somehow still + # waiting, re-emit the same pause defensively. + deferred_tool_results = None + if self._session_store is not None: + stored_session = self._session_store.load_active_session(session_scope) + if stored_session is not None and stored_session.pending_form_id is not None: + resume_outcome = resolve_ask_human_form( + form_id=stored_session.pending_form_id, + tenant_id=dify_ctx.tenant_id, + node_id=self._node_id, + ) + if resume_outcome is not None and resume_outcome.repause is not None: + yield PauseRequestedEvent(reason=resume_outcome.repause) + return + if ( + resume_outcome is not None + and resume_outcome.deferred_result is not None + and stored_session.pending_tool_call_id is not None + ): + deferred_tool_results = build_deferred_tool_results( + tool_call_id=stored_session.pending_tool_call_id, + result=resume_outcome.deferred_result, + ) + # ──── Retry loop (Stage 4 §7) ──── attempt = 0 while True: @@ -188,6 +224,7 @@ class DifyAgentNode(Node[DifyAgentNodeData]): snapshot=bundle.snapshot, attempt=attempt, session_snapshot=session_snapshot, + deferred_tool_results=deferred_tool_results, ) ) except WorkflowAgentRuntimeRequestBuildError as error: @@ -250,20 +287,57 @@ class DifyAgentNode(Node[DifyAgentNodeData]): ) return - if isinstance(terminal_event, AgentBackendRunPausedInternalEvent): + if isinstance(terminal_event, AgentBackendDeferredToolCallInternalEvent): + # ENG-636: a dify.ask_human deferred call pauses the *outer* + # workflow through the existing HITL form path. Any other deferred + # tool (none today) falls back to a generic scheduling pause. The + # form is built *before* the snapshot is saved so its id can be + # persisted as the pause correlation (ENG-637). + try: + pause_reason: HumanInputRequired | SchedulingPause | None = build_ask_human_pause_reason( + deferred_tool_call=terminal_event.deferred_tool_call, + node_id=self._node_id, + default_node_title=bundle.agent.name or self._node_id, + workflow_run_id=workflow_run_id, + conversation_id=conversation_id, + contacts=AgentSoulConfig.model_validate(bundle.snapshot.config_snapshot_dict).human.contacts, + repository=self._build_human_input_form_repository( + dify_ctx=dify_ctx, workflow_run_id=workflow_run_id + ), + ) + except AskHumanFormBuildError as error: + yield self._failure_event( + inputs=inputs, + process_data=process_data, + metadata=metadata, + error=str(error), + error_type="agent_ask_human_form_build_error", + ) + return + + # ENG-637: persist the awaiting-form id + deferred tool_call id + # next to the snapshot so the resumed node can rebuild + # deferred_tool_results from the submitted form. + pending_form_id: str | None = None + pending_tool_call_id: str | None = None + if isinstance(pause_reason, HumanInputRequired): + pending_form_id = pause_reason.form_id + pending_tool_call_id = terminal_event.deferred_tool_call.tool_call_id + else: + pause_reason = SchedulingPause( + message=terminal_event.message + or "Agent backend run requested workflow pause for external input." + ) self._save_session_snapshot( session_scope=session_scope, backend_run_id=terminal_event.run_id, snapshot=terminal_event.session_snapshot, - composition_layer_specs=extract_cleanup_layer_specs(runtime_request.request.composition), + runtime_layer_specs=extract_runtime_layer_specs(runtime_request.request.composition), metadata=metadata, + pending_form_id=pending_form_id, + pending_tool_call_id=pending_tool_call_id, ) - yield PauseRequestedEvent( - reason=SchedulingPause( - message=terminal_event.message - or "Agent backend run requested workflow pause for external input." - ) - ) + yield PauseRequestedEvent(reason=pause_reason) return # Non-success terminal (failed / cancelled) skips per-output @@ -293,7 +367,7 @@ class DifyAgentNode(Node[DifyAgentNodeData]): session_scope=session_scope, backend_run_id=terminal_event.run_id, snapshot=terminal_event.session_snapshot, - composition_layer_specs=extract_cleanup_layer_specs(runtime_request.request.composition), + runtime_layer_specs=extract_runtime_layer_specs(runtime_request.request.composition), metadata=metadata, ) @@ -394,16 +468,15 @@ class DifyAgentNode(Node[DifyAgentNodeData]): **dict(metadata.get("agent_backend") or {}), "stream_event_count": stream_event_count, } - # Narrow to the 4 known terminal event types so the caller - # can hand the result to ``build_failure_result`` (which is - # typed against the union). Anything else is a protocol- - # level surprise we surface as a stream error. + # Narrow to the known terminal event types before returning + # to the caller. Deferred-tool events are terminal on the + # Dify Agent wire, then converted into workflow pause locally. if isinstance( internal_event, AgentBackendRunSucceededInternalEvent | AgentBackendRunFailedInternalEvent | AgentBackendRunCancelledInternalEvent - | AgentBackendRunPausedInternalEvent, + | AgentBackendDeferredToolCallInternalEvent, ): return internal_event, None return None, self._failure_event( @@ -449,14 +522,37 @@ class DifyAgentNode(Node[DifyAgentNodeData]): ], } + def _build_human_input_form_repository( + self, + *, + dify_ctx: DifyRunContext, + workflow_run_id: str | None, + ) -> HumanInputFormRepository: + """Construct the existing HITL form repository for ask_human form creation. + + Mirrors the Human Input node's repository wiring (``node_runtime``) so the + ask_human form shares the same delivery/debug/console behavior: a + submission actor is only attributed for debugger/explore surfaces. + """ + invoke_source = dify_ctx.invoke_from.value + return HumanInputFormRepositoryImpl( + tenant_id=dify_ctx.tenant_id, + app_id=dify_ctx.app_id, + workflow_execution_id=workflow_run_id, + invoke_source=invoke_source, + submission_actor_id=dify_ctx.user_id if invoke_source in {"debugger", "explore"} else None, + ) + def _save_session_snapshot( self, *, session_scope: WorkflowAgentSessionScope, backend_run_id: str, snapshot: CompositorSessionSnapshot | None, - composition_layer_specs: list[CleanupLayerSpec], + runtime_layer_specs: list[RuntimeLayerSpec], metadata: dict[str, Any], + pending_form_id: str | None = None, + pending_tool_call_id: str | None = None, ) -> None: if self._session_store is None: return @@ -465,7 +561,9 @@ class DifyAgentNode(Node[DifyAgentNodeData]): scope=session_scope, backend_run_id=backend_run_id, snapshot=snapshot, - composition_layer_specs=composition_layer_specs, + runtime_layer_specs=runtime_layer_specs, + pending_form_id=pending_form_id, + pending_tool_call_id=pending_tool_call_id, ) agent_backend = dict(metadata.get("agent_backend") or {}) agent_backend["session_snapshot_persisted"] = snapshot is not None diff --git a/api/core/workflow/nodes/agent_v2/ask_human_hitl.py b/api/core/workflow/nodes/agent_v2/ask_human_hitl.py new file mode 100644 index 00000000000..0b535cf4f10 --- /dev/null +++ b/api/core/workflow/nodes/agent_v2/ask_human_hitl.py @@ -0,0 +1,354 @@ +"""Map a ``dify.ask_human`` deferred tool call onto the existing HITL form path. + +ENG-636. When an Agent backend run ends with a deferred ``dify.ask_human`` tool +call, the workflow Agent node pauses the *outer* workflow through the very same +``HumanInputRequired`` form mechanism the Human Input node uses — reusing the +form repository, delivery channels, and submission endpoints unchanged. This +module is a pure translation layer: model-facing ask_human tool args become +graphon form entities (``HumanInputNodeData`` / ``FormInputConfig`` / +``UserActionConfig``) plus Dify delivery configs. It adds no new HITL behavior. + +The agent-side ``dify.ask_human`` contract is richer than the workflow form +schema in two places, handled here without widening the form vocabulary: + +* ask_human fields carry a human label; graphon ``FormInputConfig`` does not. + Labels are rendered into ``form_content`` next to a ``{{#$output.#}}`` + marker — exactly how the Human Input node positions labelled inputs today. +* ask_human action ids are valid identifiers of any length; graphon + ``UserActionConfig.id`` caps ids at 20 chars. Over-long ids are clamped + deterministically (stable + collision-resistant) so the form always builds; + the human-visible action *label* is always preserved verbatim. +""" + +from __future__ import annotations + +import hashlib +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Any, assert_never + +from dify_agent.layers.ask_human import ( + AskHumanAction, + AskHumanField, + AskHumanFileField, + AskHumanFileListField, + AskHumanParagraphField, + AskHumanSelectField, + AskHumanToolArgs, +) +from dify_agent.protocol import DeferredToolCallPayload +from pydantic import ValidationError + +from core.repositories.human_input_repository import FormCreateParams, HumanInputFormRepository +from core.workflow.human_input_adapter import ( + DeliveryChannelConfig, + EmailDeliveryConfig, + EmailDeliveryMethod, + EmailRecipients, + ExternalRecipient, + InteractiveSurfaceDeliveryMethod, +) +from graphon.entities.pause_reason import HumanInputRequired +from graphon.nodes.human_input.entities import ( + FileInputConfig, + FileListInputConfig, + FormInputConfig, + HumanInputNodeData, + ParagraphInputConfig, + SelectInputConfig, + StringListSource, + StringSource, + UserActionConfig, +) +from graphon.nodes.human_input.enums import ButtonStyle, TimeoutUnit, ValueSourceType +from models.agent_config_entities import AgentHumanContactConfig + +# Default ask_human tool name (see ``DifyAskHumanLayerConfig.tool_name``). The +# Agent node only knows how to translate this one deferred tool into a form. +DEFAULT_ASK_HUMAN_TOOL_NAME = "ask_human" + +# Graphon ``UserActionConfig.id`` hard-caps identifiers at 20 chars. +_MAX_ACTION_ID_LEN = 20 +# No timeout lives in the ask_human contract; reuse the Human Input node default. +_DEFAULT_TIMEOUT_HOURS = 36 +_EMAIL_SUBJECT_MAX_LEN = 120 + +# ``ButtonStyle`` has no "destructive"; map the ask_human destructive intent onto +# the closest existing form style rather than extending the HITL vocabulary. +_ACTION_STYLE_TO_BUTTON: dict[str, ButtonStyle] = { + "default": ButtonStyle.DEFAULT, + "primary": ButtonStyle.PRIMARY, + "destructive": ButtonStyle.ACCENT, +} + +# ask_human permits zero actions (a plain acknowledgement); the form surface +# needs at least one submit affordance. +_DEFAULT_SUBMIT_ACTION = UserActionConfig(id="submit", title="Submit", button_style=ButtonStyle.PRIMARY) + + +class AskHumanFormBuildError(ValueError): + """Raised when ask_human tool args cannot be mapped to a HITL form.""" + + +def parse_ask_human_args(raw_args: object) -> AskHumanToolArgs: + """Validate the raw deferred-tool ``args`` payload into ``AskHumanToolArgs``.""" + if isinstance(raw_args, AskHumanToolArgs): + return raw_args + if isinstance(raw_args, Mapping): + try: + return AskHumanToolArgs.model_validate(dict(raw_args)) + except ValidationError as error: + raise AskHumanFormBuildError(f"invalid ask_human args: {error}") from error + raise AskHumanFormBuildError(f"ask_human args must be a mapping, got {type(raw_args).__name__}") + + +def _clamp_action_id(action_id: str) -> str: + """Return a stable graphon-safe (<= 20 char) action id. + + ask_human ids are already valid identifiers; ids within the limit pass + through untouched so the resume round-trip returns the model's exact id. + Over-long ids are truncated with a short content hash to stay deterministic + and collision-resistant while remaining a valid identifier. + """ + if len(action_id) <= _MAX_ACTION_ID_LEN: + return action_id + digest = hashlib.blake2b(action_id.encode("utf-8"), digest_size=3).hexdigest() + prefix = action_id[: _MAX_ACTION_ID_LEN - len(digest) - 1] + return f"{prefix}_{digest}" + + +def _to_form_input(field: AskHumanField) -> FormInputConfig: + match field: + case AskHumanParagraphField(): + default = ( + StringSource(type=ValueSourceType.CONSTANT, value=field.default) if field.default is not None else None + ) + return ParagraphInputConfig(output_variable_name=field.name, default=default) + case AskHumanSelectField(): + return SelectInputConfig( + output_variable_name=field.name, + option_source=StringListSource( + type=ValueSourceType.CONSTANT, + value=[option.value for option in field.options], + ), + ) + case AskHumanFileField(): + return FileInputConfig(output_variable_name=field.name) + case AskHumanFileListField(): + return FileListInputConfig(output_variable_name=field.name, number_limits=field.max_files or 0) + case _: # pragma: no cover - exhaustive over the discriminated union + assert_never(field) + + +def _to_user_actions(actions: Sequence[AskHumanAction]) -> list[UserActionConfig]: + if not actions: + return [_DEFAULT_SUBMIT_ACTION] + return [ + UserActionConfig( + id=_clamp_action_id(action.id), + title=action.label, + button_style=_ACTION_STYLE_TO_BUTTON.get(action.style, ButtonStyle.DEFAULT), + ) + for action in actions + ] + + +def _render_form_content(args: AskHumanToolArgs) -> str: + """Compose the markdown body, positioning each field's label + input marker. + + Graphon ``FormInputConfig`` carries no label, so the field label is written + into the content next to the ``{{#$output.#}}`` marker that the form + surface replaces with the live input — identical to the Human Input node. + """ + blocks: list[str] = [] + if args.title: + blocks.append(f"## {args.title}") + blocks.append(args.question) + if args.markdown: + blocks.append(args.markdown) + for field in args.fields: + label = f"{field.label} *" if field.required else field.label + blocks.append(f"**{label}**\n\n{{{{#$output.{field.name}#}}}}") + return "\n\n".join(blocks) + + +def _resolved_default_values(args: AskHumanToolArgs) -> dict[str, Any]: + """Pre-fill map the form surface reads, keyed by output variable name. + + The graphon select input has no default field, so a select default can only + be conveyed here; paragraph defaults are included for a uniform pre-fill. + """ + defaults: dict[str, Any] = {} + for field in args.fields: + if isinstance(field, AskHumanParagraphField | AskHumanSelectField) and field.default is not None: + defaults[field.name] = field.default + return defaults + + +def ask_human_args_to_node_data(args: AskHumanToolArgs, *, node_title: str) -> HumanInputNodeData: + """Translate validated ask_human args into a synthetic Human Input node config.""" + return HumanInputNodeData( + title=node_title, + form_content=_render_form_content(args), + inputs=[_to_form_input(field) for field in args.fields], + user_actions=_to_user_actions(args.actions), + timeout=_DEFAULT_TIMEOUT_HOURS, + timeout_unit=TimeoutUnit.HOUR, + ) + + +def build_delivery_methods( + contacts: Sequence[AgentHumanContactConfig], + *, + args: AskHumanToolArgs, +) -> list[DeliveryChannelConfig]: + """Build form delivery channels: always the interactive surface, plus email to + the configured human contacts (the recipients chosen in Agent Soul).""" + methods: list[DeliveryChannelConfig] = [InteractiveSurfaceDeliveryMethod()] + + seen: set[str] = set() + emails: list[str] = [] + for contact in contacts: + email = (contact.email or "").strip() + if email and email not in seen: + seen.add(email) + emails.append(email) + + if emails: + subject = (args.title or args.question).strip()[:_EMAIL_SUBJECT_MAX_LEN] + if args.urgency == "high": + subject = f"[Action needed] {subject}" + body = f"{args.question}\n\nOpen the request: {EmailDeliveryConfig.URL_PLACEHOLDER}" + methods.append( + EmailDeliveryMethod( + config=EmailDeliveryConfig( + recipients=EmailRecipients(items=[ExternalRecipient(email=email) for email in emails]), + subject=subject, + body=body, + ) + ) + ) + return methods + + +@dataclass(frozen=True, slots=True) +class AskHumanFormCreated: + """A created ask_human HITL form, owner-agnostic (workflow run or conversation).""" + + form_id: str + args: AskHumanToolArgs + node_data: HumanInputNodeData + node_title: str + resolved_default_values: dict[str, Any] + + +def create_ask_human_form( + *, + deferred_tool_call: DeferredToolCallPayload, + node_id: str, + default_node_title: str, + contacts: Sequence[AgentHumanContactConfig], + repository: HumanInputFormRepository, + workflow_run_id: str | None = None, + conversation_id: str | None = None, + display_in_ui: bool = True, +) -> AskHumanFormCreated: + """Create a HITL form from an ask_human deferred call (caller verified tool_name). + + The form is owned by exactly one of ``workflow_run_id`` (workflow Agent node) + or ``conversation_id`` (Agent v2 chat). Raises ``AskHumanFormBuildError`` on + invalid args, a missing owner, or a repository failure. + """ + if not workflow_run_id and not conversation_id: + raise AskHumanFormBuildError("an ask_human HITL form requires a workflow_run_id or conversation_id") + + args = parse_ask_human_args(deferred_tool_call.args) + node_title = args.title or default_node_title + node_data = ask_human_args_to_node_data(args, node_title=node_title) + resolved_default_values = _resolved_default_values(args) + + try: + form = repository.create_form( + FormCreateParams( + workflow_execution_id=workflow_run_id, + conversation_id=conversation_id, + node_id=node_id, + form_config=node_data, + # No workflow-variable placeholders to resolve — the content is + # fully model-authored, so rendered == template. + rendered_content=node_data.form_content, + delivery_methods=build_delivery_methods(contacts, args=args), + display_in_ui=display_in_ui, + resolved_default_values=resolved_default_values, + ) + ) + except ValueError as error: + raise AskHumanFormBuildError(f"failed to create ask_human HITL form: {error}") from error + + return AskHumanFormCreated( + form_id=form.id, + args=args, + node_data=node_data, + node_title=node_title, + resolved_default_values=resolved_default_values, + ) + + +def build_ask_human_pause_reason( + *, + deferred_tool_call: DeferredToolCallPayload, + node_id: str, + default_node_title: str, + workflow_run_id: str | None, + contacts: Sequence[AgentHumanContactConfig], + repository: HumanInputFormRepository, + conversation_id: str | None = None, + expected_tool_name: str = DEFAULT_ASK_HUMAN_TOOL_NAME, + display_in_ui: bool = True, +) -> HumanInputRequired | None: + """Create a workflow HITL form for an ask_human call and return its pause reason. + + Returns ``None`` when the deferred call is *not* the ask_human tool, letting + the caller fall back to a generic scheduling pause. Raises + ``AskHumanFormBuildError`` when the call is ask_human but its args or the form + cannot be built — the caller should surface that as a node failure rather + than a silent, unresumable pause. + """ + if deferred_tool_call.tool_name != expected_tool_name: + return None + if not workflow_run_id: + raise AskHumanFormBuildError("workflow_run_id is required to create an ask_human HITL form") + + created = create_ask_human_form( + deferred_tool_call=deferred_tool_call, + node_id=node_id, + default_node_title=default_node_title, + contacts=contacts, + repository=repository, + workflow_run_id=workflow_run_id, + # A chatflow agent node also belongs to a conversation; tag the form so it is + # queryable per conversation. None for a pure workflow run (workflow_run_id only). + conversation_id=conversation_id, + display_in_ui=display_in_ui, + ) + return HumanInputRequired( + form_id=created.form_id, + form_content=created.node_data.form_content, + inputs=list(created.node_data.inputs), + actions=list(created.node_data.user_actions), + node_id=node_id, + node_title=created.node_title, + resolved_default_values=created.resolved_default_values, + ) + + +__all__ = [ + "DEFAULT_ASK_HUMAN_TOOL_NAME", + "AskHumanFormBuildError", + "AskHumanFormCreated", + "ask_human_args_to_node_data", + "build_ask_human_pause_reason", + "build_delivery_methods", + "create_ask_human_form", + "parse_ask_human_args", +] diff --git a/api/core/workflow/nodes/agent_v2/ask_human_resume.py b/api/core/workflow/nodes/agent_v2/ask_human_resume.py new file mode 100644 index 00000000000..13c9602722b --- /dev/null +++ b/api/core/workflow/nodes/agent_v2/ask_human_resume.py @@ -0,0 +1,159 @@ +"""Resume an Agent run after a dify.ask_human HITL form reaches a terminal state. + +ENG-638. When the outer workflow resumes (the human submitted the form, or it +timed out), graphon re-executes the Agent node's ``_run``. This module reads the +correlated HITL form (by ``pending_form_id``) and maps it back into the +agent-side ``dify.ask_human`` contract so the node can start a *second* Agent run +that carries the human's answer: + +* submitted -> AskHumanToolResult(status="submitted", action, values) +* timeout / expired -> AskHumanToolResult(status="timeout") +* still waiting (defensive: the host resumed us early) -> re-emit the same + HumanInputRequired pause rebuilt from the stored form definition. + +It only *reads* existing HITL form state — it never mutates the form or the HITL +submission flow. The DB read (``resolve_ask_human_form``) is kept thin so the +mapping (``map_form_to_outcome``) stays pure and unit-testable. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass + +from dify_agent.layers.ask_human import ( + AskHumanResultStatus, + AskHumanSelectedAction, + AskHumanToolResult, +) +from dify_agent.protocol import DeferredToolResultsPayload +from pydantic import JsonValue +from sqlalchemy import select + +from core.db.session_factory import session_factory +from graphon.entities.pause_reason import HumanInputRequired +from graphon.nodes.human_input.entities import FormDefinition +from graphon.nodes.human_input.enums import HumanInputFormStatus +from models.human_input import HumanInputForm + +# A WAITING form has not been answered yet; the other terminal states map onto +# the agent-facing result status. EXPIRED (global timeout) and TIMEOUT +# (node-level) both surface to the model as "timeout" so it can react. +_FORM_STATUS_TO_RESULT: dict[HumanInputFormStatus, AskHumanResultStatus] = { + HumanInputFormStatus.SUBMITTED: "submitted", + HumanInputFormStatus.TIMEOUT: "timeout", + HumanInputFormStatus.EXPIRED: "timeout", +} + + +@dataclass(frozen=True, slots=True) +class AskHumanResumeOutcome: + """Result of inspecting a correlated ask_human form on workflow resume. + + Exactly one of ``deferred_result`` / ``repause`` is set: + * ``deferred_result`` — the form reached a terminal state; start the second run. + * ``repause`` — the form is still waiting; re-emit this pause defensively. + """ + + deferred_result: AskHumanToolResult | None = None + repause: HumanInputRequired | None = None + + +def resolve_ask_human_form(*, form_id: str, tenant_id: str, node_id: str) -> AskHumanResumeOutcome | None: + """Load the correlated form and map it to a resume outcome. + + Returns ``None`` when the form no longer exists (correlation lost) — the + caller should fall back to a normal (non-resume) Agent run. + """ + with session_factory.create_session() as session: + form = session.scalar( + select(HumanInputForm).where( + HumanInputForm.id == form_id, + HumanInputForm.tenant_id == tenant_id, + ) + ) + if form is None: + return None + return map_form_to_outcome( + status=form.status, + selected_action_id=form.selected_action_id, + submitted_data=form.submitted_data, + rendered_content=form.rendered_content, + form_definition=form.form_definition, + form_id=form.id, + node_id=node_id, + ) + + +def map_form_to_outcome( + *, + status: HumanInputFormStatus, + selected_action_id: str | None, + submitted_data: str | None, + rendered_content: str, + form_definition: str, + form_id: str, + node_id: str, +) -> AskHumanResumeOutcome: + """Map a terminal (or still-waiting) HITL form to a resume outcome. Pure.""" + definition = FormDefinition.model_validate_json(form_definition) + if status == HumanInputFormStatus.WAITING: + return AskHumanResumeOutcome(repause=_rebuild_pause(definition=definition, form_id=form_id, node_id=node_id)) + + result_status = _FORM_STATUS_TO_RESULT.get(status, "unavailable") + if result_status != "submitted": + return AskHumanResumeOutcome(deferred_result=AskHumanToolResult(status=result_status)) + return AskHumanResumeOutcome( + deferred_result=AskHumanToolResult( + status="submitted", + action=_selected_action(selected_action_id=selected_action_id, definition=definition), + values=_submitted_values(submitted_data), + rendered_content=rendered_content, + ) + ) + + +def build_deferred_tool_results(*, tool_call_id: str, result: AskHumanToolResult) -> DeferredToolResultsPayload: + """Wrap an ask_human result as the deferred-tool-results payload for resume.""" + return DeferredToolResultsPayload(calls={tool_call_id: result.model_dump(mode="json")}) + + +def _submitted_values(submitted_data: str | None) -> dict[str, JsonValue]: + if not submitted_data: + return {} + parsed = json.loads(submitted_data) + if not isinstance(parsed, dict): + return {} + return {str(key): value for key, value in parsed.items()} + + +def _selected_action(*, selected_action_id: str | None, definition: FormDefinition) -> AskHumanSelectedAction | None: + if selected_action_id is None: + return None + # The form's user_action title is the verbatim ask_human action label set at + # form-build time; fall back to the id only if the action is somehow missing. + label = next( + (action.title for action in definition.user_actions if action.id == selected_action_id), + selected_action_id, + ) + return AskHumanSelectedAction(id=selected_action_id, label=label) + + +def _rebuild_pause(*, definition: FormDefinition, form_id: str, node_id: str) -> HumanInputRequired: + return HumanInputRequired( + form_id=form_id, + form_content=definition.rendered_content or definition.form_content, + inputs=list(definition.inputs), + actions=list(definition.user_actions), + node_id=node_id, + node_title=definition.node_title or node_id, + resolved_default_values=dict(definition.default_values), + ) + + +__all__ = [ + "AskHumanResumeOutcome", + "build_deferred_tool_results", + "map_form_to_outcome", + "resolve_ask_human_form", +] diff --git a/api/core/workflow/nodes/agent_v2/binding_resolver.py b/api/core/workflow/nodes/agent_v2/binding_resolver.py index d2f50b0ae4d..8dbabff2b8f 100644 --- a/api/core/workflow/nodes/agent_v2/binding_resolver.py +++ b/api/core/workflow/nodes/agent_v2/binding_resolver.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from sqlalchemy import select from core.db.session_factory import session_factory -from models.agent import Agent, AgentConfigSnapshot, AgentStatus, WorkflowAgentNodeBinding +from models.agent import Agent, AgentConfigSnapshot, AgentStatus, WorkflowAgentBindingType, WorkflowAgentNodeBinding class WorkflowAgentBindingError(Exception): @@ -52,11 +52,6 @@ class WorkflowAgentBindingResolver: ) if binding.agent_id is None: raise WorkflowAgentBindingError("agent_not_available", "Workflow Agent binding has no agent.") - if binding.current_snapshot_id is None: - raise WorkflowAgentBindingError( - "agent_config_snapshot_not_found", - "Workflow Agent binding has no current config snapshot.", - ) agent = session.scalar( select(Agent) @@ -72,19 +67,30 @@ class WorkflowAgentBindingResolver: f"Agent {binding.agent_id} is not available.", ) + snapshot_id = ( + agent.active_config_snapshot_id + if binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT + else binding.current_snapshot_id + ) + if snapshot_id is None: + raise WorkflowAgentBindingError( + "agent_config_snapshot_not_found", + "Workflow Agent binding has no current config snapshot.", + ) + snapshot = session.scalar( select(AgentConfigSnapshot) .where( AgentConfigSnapshot.tenant_id == tenant_id, AgentConfigSnapshot.agent_id == agent.id, - AgentConfigSnapshot.id == binding.current_snapshot_id, + AgentConfigSnapshot.id == snapshot_id, ) .limit(1) ) if snapshot is None: raise WorkflowAgentBindingError( "agent_config_snapshot_not_found", - f"Agent config snapshot {binding.current_snapshot_id} not found.", + f"Agent config snapshot {snapshot_id} not found.", ) session.expunge(binding) diff --git a/api/core/workflow/nodes/agent_v2/output_adapter.py b/api/core/workflow/nodes/agent_v2/output_adapter.py index b679c409bac..0fae0105348 100644 --- a/api/core/workflow/nodes/agent_v2/output_adapter.py +++ b/api/core/workflow/nodes/agent_v2/output_adapter.py @@ -4,11 +4,11 @@ from collections.abc import Mapping, Sequence from typing import Any, Protocol from clients.agent_backend import ( + AgentBackendDeferredToolCallInternalEvent, AgentBackendInternalEvent, AgentBackendInternalEventType, AgentBackendRunCancelledInternalEvent, AgentBackendRunFailedInternalEvent, - AgentBackendRunPausedInternalEvent, AgentBackendRunSucceededInternalEvent, ) from core.app.file_access import DatabaseFileAccessController @@ -85,11 +85,7 @@ class WorkflowAgentOutputAdapter: def build_failure_result( self, *, - event: ( - AgentBackendRunFailedInternalEvent - | AgentBackendRunCancelledInternalEvent - | AgentBackendRunPausedInternalEvent - ), + event: (AgentBackendRunFailedInternalEvent | AgentBackendRunCancelledInternalEvent), inputs: dict[str, Any], process_data: dict[str, Any], metadata: dict[str, Any], @@ -108,10 +104,6 @@ class WorkflowAgentOutputAdapter: error = event.message or "Agent backend run was cancelled." error_type = "agent_backend_run_cancelled" terminal_status = "cancelled" - case AgentBackendRunPausedInternalEvent(): - error = event.message or "Agent backend run paused, but workflow Agent Node pause is not supported yet." - error_type = "agent_backend_paused_unsupported" - terminal_status = "paused" metadata = self._with_terminal_metadata(metadata, event, terminal_status) usage = self._usage_from_metadata(metadata) @@ -339,7 +331,7 @@ class WorkflowAgentOutputAdapter: } ) session_snapshot = None - if isinstance(event, AgentBackendRunSucceededInternalEvent | AgentBackendRunPausedInternalEvent): + if isinstance(event, AgentBackendRunSucceededInternalEvent | AgentBackendDeferredToolCallInternalEvent): session_snapshot = event.session_snapshot if session_snapshot is not None: agent_backend["session_snapshot"] = { diff --git a/api/core/workflow/nodes/agent_v2/plugin_tools_builder.py b/api/core/workflow/nodes/agent_v2/plugin_tools_builder.py index 09defaf2d51..80366cc5193 100644 --- a/api/core/workflow/nodes/agent_v2/plugin_tools_builder.py +++ b/api/core/workflow/nodes/agent_v2/plugin_tools_builder.py @@ -42,14 +42,32 @@ class AgentToolRuntimeProvider(Protocol): user_id: str | None = None, invoke_from: InvokeFrom = InvokeFrom.DEBUGGER, variable_pool: Any | None = None, + allow_file_parameters: bool = False, + use_default_for_missing_form_parameters: bool = False, ) -> Tool: ... +class ProviderToolsLister(Protocol): + def __call__(self, *, tenant_id: str, provider_id: str) -> list[str]: ... + + +def _list_provider_tool_names(*, tenant_id: str, provider_id: str) -> list[str]: + """Tool names a provider currently declares (provider-level config entries).""" + provider = ToolManager.get_builtin_provider(provider_id, tenant_id) + return [tool.entity.identity.name for tool in provider.get_tools() or []] + + class WorkflowAgentPluginToolsBuilder: """Prepare Agent Soul Dify Plugin Tools for the public Agent backend DTO.""" - def __init__(self, *, tool_runtime_provider: AgentToolRuntimeProvider | None = None) -> None: + def __init__( + self, + *, + tool_runtime_provider: AgentToolRuntimeProvider | None = None, + provider_tools_lister: ProviderToolsLister | None = None, + ) -> None: self._tool_runtime_provider = tool_runtime_provider or ToolManager + self._provider_tools_lister = provider_tools_lister or _list_provider_tool_names def build( self, @@ -73,7 +91,7 @@ class WorkflowAgentPluginToolsBuilder: prepared: list[DifyPluginToolConfig] = [] seen_names: set[str] = set() - for tool_config in enabled_tools: + for tool_config in self._expand_provider_entries(tenant_id=tenant_id, enabled_tools=enabled_tools): agent_tool = self._to_agent_tool_entity(tool_config) tool_runtime = self._fetch_tool_runtime( tenant_id=tenant_id, @@ -96,6 +114,48 @@ class WorkflowAgentPluginToolsBuilder: return DifyPluginToolsLayerConfig(tools=prepared) + def _expand_provider_entries( + self, + *, + tenant_id: str, + enabled_tools: list[AgentSoulDifyToolConfig], + ) -> list[AgentSoulDifyToolConfig]: + """Expand provider-level entries (``tool_name`` omitted = all tools). + + An explicit per-tool entry of the same provider wins over the expansion + (it may carry its own ``runtime_parameters``); expanded clones share the + provider entry's ``credential_ref`` and start with default parameters. + """ + explicit_by_provider: dict[str, set[str]] = {} + for tool_config in enabled_tools: + if tool_config.tool_name is not None: + explicit_by_provider.setdefault(self._provider_id(tool_config), set()).add(tool_config.tool_name) + + expanded: list[AgentSoulDifyToolConfig] = [] + for tool_config in enabled_tools: + if tool_config.tool_name is not None: + expanded.append(tool_config) + continue + provider_id = self._provider_id(tool_config) + try: + tool_names = self._provider_tools_lister(tenant_id=tenant_id, provider_id=provider_id) + except ToolProviderNotFoundError as exc: + raise WorkflowAgentPluginToolsBuildError( + "agent_tool_declaration_not_found", + f"Dify Plugin Tool provider {provider_id!r} declaration not found: {exc}", + ) from exc + if not tool_names: + raise WorkflowAgentPluginToolsBuildError( + "agent_tool_declaration_not_found", + f"Dify Plugin Tool provider {provider_id!r} declares no tools.", + ) + already_explicit = explicit_by_provider.get(provider_id, set()) + for tool_name in tool_names: + if tool_name in already_explicit: + continue + expanded.append(tool_config.model_copy(update={"tool_name": tool_name, "runtime_parameters": {}})) + return expanded + def _fetch_tool_runtime( self, *, @@ -118,6 +178,8 @@ class WorkflowAgentPluginToolsBuilder: user_id=user_id, invoke_from=invoke_from, variable_pool=None, + allow_file_parameters=True, + use_default_for_missing_form_parameters=True, ) except ToolProviderNotFoundError as exc: raise WorkflowAgentPluginToolsBuildError( @@ -141,6 +203,8 @@ class WorkflowAgentPluginToolsBuilder: @staticmethod def _to_agent_tool_entity(tool_config: AgentSoulDifyToolConfig) -> AgentToolEntity: + # Provider-level entries are expanded into per-tool clones before this point. + assert tool_config.tool_name is not None return AgentToolEntity( provider_type=ToolProviderType.value_of(tool_config.provider_type), provider_id=WorkflowAgentPluginToolsBuilder._provider_id(tool_config), @@ -160,7 +224,9 @@ class WorkflowAgentPluginToolsBuilder: @staticmethod def _exposed_tool_name(tool_config: AgentSoulDifyToolConfig) -> str: # Stage 3.1 decision: no user rename yet. Keep the model-visible tool - # name aligned with the plugin declaration identity. + # name aligned with the plugin declaration identity. Provider-level + # entries are expanded into per-tool clones before this point. + assert tool_config.tool_name is not None return tool_config.tool_name def _to_backend_tool_config( @@ -190,11 +256,11 @@ class WorkflowAgentPluginToolsBuilder: return DifyPluginToolConfig( plugin_id=plugin_id, provider=provider, - tool_name=tool_config.tool_name, + tool_name=exposed_name, credential_type=self._credential_type(tool_config, runtime.credentials), name=exposed_name, description=description, - credentials=self._normalize_credentials(runtime.credentials, tool_name=tool_config.tool_name), + credentials=self._normalize_credentials(runtime.credentials, tool_name=exposed_name), runtime_parameters=runtime_parameters, parameters=parameters, parameters_json_schema=tool_runtime.get_llm_parameters_json_schema(), diff --git a/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py b/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py index 28590632425..8e0578d1a15 100644 --- a/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py +++ b/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py @@ -15,20 +15,28 @@ SUPPORTED_AGENT_BACKEND_FEATURES = frozenset( "tools.cli_tools", "env", "sandbox", + # ENG-623: exposed at runtime as the dify.drive declaration layer + # (an index the agent pulls through the back proxy). + "skills_files", + # ENG-635: human involvement is exposed at runtime as the dify.ask_human + # deferred tool; a call pauses via the existing HITL form mechanism. + "human", } ) RESERVED_AGENT_BACKEND_FEATURES = frozenset( { - "skills_files", "knowledge", - "human", "memory", } ) -def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any]: +def build_runtime_feature_manifest( + agent_soul: AgentSoulConfig, + *, + drive_manifest_enabled: bool = False, +) -> dict[str, Any]: """Describe PRD capabilities supported by or still reserved from Agent backend runtime.""" warnings: list[dict[str, str]] = [] soul_dump = agent_soul.model_dump(mode="json", exclude_none=True, exclude_defaults=True) @@ -46,11 +54,40 @@ def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any } ) + has_skills_files = bool(agent_soul.skills_files.skills or agent_soul.skills_files.files) + if has_skills_files and not drive_manifest_enabled: + warnings.append( + { + "section": "agent_soul.skills_files", + "code": "drive_manifest_disabled", + "message": ( + "skills_files is configured but AGENT_DRIVE_MANIFEST_ENABLED is off; " + "the drive declaration layer is not injected into this run." + ), + } + ) + for skill in agent_soul.skills_files.skills: + if not skill.skill_md_key: + warnings.append( + { + "section": "agent_soul.skills_files", + "code": "skill_ref_dangling", + "message": ( + f"skill_ref_dangling: skill '{skill.name or skill.id or 'unknown'}' has no drive key; " + "re-standardize it to expose it at runtime." + ), + } + ) + reserved_status = dict.fromkeys(sorted(RESERVED_AGENT_BACKEND_FEATURES), "reserved_not_executed") + reserved_status["skills_files"] = ( + "supported_by_drive_manifest" if drive_manifest_enabled else "drive_manifest_disabled" + ) reserved_status["tools.dify_tools"] = "supported_when_config_valid" reserved_status["tools.cli_tools"] = "supported_by_shell_bootstrap" reserved_status["env"] = "supported_by_shell_bootstrap" reserved_status["sandbox"] = "forwarded_to_shell_layer_config" + reserved_status["human"] = "supported_by_ask_human_hitl" if agent_soul.human.contacts else "not_configured" return { "supported": sorted(SUPPORTED_AGENT_BACKEND_FEATURES), diff --git a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py index 1a85b14d286..53c657e8ef9 100644 --- a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py +++ b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py @@ -5,6 +5,12 @@ from dataclasses import dataclass from typing import Any, Literal, Protocol, assert_never, cast from agenton.compositor import CompositorSessionSnapshot +from dify_agent.layers.ask_human import DifyAskHumanLayerConfig +from dify_agent.layers.drive import ( + DifyDriveFileConfig, + DifyDriveLayerConfig, + DifyDriveSkillConfig, +) from dify_agent.layers.execution_context import ( DifyExecutionContextInvokeFrom, DifyExecutionContextLayerConfig, @@ -17,7 +23,7 @@ from dify_agent.layers.shell import ( DifyShellSandboxConfig, DifyShellSecretRefConfig, ) -from dify_agent.protocol import CreateRunRequest +from dify_agent.protocol import CreateRunRequest, DeferredToolResultsPayload from pydantic import BaseModel from clients.agent_backend import ( @@ -36,6 +42,7 @@ from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding from models.agent_config_entities import ( AgentSoulConfig, DeclaredArrayItem, + DeclaredOutputChildConfig, DeclaredOutputConfig, DeclaredOutputType, WorkflowNodeJobConfig, @@ -98,6 +105,9 @@ class WorkflowAgentRuntimeBuildContext: # idempotency key so the backend treats each retry as a fresh request. attempt: int = 0 session_snapshot: CompositorSessionSnapshot | None = None + # ENG-638: set when resuming after a submitted ask_human HITL form; threads + # the human's answer back into the second Agent run keyed by tool_call_id. + deferred_tool_results: DeferredToolResultsPayload | None = None @dataclass(frozen=True, slots=True) @@ -169,6 +179,11 @@ class WorkflowAgentRuntimeRequestBuilder: "cli_tool_count": len(agent_soul.tools.cli_tools), } + drive_config: DifyDriveLayerConfig | None = None + if dify_config.AGENT_DRIVE_MANIFEST_ENABLED: + drive_config, drive_warnings = build_drive_layer_config(agent_soul, agent_id=context.agent.id) + append_runtime_warnings(metadata, drive_warnings) + request = self._request_builder.build_for_workflow_node( AgentBackendWorkflowNodeRunInput( model=AgentBackendModelConfig( @@ -206,9 +221,12 @@ class WorkflowAgentRuntimeRequestBuilder: user_prompt=user_prompt, output=self._build_output_config(node_job.declared_outputs), tools=tools_layer, + drive_config=drive_config, + ask_human_config=build_ask_human_layer_config(agent_soul), include_shell=dify_config.AGENT_SHELL_ENABLED, shell_config=build_shell_layer_config(agent_soul), session_snapshot=context.session_snapshot, + deferred_tool_results=context.deferred_tool_results, idempotency_key=self._idempotency_key(context), metadata=metadata, ) @@ -269,7 +287,10 @@ class WorkflowAgentRuntimeRequestBuilder: "agent_config_snapshot_id": context.snapshot.id, "binding_id": context.binding.id, "workflow_node_job_mode": node_job.mode.value, - "runtime_support": build_runtime_feature_manifest(agent_soul), + "runtime_support": build_runtime_feature_manifest( + agent_soul, + drive_manifest_enabled=dify_config.AGENT_DRIVE_MANIFEST_ENABLED, + ), } def _build_workflow_context_prompt( @@ -375,7 +396,11 @@ class WorkflowAgentRuntimeRequestBuilder: @staticmethod def _schema_for_declared_output(output: DeclaredOutputConfig) -> dict[str, Any]: - schema = WorkflowAgentRuntimeRequestBuilder._schema_for_type(output.type, array_item=output.array_item) + schema = WorkflowAgentRuntimeRequestBuilder._schema_for_type( + output.type, + array_item=output.array_item, + children=output.children, + ) if output.description: schema["description"] = output.description return schema @@ -385,6 +410,7 @@ class WorkflowAgentRuntimeRequestBuilder: output_type: DeclaredOutputType, *, array_item: DeclaredArrayItem | None = None, + children: Sequence[DeclaredOutputChildConfig] | None = None, ) -> dict[str, Any]: match output_type: case DeclaredOutputType.STRING: @@ -394,18 +420,23 @@ class WorkflowAgentRuntimeRequestBuilder: case DeclaredOutputType.BOOLEAN: return {"type": "boolean"} case DeclaredOutputType.OBJECT: - return {"type": "object"} + object_schema: dict[str, Any] = {"type": "object"} + WorkflowAgentRuntimeRequestBuilder._apply_child_properties(object_schema, children or []) + return object_schema case DeclaredOutputType.ARRAY: # Stage 4 §4.2: items shape mirrors the declared array_item. # Validator guarantees array_item is set when type is array. item_type = array_item.type if array_item else DeclaredOutputType.OBJECT - schema: dict[str, Any] = { + array_schema: dict[str, Any] = { "type": "array", - "items": WorkflowAgentRuntimeRequestBuilder._schema_for_type(item_type), + "items": WorkflowAgentRuntimeRequestBuilder._schema_for_type( + item_type, + children=array_item.children if array_item else None, + ), } if array_item is not None and array_item.description: - schema["items"]["description"] = array_item.description - return schema + array_schema["items"]["description"] = array_item.description + return array_schema case DeclaredOutputType.FILE: return { "oneOf": [ @@ -449,6 +480,27 @@ class WorkflowAgentRuntimeRequestBuilder: } assert_never(output_type) + @staticmethod + def _apply_child_properties(schema: dict[str, Any], children: Sequence[DeclaredOutputChildConfig]) -> None: + if not children: + return + properties: dict[str, Any] = {} + required: list[str] = [] + for child in children: + child_schema = WorkflowAgentRuntimeRequestBuilder._schema_for_type( + child.type, + array_item=child.array_item, + children=child.children, + ) + if child.description: + child_schema["description"] = child.description + properties[child.name] = child_schema + if child.required: + required.append(child.name) + schema["properties"] = properties + if required: + schema["required"] = required + @staticmethod def _normalize_credentials(credentials: Mapping[str, Any]) -> dict[str, str | int | float | bool | None]: normalized: dict[str, str | int | float | bool | None] = {} @@ -482,6 +534,103 @@ def build_shell_layer_config(agent_soul: AgentSoulConfig) -> DifyShellLayerConfi ) +def build_ask_human_layer_config(agent_soul: AgentSoulConfig) -> DifyAskHumanLayerConfig | None: + """Enable the dify.ask_human deferred tool when the soul configures human involvement. + + HITL is opt-in: only when at least one human contact is configured does the + model get the ``ask_human`` tool (recipients for the resulting form come from + those contacts, ENG-635). Returns ``None`` to leave the tool off entirely. + The tool/field guardrails use the layer defaults; ``human.tools`` semantics are + out of scope this round. + """ + if not agent_soul.human.contacts: + return None + return DifyAskHumanLayerConfig() + + +def append_runtime_warnings(metadata: dict[str, Any], warnings: list[dict[str, str]]) -> None: + """Merge build-time warnings into the metadata runtime-support manifest.""" + if not warnings: + return + manifest = metadata.setdefault("runtime_support", {}) + if isinstance(manifest, dict): + existing = manifest.setdefault("unsupported_runtime_warnings", []) + if isinstance(existing, list): + existing.extend(warnings) + + +def build_drive_layer_config( + agent_soul: AgentSoulConfig, + *, + agent_id: str | None, +) -> tuple[DifyDriveLayerConfig | None, list[dict[str, str]]]: + """Catalog the soul's drive-backed Skills & Files into the dify.drive declaration. + + Returns ``(config, warnings)`` — ``config is None`` means nothing to inject + (no skills/files configured, or no agent identity to address the drive by). + Refs that predate standardization (no drive key) are skipped with a warning + instead of failing the run, so historic souls keep running. + """ + skill_refs = agent_soul.skills_files.skills + file_refs = agent_soul.skills_files.files + if not skill_refs and not file_refs: + return None, [] + + warnings: list[dict[str, str]] = [] + if not agent_id: + warnings.append( + { + "section": "agent_soul.skills_files", + "code": "skill_ref_dangling", + "message": "skills_files is configured but the run has no bound agent to address a drive by.", + } + ) + return None, warnings + + skills: list[DifyDriveSkillConfig] = [] + for skill in skill_refs: + if not skill.skill_md_key: + warnings.append( + { + "section": "agent_soul.skills_files", + "code": "skill_ref_dangling", + "message": ( + f"skill_ref_dangling: skill '{skill.name or skill.id or 'unknown'}' has no drive key; " + "re-standardize it to expose it at runtime." + ), + } + ) + continue + skills.append( + DifyDriveSkillConfig( + name=skill.name or skill.skill_md_key.split("/", 1)[0], + description=skill.description or "", + skill_md_key=skill.skill_md_key, + archive_key=skill.full_archive_key, + ) + ) + + files: list[DifyDriveFileConfig] = [] + for file in file_refs: + if not file.drive_key: + # Plain upload references (pre-ENG-625) are not drive-backed; they are + # simply invisible to the manifest rather than a defect worth warning on. + continue + size = file.get("size") + files.append( + DifyDriveFileConfig( + name=file.name or file.drive_key.rsplit("/", 1)[-1], + key=file.drive_key, + size=size if isinstance(size, int) else None, + mime_type=file.type, + ) + ) + + if not skills and not files: + return None, warnings + return DifyDriveLayerConfig(drive_ref=f"agent-{agent_id}", skills=skills, files=files), warnings + + def _cli_tool_enabled(item: object) -> bool: """A CLI tool is bootstrapped unless explicitly disabled (default is enabled).""" data = _plain_mapping(item) @@ -508,7 +657,32 @@ def _shell_cli_tool(item: object) -> DifyShellCliToolConfig | None: name = data.get("name") or data.get("tool_name") or data.get("label") if not commands and not isinstance(name, str): return None - return DifyShellCliToolConfig(name=name if isinstance(name, str) else None, install_commands=commands) + tool_env = data.get("env") if isinstance(data.get("env"), Mapping) else {} + env = [ + env_var + for env_var in (_shell_env_var(item) for item in _env_entries(tool_env, "variables")) + if env_var is not None + ] + secret_refs = [ + secret_ref + for secret_ref in (_shell_secret_ref(item) for item in _env_entries(tool_env, "secret_refs")) + if secret_ref is not None + ] + return DifyShellCliToolConfig( + name=name if isinstance(name, str) else None, + install_commands=commands, + env=env, + secret_refs=secret_refs, + ) + + +def _env_entries(env: object, key: str) -> list[object]: + if not isinstance(env, Mapping): + return [] + entries = env.get(key) + if not isinstance(entries, list): + return [] + return entries def _shell_env_var(item: object) -> DifyShellEnvVarConfig | None: @@ -527,7 +701,13 @@ def _shell_secret_ref(item: object) -> DifyShellSecretRefConfig | None: name = _name_from_mapping(data) if name is None: return None - ref = data.get("ref") or data.get("id") or data.get("credential_id") or data.get("provider_credential_id") + ref = ( + data.get("ref") + or data.get("value") + or data.get("id") + or data.get("credential_id") + or data.get("provider_credential_id") + ) return DifyShellSecretRefConfig(name=name, ref=str(ref) if ref is not None else None) diff --git a/api/core/workflow/nodes/agent_v2/session_cleanup_layer.py b/api/core/workflow/nodes/agent_v2/session_cleanup_layer.py index 3c225ac4704..809f63b556f 100644 --- a/api/core/workflow/nodes/agent_v2/session_cleanup_layer.py +++ b/api/core/workflow/nodes/agent_v2/session_cleanup_layer.py @@ -40,7 +40,7 @@ class WorkflowAgentSessionCleanupLayer(GraphEngineLayer): the composition, the run fails asynchronously with ``run_failed`` — but the initial ``POST /runs`` already returned 202, so the API side has no visibility of the failure unless it waits for terminal status. The - ``composition_layer_specs`` persistence in A.1–A.4 plus the + ``runtime_layer_specs`` persistence in A.1–A.4 plus the ``_filter_snapshot_to_specs`` shape in ``build_cleanup_request`` keeps the two name lists in sync. @@ -144,13 +144,13 @@ class WorkflowAgentSessionCleanupLayer(GraphEngineLayer): ) return - if not stored_session.composition_layer_specs: + if not stored_session.runtime_layer_specs: # Sessions persisted before A.1 landed do not carry the spec list, # so we cannot replay a valid cleanup composition. Leave the row # ACTIVE and warn so the absence shows up in observability rather # than being silently swallowed by a doomed cleanup run. logger.warning( - "Skipping Agent backend cleanup: no composition_layer_specs persisted. " + "Skipping Agent backend cleanup: no runtime_layer_specs persisted. " "workflow_run_id=%s node_id=%s agent_id=%s", scope.workflow_run_id, scope.node_id, @@ -160,7 +160,7 @@ class WorkflowAgentSessionCleanupLayer(GraphEngineLayer): request = self._request_builder.build_cleanup_request( session_snapshot=stored_session.session_snapshot, - composition_layer_specs=stored_session.composition_layer_specs, + runtime_layer_specs=stored_session.runtime_layer_specs, idempotency_key=f"{scope.workflow_run_id}:{scope.node_id}:{scope.binding_id}:agent-session-cleanup", metadata={ "tenant_id": scope.tenant_id, diff --git a/api/core/workflow/nodes/agent_v2/session_store.py b/api/core/workflow/nodes/agent_v2/session_store.py index f3625cb7367..215fa67b5eb 100644 --- a/api/core/workflow/nodes/agent_v2/session_store.py +++ b/api/core/workflow/nodes/agent_v2/session_store.py @@ -3,10 +3,10 @@ from __future__ import annotations from dataclasses import dataclass, field from agenton.compositor import CompositorSessionSnapshot +from dify_agent.protocol import RuntimeLayerSpec from pydantic import TypeAdapter from sqlalchemy import select -from clients.agent_backend.request_builder import CleanupLayerSpec from core.db.session_factory import session_factory from libs.datetime_utils import naive_utc_now from models.agent import ( @@ -15,14 +15,14 @@ from models.agent import ( WorkflowAgentRuntimeSessionStatus, ) -_SPECS_ADAPTER: TypeAdapter[list[CleanupLayerSpec]] = TypeAdapter(list[CleanupLayerSpec]) +_SPECS_ADAPTER: TypeAdapter[list[RuntimeLayerSpec]] = TypeAdapter(list[RuntimeLayerSpec]) -def _serialize_specs(specs: list[CleanupLayerSpec]) -> str: +def _serialize_specs(specs: list[RuntimeLayerSpec]) -> str: return _SPECS_ADAPTER.dump_json(specs).decode() -def _deserialize_specs(value: str | None) -> list[CleanupLayerSpec]: +def _deserialize_specs(value: str | None) -> list[RuntimeLayerSpec]: if not value: return [] return _SPECS_ADAPTER.validate_json(value) @@ -46,13 +46,21 @@ class StoredWorkflowAgentSession: scope: WorkflowAgentSessionScope session_snapshot: CompositorSessionSnapshot backend_run_id: str | None - composition_layer_specs: list[CleanupLayerSpec] = field(default_factory=list) + runtime_layer_specs: list[RuntimeLayerSpec] = field(default_factory=list) + # ENG-637: set while the session is paused on a dify.ask_human deferred call. + pending_form_id: str | None = None + pending_tool_call_id: str | None = None class WorkflowAgentRuntimeSessionStore: """Stores Agent backend session snapshots for workflow Agent node re-entry.""" def load_active_snapshot(self, scope: WorkflowAgentSessionScope) -> CompositorSessionSnapshot | None: + stored = self.load_active_session(scope) + return stored.session_snapshot if stored is not None else None + + def load_active_session(self, scope: WorkflowAgentSessionScope) -> StoredWorkflowAgentSession | None: + """Load the active session row including any pending ask_human correlation.""" if scope.workflow_run_id is None: return None @@ -69,7 +77,14 @@ class WorkflowAgentRuntimeSessionStore: ) if row is None: return None - return CompositorSessionSnapshot.model_validate_json(row.session_snapshot) + return StoredWorkflowAgentSession( + scope=scope, + session_snapshot=CompositorSessionSnapshot.model_validate_json(row.session_snapshot), + backend_run_id=row.backend_run_id, + runtime_layer_specs=_deserialize_specs(row.composition_layer_specs), + pending_form_id=row.pending_form_id, + pending_tool_call_id=row.pending_tool_call_id, + ) def list_active_sessions(self, *, workflow_run_id: str) -> list[StoredWorkflowAgentSession]: with session_factory.create_session() as session: @@ -97,7 +112,7 @@ class WorkflowAgentRuntimeSessionStore: ), session_snapshot=CompositorSessionSnapshot.model_validate_json(row.session_snapshot), backend_run_id=row.backend_run_id, - composition_layer_specs=_deserialize_specs(row.composition_layer_specs), + runtime_layer_specs=_deserialize_specs(row.composition_layer_specs), ) for row in rows ] @@ -108,13 +123,15 @@ class WorkflowAgentRuntimeSessionStore: scope: WorkflowAgentSessionScope, backend_run_id: str, snapshot: CompositorSessionSnapshot | None, - composition_layer_specs: list[CleanupLayerSpec], + runtime_layer_specs: list[RuntimeLayerSpec], + pending_form_id: str | None = None, + pending_tool_call_id: str | None = None, ) -> None: if scope.workflow_run_id is None or snapshot is None: return snapshot_json = snapshot.model_dump_json() - specs_json = _serialize_specs(composition_layer_specs) + specs_json = _serialize_specs(runtime_layer_specs) with session_factory.create_session() as session: row = session.scalar( select(WorkflowAgentRuntimeSession).where( @@ -141,6 +158,8 @@ class WorkflowAgentRuntimeSessionStore: session_snapshot=snapshot_json, composition_layer_specs=specs_json, status=WorkflowAgentRuntimeSessionStatus.ACTIVE, + pending_form_id=pending_form_id, + pending_tool_call_id=pending_tool_call_id, ) session.add(row) else: @@ -151,6 +170,9 @@ class WorkflowAgentRuntimeSessionStore: row.composition_layer_specs = specs_json row.status = WorkflowAgentRuntimeSessionStatus.ACTIVE row.cleaned_at = None + # Set (or clear, when omitted) the ask_human pause correlation. + row.pending_form_id = pending_form_id + row.pending_tool_call_id = pending_tool_call_id session.commit() def mark_cleaned(self, *, scope: WorkflowAgentSessionScope, backend_run_id: str | None = None) -> None: diff --git a/api/core/workflow/nodes/agent_v2/validators.py b/api/core/workflow/nodes/agent_v2/validators.py index 832a1a5e152..ca3adb5b0d1 100644 --- a/api/core/workflow/nodes/agent_v2/validators.py +++ b/api/core/workflow/nodes/agent_v2/validators.py @@ -8,7 +8,7 @@ from sqlalchemy.orm import Session from core.workflow.graph_topology import WorkflowGraphTopology from graphon.enums import BuiltinNodeTypes -from models.agent import Agent, AgentConfigSnapshot, AgentStatus, WorkflowAgentNodeBinding +from models.agent import Agent, AgentConfigSnapshot, AgentStatus, WorkflowAgentBindingType, WorkflowAgentNodeBinding from models.agent_config_entities import ( AgentFileRefConfig, AgentHumanContactConfig, @@ -102,10 +102,6 @@ class WorkflowAgentNodeValidator: ) -> None: if binding.agent_id is None: raise WorkflowAgentNodeValidationError(f"Workflow Agent node {binding.node_id} is missing agent binding.") - if binding.current_snapshot_id is None: - raise WorkflowAgentNodeValidationError( - f"Workflow Agent node {binding.node_id} is missing config snapshot binding." - ) agent = session.scalar( select(Agent) @@ -120,12 +116,22 @@ class WorkflowAgentNodeValidator: f"Workflow Agent node {binding.node_id} references an unavailable agent." ) + snapshot_id = ( + agent.active_config_snapshot_id + if binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT + else binding.current_snapshot_id + ) + if snapshot_id is None: + raise WorkflowAgentNodeValidationError( + f"Workflow Agent node {binding.node_id} is missing config snapshot binding." + ) + snapshot = session.scalar( select(AgentConfigSnapshot) .where( AgentConfigSnapshot.tenant_id == binding.tenant_id, AgentConfigSnapshot.agent_id == agent.id, - AgentConfigSnapshot.id == binding.current_snapshot_id, + AgentConfigSnapshot.id == snapshot_id, ) .limit(1) ) @@ -322,7 +328,11 @@ class WorkflowAgentNodeValidator: for tool in agent_soul.tools.dify_tools: if not tool.enabled: continue - exposed_name = tool.tool_name + # Provider-level entries (tool_name omitted = all tools of the + # provider) are deduped per provider here; the names they expand to + # are checked at runtime by the plugin tools builder. + provider_key = tool.provider_id or f"{tool.plugin_id}/{tool.provider}" + exposed_name = tool.tool_name or f"{provider_key}/*" if exposed_name in exposed_names: raise WorkflowAgentNodeValidationError( f"Workflow Agent node {binding.node_id} has duplicate Dify Plugin Tool name {exposed_name}." @@ -363,8 +373,37 @@ class WorkflowAgentNodeValidator: agent_soul: AgentSoulConfig, ) -> None: seen_names: set[str] = set() - for env_var in agent_soul.env.variables: - name = env_var.name + cls._validate_env_entries( + binding=binding, + seen_names=seen_names, + variables=agent_soul.env.variables, + secret_refs=agent_soul.env.secret_refs, + label="agent", + ) + for cli_tool in agent_soul.tools.cli_tools: + if not cli_tool.enabled: + continue + name = cli_tool.get("name") or cli_tool.get("tool_name") or cli_tool.get("label") or "" + cls._validate_env_entries( + binding=binding, + seen_names=seen_names, + variables=cli_tool.env.variables, + secret_refs=cli_tool.env.secret_refs, + label=f"CLI Tool {name}", + ) + + @classmethod + def _validate_env_entries( + cls, + *, + binding: WorkflowAgentNodeBinding, + seen_names: set[str], + variables: list[Any], + secret_refs: list[Any], + label: str, + ) -> None: + for env_var in variables: + name = cls._env_name(env_var) if not name: continue if name in seen_names: @@ -372,13 +411,13 @@ class WorkflowAgentNodeValidator: f"Workflow Agent node {binding.node_id} has duplicate env/secret name {name}." ) seen_names.add(name) - for secret_ref in agent_soul.env.secret_refs: - name = secret_ref.name + for secret_ref in secret_refs: + name = cls._env_name(secret_ref) if not name: continue if cls._permission_denied(secret_ref.model_dump(mode="python", exclude_none=True, exclude_defaults=True)): raise WorkflowAgentNodeValidationError( - f"Workflow Agent node {binding.node_id} has unauthorized secret reference {name}." + f"Workflow Agent node {binding.node_id} has unauthorized secret reference {name} in {label}." ) if name in seen_names: raise WorkflowAgentNodeValidationError( @@ -386,6 +425,15 @@ class WorkflowAgentNodeValidator: ) seen_names.add(name) + @staticmethod + def _env_name(value: Any) -> str | None: + if hasattr(value, "get"): + for key in ("name", "key", "env_name", "variable"): + item = value.get(key) + if isinstance(item, str) and item.strip(): + return item.strip() + return None + @classmethod def _validate_tool_node_agentic_mode(cls, *, node_id: str, node_data: Mapping[str, Any]) -> None: agentic_config = cls._extract_tool_agentic_config(node_data) diff --git a/api/core/workflow/workflow_entry.py b/api/core/workflow/workflow_entry.py index 3019704dac5..9de26b8214b 100644 --- a/api/core/workflow/workflow_entry.py +++ b/api/core/workflow/workflow_entry.py @@ -30,6 +30,7 @@ from graphon.entities import GraphInitParams from graphon.entities.graph_config import NodeConfigDictAdapter from graphon.errors import WorkflowNodeRunFailedError from graphon.file import File +from graphon.filters import GraphEventFilterContext, ResponseStreamFilter, filter_graph_events from graphon.graph import Graph from graphon.graph_engine import GraphEngine, GraphEngineConfig from graphon.graph_engine.command_channels import CommandChannel, InMemoryChannel @@ -45,6 +46,21 @@ logger = logging.getLogger(__name__) _file_access_controller = DatabaseFileAccessController() +def iter_dify_graph_engine_events(engine: GraphEngine) -> Generator[GraphEngineEvent, None, None]: + """ + Apply Dify's response streaming compatibility filter to GraphEngine events. + + Graphon v0.5.0 emits raw variable stream chunks and requires callers to opt + into the legacy response-ordered stream behavior that Dify exposes to its + workflow runners and tests. + """ + yield from filter_graph_events( + engine.run(), + context=GraphEventFilterContext.from_engine(engine), + filters=[ResponseStreamFilter()], + ) + + class _WorkflowChildEngineBuilder: tenant_id: str @@ -223,8 +239,8 @@ class WorkflowEntry: graph_engine = self.graph_engine try: - # run workflow - generator = graph_engine.run() + # Preserve Dify's response-stream semantics on top of Graphon 0.5.0. + generator = iter_dify_graph_engine_events(graph_engine) yield from generator except GenerateTaskStoppedError: pass diff --git a/api/dev/generate_fastopenapi_specs.py b/api/dev/generate_fastopenapi_specs.py index 5a94d32b939..2a7f9450884 100644 --- a/api/dev/generate_fastopenapi_specs.py +++ b/api/dev/generate_fastopenapi_specs.py @@ -1,4 +1,4 @@ -"""Generate FastOpenAPI OpenAPI 3.0 specs without booting the full backend.""" +"""Generate FastOpenAPI OpenAPI 3.1 specs without booting the full backend.""" from __future__ import annotations diff --git a/api/dev/generate_swagger_markdown_docs.py b/api/dev/generate_swagger_markdown_docs.py index 75575b355b3..7028f740e02 100644 --- a/api/dev/generate_swagger_markdown_docs.py +++ b/api/dev/generate_swagger_markdown_docs.py @@ -1,7 +1,7 @@ """Generate OpenAPI JSON specs and split Markdown API docs. The Markdown step uses `swagger-markdown`, the same converter family as the -Swagger Markdown UI, so CI and local regeneration catch converter-incompatible +legacy Markdown UI, so CI and local regeneration catch converter-incompatible OpenAPI output early. """ @@ -25,19 +25,21 @@ from dev.generate_swagger_specs import SPEC_TARGETS, generate_specs logger = logging.getLogger(__name__) SWAGGER_MARKDOWN_PACKAGE = "swagger-markdown@3.0.0" -CONSOLE_SWAGGER_FILENAME = "console-swagger.json" +CONSOLE_OPENAPI_FILENAME = "console-openapi.json" STALE_COMBINED_MARKDOWN_FILENAME = "api-reference.md" -def _definition_ref_name(schema: object) -> str | None: +def _schema_ref_name(schema: object) -> str | None: if not isinstance(schema, dict): return None ref = schema.get("$ref") - if not isinstance(ref, str) or not ref.startswith("#/definitions/"): + if not isinstance(ref, str): return None - return ref.removeprefix("#/definitions/") + if ref.startswith("#/components/schemas/"): + return ref.removeprefix("#/components/schemas/") + return None def _markdown_anchor(name: str) -> str: @@ -48,7 +50,7 @@ def _schema_markdown_type(schema: object) -> str: if not isinstance(schema, dict): return "" - ref_name = _definition_ref_name(schema) + ref_name = _schema_ref_name(schema) if ref_name is not None: return f"[{ref_name}](#{_markdown_anchor(ref_name)})" @@ -111,15 +113,16 @@ def _has_union_schema(schema: object) -> bool: def _patch_union_schema_markdown(markdown: str, spec_path: Path) -> str: - """Fill Swagger Markdown table cells that `swagger-markdown` leaves blank for union schemas.""" + """Fill Markdown table cells that `swagger-markdown` leaves blank for union schemas.""" spec = json.loads(spec_path.read_text(encoding="utf-8")) - definitions = spec.get("definitions") - if not isinstance(definitions, dict): + components = spec.get("components") + schemas = components.get("schemas") if isinstance(components, dict) else None + if not isinstance(schemas, dict): return markdown - for definition_name, schema in definitions.items(): - if not isinstance(definition_name, str) or not isinstance(schema, dict): + for schema_name, schema in schemas.items(): + if not isinstance(schema_name, str) or not isinstance(schema, dict): continue properties = schema.get("properties") @@ -128,7 +131,7 @@ def _patch_union_schema_markdown(markdown: str, spec_path: Path) -> str: if isinstance(property_name, str) and _has_union_schema(property_schema): markdown = _replace_schema_table_type( markdown, - definition_name, + schema_name, property_name, _schema_markdown_type(property_schema), ) @@ -139,14 +142,14 @@ def _patch_union_schema_markdown(markdown: str, spec_path: Path) -> str: markdown = _replace_schema_table_type( markdown, - definition_name, - definition_name, + schema_name, + schema_name, _schema_markdown_type(schema), ) for variant in union_variants: - variant_name = _definition_ref_name(variant) - variant_schema = definitions.get(variant_name) if variant_name is not None else None + variant_name = _schema_ref_name(variant) + variant_schema = schemas.get(variant_name) if variant_name is not None else None if not isinstance(variant_name, str) or not isinstance(variant_schema, dict): continue properties = variant_schema.get("properties") @@ -229,7 +232,7 @@ def _append_fastopenapi_markdown(console_markdown_path: Path, fastopenapi_markdo "\n\n".join( [ console_markdown, - "## FastOpenAPI Preview (OpenAPI 3.0)", + "## FastOpenAPI Preview (OpenAPI 3.1)", fastopenapi_markdown, ] ) @@ -239,17 +242,17 @@ def _append_fastopenapi_markdown(console_markdown_path: Path, fastopenapi_markdo def generate_markdown_docs( - swagger_dir: Path, + openapi_dir: Path, markdown_dir: Path, *, keep_swagger_json: bool = False, ) -> list[Path]: """Generate intermediate specs, convert them to split Markdown API docs, and return Markdown paths.""" - swagger_paths = generate_specs(swagger_dir) - fastopenapi_paths = generate_fastopenapi_specs(swagger_dir) - spec_paths = [*swagger_paths, *fastopenapi_paths] - swagger_paths_by_name = {path.name: path for path in swagger_paths} + openapi_paths = generate_specs(openapi_dir) + fastopenapi_paths = generate_fastopenapi_specs(openapi_dir) + spec_paths = [*openapi_paths, *fastopenapi_paths] + openapi_paths_by_name = {path.name: path for path in openapi_paths} fastopenapi_paths_by_name = {path.name: path for path in fastopenapi_paths} markdown_dir.mkdir(parents=True, exist_ok=True) @@ -260,9 +263,9 @@ def generate_markdown_docs( temp_markdown_dir = Path(temp_dir) for target in SPEC_TARGETS: - swagger_path = swagger_paths_by_name[target.filename] - markdown_path = markdown_dir / f"{swagger_path.stem}.md" - _convert_spec_to_markdown(swagger_path, markdown_path) + openapi_path = openapi_paths_by_name[target.filename] + markdown_path = markdown_dir / f"{openapi_path.stem}.md" + _convert_spec_to_markdown(openapi_path, markdown_path) written_paths.append(markdown_path) for target in FASTOPENAPI_SPEC_TARGETS: # type: ignore @@ -270,7 +273,7 @@ def generate_markdown_docs( markdown_path = temp_markdown_dir / f"{fastopenapi_path.stem}.md" _convert_spec_to_markdown(fastopenapi_path, markdown_path) - console_markdown_path = markdown_dir / f"{Path(CONSOLE_SWAGGER_FILENAME).stem}.md" + console_markdown_path = markdown_dir / f"{Path(CONSOLE_OPENAPI_FILENAME).stem}.md" _append_fastopenapi_markdown(console_markdown_path, markdown_path) (markdown_dir / STALE_COMBINED_MARKDOWN_FILENAME).unlink(missing_ok=True) @@ -286,6 +289,8 @@ def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--swagger-dir", + "--openapi-dir", + dest="openapi_dir", type=Path, default=Path("openapi"), help="Directory where intermediate JSON spec files will be written.", @@ -307,7 +312,7 @@ def parse_args() -> argparse.Namespace: def main() -> int: args = parse_args() written_paths = generate_markdown_docs( - args.swagger_dir, + args.openapi_dir, args.markdown_dir, keep_swagger_json=args.keep_swagger_json, ) diff --git a/api/dev/generate_swagger_specs.py b/api/dev/generate_swagger_specs.py index b7c58d64441..d3b62511ea6 100644 --- a/api/dev/generate_swagger_specs.py +++ b/api/dev/generate_swagger_specs.py @@ -1,9 +1,9 @@ -"""Generate Flask-RESTX Swagger 2.0 specs without booting the full backend. +"""Generate Flask-RESTX OpenAPI 3 specs without booting the full backend. This helper intentionally avoids `app_factory.create_app()`. The normal backend startup eagerly initializes database, Redis, Celery, and storage extensions, which is unnecessary when the goal is only to serialize the Flask-RESTX -`/swagger.json` documents. +`/openapi.json` documents. """ from __future__ import annotations @@ -42,10 +42,10 @@ class RestxApi(Protocol): SPEC_TARGETS: tuple[SpecTarget, ...] = ( - SpecTarget(route="/console/api/swagger.json", filename="console-swagger.json", namespace="console"), - SpecTarget(route="/api/swagger.json", filename="web-swagger.json", namespace="web"), - SpecTarget(route="/v1/swagger.json", filename="service-swagger.json", namespace="service"), - SpecTarget(route="/openapi/v1/swagger.json", filename="openapi-swagger.json", namespace="openapi"), + SpecTarget(route="/console/api/openapi.json", filename="console-openapi.json", namespace="console"), + SpecTarget(route="/api/openapi.json", filename="web-openapi.json", namespace="web"), + SpecTarget(route="/v1/openapi.json", filename="service-openapi.json", namespace="service"), + SpecTarget(route="/openapi/v1/openapi.json", filename="openapi-openapi.json", namespace="openapi"), ) @@ -126,7 +126,7 @@ def _inline_model_signature(nested_fields: dict[object, object]) -> object: def _inline_model_name(nested_fields: dict[object, object]) -> str: - """Return a stable Swagger model name for an anonymous inline field map.""" + """Return a stable OpenAPI model name for an anonymous inline field map.""" signature = json.dumps(_inline_model_signature(nested_fields), sort_keys=True, separators=(",", ":")) digest = hashlib.sha1(signature.encode("utf-8")).hexdigest()[:12] @@ -134,7 +134,7 @@ def _inline_model_name(nested_fields: dict[object, object]) -> str: def apply_runtime_defaults() -> None: - """Force the small config surface required for Swagger generation.""" + """Force the small config surface required for OpenAPI generation.""" os.environ.setdefault("SECRET_KEY", "spec-export") os.environ.setdefault("STORAGE_TYPE", "local") @@ -150,7 +150,7 @@ def apply_runtime_defaults() -> None: def create_spec_app() -> Flask: - """Build a minimal Flask app that only mounts the Swagger-producing blueprints.""" + """Build a minimal Flask app that only mounts the OpenAPI-producing blueprints.""" apply_runtime_defaults() @@ -182,7 +182,7 @@ def create_spec_app() -> Flask: def _registered_models(namespace: str) -> dict[str, object]: - """Return the Flask-RESTX models registered for a Swagger namespace.""" + """Return the Flask-RESTX models registered for an OpenAPI namespace.""" if namespace == "console": from controllers.console import console_ns @@ -213,7 +213,7 @@ def _registered_models(namespace: str) -> dict[str, object]: models.update(api.models) return models - raise ValueError(f"unknown Swagger namespace: {namespace}") + raise ValueError(f"unknown OpenAPI namespace: {namespace}") def _materialize_inline_model_definitions(api: RestxApi) -> None: @@ -289,7 +289,7 @@ def drop_null_values(value: object) -> object: def sort_openapi_arrays(value: object, *, parent_key: str | None = None) -> object: - """Sort order-insensitive Swagger arrays so generated Markdown is stable.""" + """Sort order-insensitive OpenAPI arrays so generated Markdown is stable.""" if isinstance(value, dict): return {key: sort_openapi_arrays(item, parent_key=key) for key, item in value.items()} @@ -313,23 +313,81 @@ def sort_openapi_arrays(value: object, *, parent_key: str | None = None) -> obje return sorted_items -def _merge_registered_definitions(payload: dict[str, object], namespace: str) -> dict[str, object]: - """Include registered but route-indirect models in the exported Swagger definitions.""" +def _replace_legacy_refs(value: object) -> object: + if isinstance(value, dict): + replaced: dict[object, object] = {} + for key, item in value.items(): + if key == "$ref" and isinstance(item, str) and item.startswith("#/definitions/"): + replaced[key] = item.replace("#/definitions/", "#/components/schemas/", 1) + else: + replaced[key] = _replace_legacy_refs(item) + return replaced + if isinstance(value, list): + return [_replace_legacy_refs(item) for item in value] + return value - definitions = payload.setdefault("definitions", {}) - if not isinstance(definitions, dict): - raise RuntimeError("unexpected Swagger definitions payload") + +HTTP_METHODS = {"delete", "get", "head", "options", "patch", "post", "put", "trace"} + + +def _deduplicate_operation_ids(payload: dict[str, object]) -> dict[str, object]: + """Make operationId values unique while preserving already-unique IDs.""" + + paths = payload.get("paths") + if not isinstance(paths, dict): + return payload + + operations_by_id: dict[str, list[tuple[str, str, dict[str, object]]]] = {} + for path, path_item in paths.items(): + if not isinstance(path, str) or not isinstance(path_item, dict): + continue + for method, operation in path_item.items(): + if method not in HTTP_METHODS or not isinstance(operation, dict): + continue + operation_id = operation.get("operationId") + if isinstance(operation_id, str): + operations_by_id.setdefault(operation_id, []).append((method, path, operation)) + + for operation_id, operations in operations_by_id.items(): + if len(operations) < 2: + continue + for method, path, operation in operations: + digest = hashlib.sha1(f"{method}:{path}".encode()).hexdigest()[:8] + operation["operationId"] = f"{operation_id}_{digest}" + + return payload + + +def _component_schemas(payload: dict[str, object]) -> dict[str, object]: + components = payload.setdefault("components", {}) + if not isinstance(components, dict): + raise RuntimeError("unexpected OpenAPI components payload") + + schemas = components.setdefault("schemas", {}) + if not isinstance(schemas, dict): + raise RuntimeError("unexpected OpenAPI component schemas payload") + + return schemas + + +def _merge_registered_schemas(payload: dict[str, object], namespace: str) -> dict[str, object]: + """Include registered but route-indirect models in exported OpenAPI schemas.""" + + schemas = _component_schemas(payload) for name, model in _registered_models(namespace).items(): schema = getattr(model, "__schema__", None) if isinstance(schema, dict): - definitions.setdefault(name, schema) + schemas.setdefault(name, _replace_legacy_refs(schema)) + + payload.pop("definitions", None) + payload = _replace_legacy_refs(payload) # type: ignore[assignment] return payload def generate_specs(output_dir: Path) -> list[Path]: - """Write all Swagger specs to `output_dir` and return the written paths.""" + """Write all OpenAPI specs to `output_dir` and return the written paths.""" output_dir.mkdir(parents=True, exist_ok=True) @@ -345,7 +403,8 @@ def generate_specs(output_dir: Path) -> list[Path]: payload = response.get_json() if not isinstance(payload, dict): raise RuntimeError(f"unexpected response payload for {target.route}") - payload = _merge_registered_definitions(payload, target.namespace) + payload = _merge_registered_schemas(payload, target.namespace) + payload = _deduplicate_operation_ids(payload) payload = drop_null_values(payload) payload = sort_openapi_arrays(payload) @@ -363,7 +422,7 @@ def parse_args() -> argparse.Namespace: "--output-dir", type=Path, default=Path("openapi"), - help="Directory where the Swagger JSON files will be written.", + help="Directory where the OpenAPI JSON files will be written.", ) return parser.parse_args() diff --git a/api/extensions/ext_celery.py b/api/extensions/ext_celery.py index fce065eda9d..42c83b30f2b 100644 --- a/api/extensions/ext_celery.py +++ b/api/extensions/ext_celery.py @@ -30,6 +30,11 @@ class CelerySSLOptionsDict(TypedDict): ssl_keyfile: str | None +class CeleryBeatScheduleEntry(TypedDict): + task: str + schedule: crontab | timedelta + + def get_celery_ssl_options() -> CelerySSLOptionsDict | None: """Get SSL configuration for Celery broker/backend connections.""" # Only apply SSL if we're using Redis as broker/backend @@ -148,11 +153,12 @@ def init_app(app: DifyApp) -> Celery: "tasks.trigger_processing_tasks", # async trigger processing "tasks.generate_summary_index_task", # summary index generation "tasks.regenerate_summary_index_task", # summary index regeneration + "tasks.app_generate.resume_agent_app_task", # ENG-635: Agent v2 chat ask_human resume ] day = dify_config.CELERY_BEAT_SCHEDULER_TIME # if you add a new task, please add the switch to CeleryScheduleTasksConfig - beat_schedule = {} + beat_schedule: dict[str, CeleryBeatScheduleEntry] = {} if dify_config.ENABLE_CLEAN_EMBEDDING_CACHE_TASK: imports.append("schedule.clean_embedding_cache_task") beat_schedule["clean_embedding_cache_task"] = { diff --git a/api/extensions/ext_commands.py b/api/extensions/ext_commands.py index 5d91d79f26a..4d60bdb5f65 100644 --- a/api/extensions/ext_commands.py +++ b/api/extensions/ext_commands.py @@ -5,6 +5,7 @@ def init_app(app: DifyApp): from commands import ( add_qdrant_index, archive_workflow_runs, + backfill_plugin_auto_upgrade, clean_expired_messages, clean_workflow_runs, cleanup_orphaned_draft_variables, @@ -53,6 +54,7 @@ def init_app(app: DifyApp): upgrade_db, fix_app_site_missing, migrate_data_for_plugin, + backfill_plugin_auto_upgrade, extract_plugins, extract_unique_plugins, install_plugins, diff --git a/api/extensions/ext_fastopenapi.py b/api/extensions/ext_fastopenapi.py index 569203e974f..d51f2781fc5 100644 --- a/api/extensions/ext_fastopenapi.py +++ b/api/extensions/ext_fastopenapi.py @@ -26,7 +26,7 @@ def init_app(app: DifyApp) -> None: docs_url=docs_url, redoc_url=redoc_url, openapi_url=openapi_url, - openapi_version="3.0.0", + openapi_version="3.1.0", title="Dify API (FastOpenAPI PoC)", version="1.0", description="FastOpenAPI proof of concept for Dify API", diff --git a/api/extensions/logstore/repositories/logstore_api_workflow_run_repository.py b/api/extensions/logstore/repositories/logstore_api_workflow_run_repository.py index cdc7d129fd4..66028fb85bb 100644 --- a/api/extensions/logstore/repositories/logstore_api_workflow_run_repository.py +++ b/api/extensions/logstore/repositories/logstore_api_workflow_run_repository.py @@ -106,24 +106,26 @@ def _dict_to_workflow_run(data: dict[str, Any]) -> WorkflowRun: # Handle datetime fields started_at = data.get("started_at") or data.get("created_at") if started_at: - if isinstance(started_at, str): - model.created_at = datetime.fromisoformat(started_at) - elif isinstance(started_at, (int, float)): - model.created_at = datetime.fromtimestamp(started_at) - else: - model.created_at = started_at + match started_at: + case str(): + model.created_at = datetime.fromisoformat(started_at) + case int() | float(): + model.created_at = datetime.fromtimestamp(started_at) + case _: + model.created_at = started_at else: # Provide default created_at if missing model.created_at = datetime.now() finished_at = data.get("finished_at") if finished_at: - if isinstance(finished_at, str): - model.finished_at = datetime.fromisoformat(finished_at) - elif isinstance(finished_at, (int, float)): - model.finished_at = datetime.fromtimestamp(finished_at) - else: - model.finished_at = finished_at + match finished_at: + case str(): + model.finished_at = datetime.fromisoformat(finished_at) + case int() | float(): + model.finished_at = datetime.fromtimestamp(finished_at) + case _: + model.finished_at = finished_at # Compute elapsed_time from started_at and finished_at # LogStore doesn't store elapsed_time, it's computed in WorkflowExecution domain entity diff --git a/api/extensions/storage/huawei_obs_storage.py b/api/extensions/storage/huawei_obs_storage.py index 72fdabe4557..c840b170861 100644 --- a/api/extensions/storage/huawei_obs_storage.py +++ b/api/extensions/storage/huawei_obs_storage.py @@ -27,12 +27,14 @@ class HuaweiObsStorage(BaseStorage): @override def load_once(self, filename: str) -> bytes: - data: bytes = self.client.getObject(bucketName=self.bucket_name, objectKey=filename)["body"].response.read() + # TODO: Huawei SDK lacks proper typing + data: bytes = self.client.getObject(bucketName=self.bucket_name, objectKey=filename).body.response.read() # type: ignore return data @override def load_stream(self, filename: str) -> Generator: - response = self.client.getObject(bucketName=self.bucket_name, objectKey=filename)["body"].response + # TODO: Huawei SDK lacks proper typing + response = self.client.getObject(bucketName=self.bucket_name, objectKey=filename).body.response # type: ignore while chunk := response.read(4096): yield chunk diff --git a/api/fields/agent_fields.py b/api/fields/agent_fields.py index d27a5708bf6..724e5ecf7db 100644 --- a/api/fields/agent_fields.py +++ b/api/fields/agent_fields.py @@ -1,8 +1,10 @@ +from datetime import datetime from typing import Annotated, Literal -from pydantic import Field +from pydantic import Field, field_validator from fields.base import ResponseModel +from libs.helper import to_timestamp from models.agent import ( AgentConfigRevisionOperation, AgentIconType, @@ -41,10 +43,24 @@ class AgentConfigSnapshotSummaryResponse(ResponseModel): created_at: int | None = None +class AgentPublishedReferenceResponse(ResponseModel): + app_id: str + app_name: str + app_icon_type: str | None = None + app_icon: str | None = None + app_icon_background: str | None = None + app_mode: str + app_updated_at: int | None = None + workflow_id: str + workflow_version: str + node_ids: list[str] = Field(default_factory=list) + + class AgentRosterResponse(ResponseModel): id: str name: str description: str + role: str = "" icon_type: AgentIconType | None = None icon: str | None = None icon_background: str | None = None @@ -56,6 +72,7 @@ class AgentRosterResponse(ResponseModel): workflow_node_id: str | None = None active_config_snapshot_id: str | None = None active_config_snapshot: AgentConfigSnapshotSummaryResponse | None = None + active_config_is_published: bool = False status: AgentStatus created_by: str | None = None updated_by: str | None = None @@ -63,6 +80,9 @@ class AgentRosterResponse(ResponseModel): archived_at: int | None = None created_at: int | None = None updated_at: int | None = None + published_reference_count: int = 0 + published_node_reference_count: int = 0 + published_references: list[AgentPublishedReferenceResponse] = Field(default_factory=list) class AgentInviteOptionResponse(AgentRosterResponse): @@ -87,6 +107,114 @@ class AgentInviteOptionsResponse(ResponseModel): has_more: bool +class AgentLogItemResponse(ResponseModel): + id: str + message_id: str + conversation_id: str + conversation_name: str | None = None + query: str + answer: str + status: str + error: str | None = None + source: str | None = None + from_source: str | None = None + from_end_user_id: str | None = None + from_account_id: str | None = None + message_tokens: int + answer_tokens: int + total_tokens: int + total_price: str + currency: str + latency: float + created_at: int | 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 AgentLogListResponse(ResponseModel): + data: list[AgentLogItemResponse] + page: int + limit: int + total: int + has_more: bool + + +class AgentStatisticSummaryResponse(ResponseModel): + total_messages: int + total_conversations: int + total_end_users: int + total_tokens: int + total_price: str + currency: str + average_session_interactions: float + average_response_time: float + tokens_per_second: float + user_satisfaction_rate: float + + +class AgentDailyMessageStatisticResponse(ResponseModel): + date: str + message_count: int + + +class AgentDailyConversationStatisticResponse(ResponseModel): + date: str + conversation_count: int + + +class AgentDailyEndUserStatisticResponse(ResponseModel): + date: str + terminal_count: int + + +class AgentTokenUsageStatisticResponse(ResponseModel): + date: str + token_count: int + total_price: str + currency: str + + +class AgentAverageSessionInteractionStatisticResponse(ResponseModel): + date: str + interactions: float + + +class AgentAverageResponseTimeStatisticResponse(ResponseModel): + date: str + latency: float + + +class AgentTokensPerSecondStatisticResponse(ResponseModel): + date: str + tps: float + + +class AgentUserSatisfactionRateStatisticResponse(ResponseModel): + date: str + rate: float + + +class AgentStatisticChartsResponse(ResponseModel): + daily_messages: list[AgentDailyMessageStatisticResponse] = Field(default_factory=list) + daily_conversations: list[AgentDailyConversationStatisticResponse] = Field(default_factory=list) + daily_end_users: list[AgentDailyEndUserStatisticResponse] = Field(default_factory=list) + token_usage: list[AgentTokenUsageStatisticResponse] = Field(default_factory=list) + average_session_interactions: list[AgentAverageSessionInteractionStatisticResponse] = Field(default_factory=list) + average_response_time: list[AgentAverageResponseTimeStatisticResponse] = Field(default_factory=list) + tokens_per_second: list[AgentTokensPerSecondStatisticResponse] = Field(default_factory=list) + user_satisfaction_rate: list[AgentUserSatisfactionRateStatisticResponse] = Field(default_factory=list) + + +class AgentStatisticSummaryEnvelopeResponse(ResponseModel): + source: str + summary: AgentStatisticSummaryResponse + charts: AgentStatisticChartsResponse + + class AgentConfigRevisionResponse(ResponseModel): id: str previous_snapshot_id: str | None = None @@ -197,11 +325,15 @@ class AgentComposerValidateResponse(ResponseModel): class AgentComposerDifyToolCandidateResponse(ResponseModel): id: str | None = None + # "provider" = the whole provider (all of its tools, id "/*"); + # "tool" = one tool (id "/"). See ENG-616. + granularity: str | None = None name: str | None = None description: str | None = None provider: str | None = None provider_id: str | None = None plugin_id: str | None = None + tools_count: int | None = None class AgentComposerSkillCandidateResponse(AgentSkillRefConfig): diff --git a/api/fields/app_fields.py b/api/fields/app_fields.py index d1a8f0c9594..9b197da4433 100644 --- a/api/fields/app_fields.py +++ b/api/fields/app_fields.py @@ -18,6 +18,24 @@ class JsonStringField(fields.Raw): 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, @@ -36,25 +54,25 @@ related_app_list = { 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"), - "speech_to_text": fields.Raw(attribute="speech_to_text_dict"), - "text_to_speech": fields.Raw(attribute="text_to_speech_dict"), - "retriever_resource": fields.Raw(attribute="retriever_resource_dict"), - "annotation_reply": fields.Raw(attribute="annotation_reply_dict"), - "more_like_this": fields.Raw(attribute="more_like_this_dict"), - "sensitive_word_avoidance": fields.Raw(attribute="sensitive_word_avoidance_dict"), - "external_data_tools": fields.Raw(attribute="external_data_tools_list"), - "model": fields.Raw(attribute="model_dict"), - "user_input_form": fields.Raw(attribute="user_input_form_list"), + "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": fields.Raw(attribute="agent_mode_dict"), + "agent_mode": OpaqueRawField(attribute="agent_mode_dict"), "prompt_type": fields.String, - "chat_prompt_config": fields.Raw(attribute="chat_prompt_config_dict"), - "completion_prompt_config": fields.Raw(attribute="completion_prompt_config_dict"), - "dataset_configs": fields.Raw(attribute="dataset_configs_dict"), - "file_upload": fields.Raw(attribute="file_upload_dict"), + "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, @@ -74,7 +92,7 @@ app_detail_fields = { "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": fields.Raw, + "tracing": OpaqueRawField, "use_icon_as_answer_icon": fields.Boolean, "created_by": fields.String, "created_at": TimestampField, @@ -89,7 +107,7 @@ prompt_config_fields = { } model_config_partial_fields = { - "model": fields.Raw(attribute="model_dict"), + "model": OpaqueRawField(attribute="model_dict"), "pre_prompt": fields.String, "created_by": fields.String, "created_at": TimestampField, @@ -100,7 +118,7 @@ model_config_partial_fields = { app_partial_fields = { "id": fields.String, "name": fields.String, - "max_active_requests": fields.Raw(), + "max_active_requests": OpaqueRawField(), "description": fields.String(attribute="desc_or_prompt"), "mode": fields.String(attribute="mode_compatible_with_agent"), "icon_type": fields.String, @@ -222,7 +240,7 @@ app_site_fields = { "use_icon_as_answer_icon": fields.Boolean, } -leaked_dependency_fields = {"type": fields.String, "value": fields.Raw, "current_identifier": fields.String} +leaked_dependency_fields = {"type": fields.String, "value": OpaqueRawField, "current_identifier": fields.String} app_import_fields = { "id": fields.String, diff --git a/api/fields/conversation_fields.py b/api/fields/conversation_fields.py index eb49577d59f..d256ad96cf0 100644 --- a/api/fields/conversation_fields.py +++ b/api/fields/conversation_fields.py @@ -179,15 +179,18 @@ class StatusCount(ResponseModel): class ModelConfig(ResponseModel): opening_statement: str | None = None - suggested_questions: JSONValue | None = None - model: JSONValue | None = None - user_input_form: JSONValue | None = None + suggested_questions: JSONValue | None = Field(default=None) + model: JSONValue | None = Field(default=None) + user_input_form: JSONValue | None = Field(default=None) pre_prompt: str | None = None - agent_mode: JSONValue | None = None + agent_mode: JSONValue | None = Field(default=None) class SimpleModelConfig(ResponseModel): - model: JSONValue | None = Field(default=None, validation_alias="model_dict") + model: JSONValue | None = Field( + default=None, + validation_alias="model_dict", + ) pre_prompt: str | None = None diff --git a/api/fields/message_fields.py b/api/fields/message_fields.py index e0d37dd701f..3f9c5bf0521 100644 --- a/api/fields/message_fields.py +++ b/api/fields/message_fields.py @@ -71,7 +71,10 @@ class MessageListItem(ResponseModel): class WebMessageListItem(MessageListItem): - metadata: JSONValueType | None = Field(default=None, validation_alias="message_metadata_dict") + metadata: JSONValueType | None = Field( + default=None, + validation_alias="message_metadata_dict", + ) class MessageInfiniteScrollPagination(ResponseModel): diff --git a/api/fields/snippet_fields.py b/api/fields/snippet_fields.py index ec0821fc85a..699a3687ac1 100644 --- a/api/fields/snippet_fields.py +++ b/api/fields/snippet_fields.py @@ -1,8 +1,17 @@ +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) @@ -14,7 +23,7 @@ snippet_list_fields = { "version": fields.Integer, "use_count": fields.Integer, "is_published": fields.Boolean, - "icon_info": fields.Raw, + "icon_info": OpaqueRawField, "tags": fields.List(fields.Nested(tag_fields)), "created_by": fields.String, "author_name": fields.String, @@ -32,9 +41,9 @@ snippet_fields = { "version": fields.Integer, "use_count": fields.Integer, "is_published": fields.Boolean, - "icon_info": fields.Raw, - "graph": fields.Raw(attribute="graph_dict"), - "input_fields": fields.Raw(attribute="input_fields_list"), + "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, diff --git a/api/fields/workflow_fields.py b/api/fields/workflow_fields.py index 49b7a8be254..2d0d8f9f546 100644 --- a/api/fields/workflow_fields.py +++ b/api/fields/workflow_fields.py @@ -12,7 +12,33 @@ 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 @@ -48,7 +74,7 @@ conversation_variable_fields = { "id": fields.String, "name": fields.String, "value_type": fields.String(attribute=serialize_value_type), - "value": fields.Raw, + "value": JsonValueRawField, "description": fields.String, } @@ -60,7 +86,7 @@ pipeline_variable_fields = { "max_length": fields.Integer, "required": fields.Boolean, "unit": fields.String, - "default_value": fields.Raw, + "default_value": JsonValueRawField, "options": fields.List(fields.String), "placeholder": fields.String, "tooltips": fields.String, @@ -71,8 +97,8 @@ pipeline_variable_fields = { workflow_fields = { "id": fields.String, - "graph": fields.Raw(attribute="graph_dict"), - "features": fields.Raw(attribute="features_dict"), + "graph": OpaqueRawField(attribute="graph_dict"), + "features": OpaqueRawField(attribute="features_dict"), "hash": fields.String(attribute="unique_hash"), "version": fields.String, "marked_name": fields.String, diff --git a/api/gunicorn.conf.py b/api/gunicorn.conf.py index da75d25ba67..140b70c67d0 100644 --- a/api/gunicorn.conf.py +++ b/api/gunicorn.conf.py @@ -1,6 +1,6 @@ -import psycogreen.gevent as pscycogreen_gevent # type: ignore +import psycogreen.gevent as pscycogreen_gevent from gevent import events as gevent_events -from grpc.experimental import gevent as grpc_gevent # type: ignore +from grpc.experimental import gevent as grpc_gevent # WARNING: This module is loaded very early in the Gunicorn worker lifecycle, # before gevent's monkey-patching is applied. Importing modules at the top level here can diff --git a/api/libs/external_api.py b/api/libs/external_api.py index dd2d7347846..43f7c409f5b 100644 --- a/api/libs/external_api.py +++ b/api/libs/external_api.py @@ -1,8 +1,8 @@ import re from collections.abc import Mapping -from typing import Any +from typing import Any, Protocol, override -from flask import Blueprint, Flask, current_app, got_request_exception +from flask import Blueprint, Flask, current_app, got_request_exception, request from flask_restx import Api from werkzeug.exceptions import HTTPException from werkzeug.http import HTTP_STATUS_CODES @@ -17,11 +17,24 @@ def http_status_message(code): return HTTP_STATUS_CODES.get(code, "") -def register_external_error_handlers(api: Api): +class ErrorBodyFormatter(Protocol): + """Last-touch hook over an error body before it goes on the wire.""" + + def finalize(self, e: Exception, data: dict[str, Any], status_code: int) -> dict[str, Any]: ... + + +def register_external_error_handlers(api: Api, body_formatter: ErrorBodyFormatter | None = None): + def _finalize(e: Exception, data: dict[str, Any], status_code: int) -> dict[str, Any]: + if body_formatter is None: + return data + return body_formatter.finalize(e, data, status_code) + def handle_http_exception(e: HTTPException): got_request_exception.send(current_app, exception=e) - # If Werkzeug already prepared a Response, just use it. + # If Werkzeug already prepared a Response, just use it. This bypasses + # body_formatter entirely — surfaces with a formatter must not raise + # exceptions carrying a pre-built response. if e.response is not None: return e.response @@ -45,7 +58,7 @@ def register_external_error_handlers(api: Api): # Payload per status if status_code == 406 and api.default_mediatype is None: data = {"code": "not_acceptable", "message": default_data["message"], "status": status_code} - return data, status_code, headers + return _finalize(e, data, status_code), status_code, headers elif status_code == 400: msg = default_data["message"] if isinstance(msg, Mapping) and msg: @@ -60,7 +73,7 @@ def register_external_error_handlers(api: Api): else: data = {**default_data} data.setdefault("code", "unknown") - return data, status_code, headers + return _finalize(e, data, status_code), status_code, headers else: data = {**default_data} data.setdefault("code", "unknown") @@ -72,20 +85,20 @@ def register_external_error_handlers(api: Api): if error_code == "unauthorized_and_force_logout": # Add Set-Cookie headers to clear auth cookies headers["Set-Cookie"] = build_force_logout_cookie_headers() - return data, status_code, headers + return _finalize(e, data, status_code), status_code, headers def handle_value_error(e: ValueError): got_request_exception.send(current_app, exception=e) current_app.logger.exception("value_error in request handler") status_code = 400 data = {"code": "invalid_param", "message": str(e), "status": status_code} - return data, status_code + return _finalize(e, data, status_code), status_code def handle_quota_exceeded(e: AppInvokeQuotaExceededError): got_request_exception.send(current_app, exception=e) status_code = 429 data = {"code": "too_many_requests", "message": str(e), "status": status_code} - return data, status_code + return _finalize(e, data, status_code), status_code def handle_general_exception(e: Exception): got_request_exception.send(current_app, exception=e) @@ -103,7 +116,7 @@ def register_external_error_handlers(api: Api): # Note: Exception logging is handled by Flask/Flask-RESTX framework automatically # Explicit log_exception call removed to avoid duplicate log entries - return data, status_code + return _finalize(e, data, status_code), status_code api.errorhandler(HTTPException)(handle_http_exception) api.errorhandler(ValueError)(handle_value_error) @@ -121,14 +134,46 @@ class ExternalApi(Api): } } - def __init__(self, app: Blueprint | Flask, *args, **kwargs): + def __init__(self, app: Blueprint | Flask, *args, error_body_formatter: ErrorBodyFormatter | None = None, **kwargs): + self._error_body_formatter = error_body_formatter patch_swagger_for_inline_nested_dicts() kwargs.setdefault("authorizations", self._authorizations) kwargs.setdefault("security", "Bearer") kwargs["add_specs"] = dify_config.SWAGGER_UI_ENABLED kwargs["doc"] = dify_config.SWAGGER_UI_PATH if dify_config.SWAGGER_UI_ENABLED else False + if error_body_formatter is not None: + kwargs.setdefault("catch_all_404s", True) + # the overrides below patch private flask-restx methods; fail at + # startup (not at the first 404) if an upgrade removes them + for private_hook in ("_should_use_fr_error_handler", "_help_on_404"): + if not callable(getattr(Api, private_hook, None)): + raise RuntimeError(f"flask-restx no longer exposes {private_hook}; update ExternalApi overrides") # manual separate call on construction and init_app to ensure configs in kwargs effective super().__init__(app=None, *args, **kwargs) self.init_app(app, **kwargs) - register_external_error_handlers(self) + register_external_error_handlers(self, body_formatter=error_body_formatter) + + @override + def _should_use_fr_error_handler(self): + # catch_all_404s makes flask-restx claim NotFound for ANY app path + # (it wraps the app-level handle_exception), so scope the claim to + # this blueprint's url prefix; other surfaces keep their own 404s. + if self._error_body_formatter is not None and not self._request_under_own_prefix(): + return False + return super()._should_use_fr_error_handler() + + def _request_under_own_prefix(self) -> bool: + prefix = self.blueprint.url_prefix if self.blueprint is not None else None + if not prefix: + return True + return request.path == prefix or request.path.startswith(prefix.rstrip("/") + "/") + + @override + def _help_on_404(self, message: str | None = None) -> str | None: + # flask-restx appends route suggestions post-handler; with a canonical + # formatter installed, that would corrupt the contract and enumerate + # routes to unauthenticated callers. + if self._error_body_formatter is not None: + return message + return super()._help_on_404(message) diff --git a/api/libs/flask_restx_compat.py b/api/libs/flask_restx_compat.py index 34e0d586a07..08fd3d9055d 100644 --- a/api/libs/flask_restx_compat.py +++ b/api/libs/flask_restx_compat.py @@ -1,8 +1,8 @@ -"""Compatibility helpers for Dify's Flask-RESTX Swagger integration. +"""Compatibility helpers for Dify's Flask-RESTX OpenAPI integration. These helpers are temporary bridges for legacy Flask-RESTX field contracts while controllers migrate their request and response documentation to Pydantic -models. Keep the behavior centralized so live Swagger endpoints and offline +models. Keep the behavior centralized so live OpenAPI endpoints and offline spec export fail or succeed in the same way. """ @@ -91,7 +91,7 @@ def _inline_model_signature(nested_fields: dict[object, object]) -> object: def _inline_model_name(nested_fields: dict[object, object]) -> str: - """Return a stable Swagger model name for an anonymous inline field map.""" + """Return a stable OpenAPI model name for an anonymous inline field map.""" signature = json.dumps(_inline_model_signature(nested_fields), sort_keys=True, separators=(",", ":")) digest = hashlib.sha1(signature.encode("utf-8")).hexdigest()[:12] @@ -99,11 +99,11 @@ def _inline_model_name(nested_fields: dict[object, object]) -> str: def patch_swagger_for_inline_nested_dicts() -> None: - """Allow Swagger generation to handle legacy inline Flask-RESTX field dicts. + """Allow OpenAPI generation to handle legacy inline Flask-RESTX field dicts. Some existing controllers use raw field mappings in `fields.Nested({...})` or directly in `@namespace.response(...)`. Runtime marshalling accepts that, - but Flask-RESTX Swagger registration expects a named model. Convert those + but Flask-RESTX registration expects a named model. Convert those anonymous mappings into temporary named models during docs generation. """ diff --git a/api/libs/gmpy2_pkcs10aep_cipher.py b/api/libs/gmpy2_pkcs10aep_cipher.py index ef26699fb32..ef1cc56e0e6 100644 --- a/api/libs/gmpy2_pkcs10aep_cipher.py +++ b/api/libs/gmpy2_pkcs10aep_cipher.py @@ -20,6 +20,7 @@ # =================================================================== from hashlib import sha1 +from typing import TYPE_CHECKING, cast import Crypto.Hash.SHA1 import Crypto.Util.number @@ -30,6 +31,9 @@ from Crypto.Util.number import bytes_to_long, ceil_div, long_to_bytes from Crypto.Util.py3compat import bord from Crypto.Util.strxor import strxor +if TYPE_CHECKING: + from Crypto.Signature.pss import HashModule + class PKCS1OAepCipher: """Cipher object for PKCS#1 v1.5 OAEP. @@ -70,7 +74,7 @@ class PKCS1OAepCipher: if mgfunc: self._mgf = mgfunc else: - self._mgf = lambda x, y: MGF1(x, y, self._hashObj) + self._mgf = lambda x, y: MGF1(x, y, cast("HashModule", self._hashObj)) self._label = bytes(label) self._randfunc = randfunc diff --git a/api/libs/helper.py b/api/libs/helper.py index 3f27eac516d..ac85e88ef7f 100644 --- a/api/libs/helper.py +++ b/api/libs/helper.py @@ -128,6 +128,10 @@ def run(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: @@ -177,12 +181,20 @@ class AvatarUrlField(fields.Raw): 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: diff --git a/api/libs/login.py b/api/libs/login.py index 12d0f53f2d6..bbb8ba1611c 100644 --- a/api/libs/login.py +++ b/api/libs/login.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any, Concatenate, cast, overload from flask import Response, current_app, g, has_request_context, request from flask_login.config import EXEMPT_METHODS +from werkzeug.exceptions import Unauthorized from werkzeug.local import LocalProxy from configs import dify_config @@ -48,6 +49,53 @@ def current_account_with_tenant() -> tuple[Account, str]: return user, user.current_tenant_id +def current_account_with_tenant_optional() -> tuple[Account | None, str | None]: + try: + user = _resolve_current_user() + except Unauthorized: + return None, None + + if not isinstance(user, Account): + return None, None + if not bool(getattr(user, "is_authenticated", False)): + return None, None + return user, user.current_tenant_id + + +def resolve_account_fallback( + current_user: Account | None = None, + current_tenant_id: str | None = None, + *, + fallback_tenant_id: str | None = None, +) -> tuple[Account, str]: + """ + If the provided current user and tenant ID is None, fallback to current_account_with_tenant. + This is useful for those service layers whose controllers are not migrated to use DI for + resolving current user yet. + + TODO: this should be removed after all ctrls (especially service API) are migrated + """ + if current_user is not None: + tenant_id = current_tenant_id or fallback_tenant_id + if tenant_id is None: + raise ValueError("current_tenant_id is required when current_user is provided.") + return current_user, tenant_id + return current_account_with_tenant() + + +def resolve_tenant_id_fallback(current_tenant_id: str | None = None) -> str: + """ + If the provided tenant ID is None, fallback to the tenant resolved from current_account_with_tenant. + This is useful for tenant-only service paths whose controllers are not all migrated to tenant injection yet. + + TODO: this should be removed after all ctrls (especially service API) are migrated + """ + if current_tenant_id is not None: + return current_tenant_id + _, tenant_id = current_account_with_tenant() + return tenant_id + + @overload def login_required[T, **P, R]( func: Callable[Concatenate[T, P], R], diff --git a/api/libs/rate_limit.py b/api/libs/rate_limit.py index 68147f21cfe..13b27eae9f7 100644 --- a/api/libs/rate_limit.py +++ b/api/libs/rate_limit.py @@ -15,7 +15,7 @@ from enum import StrEnum from functools import wraps from typing import ParamSpec, TypeVar -from flask import jsonify, make_response, request, session +from flask import request, session from werkzeug.exceptions import TooManyRequests from configs import dify_config @@ -132,20 +132,32 @@ def enforce(spec: RateLimit, *, key: str) -> None: limiter.increment_rate_limit(key) +class _BearerRateLimited(TooManyRequests): + """Per-token 429. Carries Retry-After as a plain ``headers`` attribute because the openapi + error formatter reads ``getattr(e, "headers")`` (not werkzeug's ``get_headers()``). No + pre-built response, so the formatter still renders the canonical ErrorBody ("too_many_requests"). + """ + + headers: dict[str, str] + + def __init__(self, retry_after_seconds: int) -> None: + super().__init__() + self.headers = {"Retry-After": str(retry_after_seconds)} + + def enforce_bearer_rate_limit(token_hash: str) -> None: """Per-token rate limit on /openapi/v1/* bearer-authed routes. Bucket key = ``token:`` so the same token shares one bucket across api replicas (Redis-backed sliding window). """ + # 0 (or less) disables the per-token limit. Short-circuit here: a limiter built with + # max_attempts=0 would otherwise treat every request as already over the limit. + if LIMIT_BEARER_PER_TOKEN.limit <= 0: + return limiter = _build_limiter(LIMIT_BEARER_PER_TOKEN) key = f"token:{token_hash}" if limiter.is_rate_limited(key): retry_after = limiter.seconds_until_available(key) - response = make_response( - jsonify({"error": "rate_limited", "retry_after_ms": retry_after * 1000}), - 429, - ) - response.headers["Retry-After"] = str(retry_after) - raise TooManyRequests(response=response) + raise _BearerRateLimited(retry_after) limiter.increment_rate_limit(key) diff --git a/api/migrations/versions/2026_06_12_1100-0b2f2c8a9d1e_add_agent_role.py b/api/migrations/versions/2026_06_12_1100-0b2f2c8a9d1e_add_agent_role.py new file mode 100644 index 00000000000..900f7da06fc --- /dev/null +++ b/api/migrations/versions/2026_06_12_1100-0b2f2c8a9d1e_add_agent_role.py @@ -0,0 +1,27 @@ +"""add agent role + +Revision ID: 0b2f2c8a9d1e +Revises: 7bad07dc267d +Create Date: 2026-06-12 11:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "0b2f2c8a9d1e" +down_revision = "7bad07dc267d" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("agents", schema=None) as batch_op: + batch_op.add_column(sa.Column("role", sa.String(length=255), nullable=False, server_default="")) + batch_op.alter_column("role", server_default=None) + + +def downgrade(): + with op.batch_alter_table("agents", schema=None) as batch_op: + batch_op.drop_column("role") diff --git a/api/migrations/versions/2026_06_12_1600-9f4b7c2d1a80_add_agent_active_config_has_model.py b/api/migrations/versions/2026_06_12_1600-9f4b7c2d1a80_add_agent_active_config_has_model.py new file mode 100644 index 00000000000..6fbce0591e8 --- /dev/null +++ b/api/migrations/versions/2026_06_12_1600-9f4b7c2d1a80_add_agent_active_config_has_model.py @@ -0,0 +1,40 @@ +"""add agent active config has model + +Revision ID: 9f4b7c2d1a80 +Revises: 0b2f2c8a9d1e +Create Date: 2026-06-12 16:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "9f4b7c2d1a80" +down_revision = "0b2f2c8a9d1e" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("agents", schema=None) as batch_op: + batch_op.add_column( + sa.Column( + "active_config_has_model", + sa.Boolean(), + server_default=sa.text("false"), + nullable=False, + ) + ) + + op.create_index( + "agent_tenant_invitable_idx", + "agents", + ["tenant_id", "scope", "status", "active_config_has_model", "updated_at"], + ) + + +def downgrade(): + op.drop_index("agent_tenant_invitable_idx", table_name="agents") + with op.batch_alter_table("agents", schema=None) as batch_op: + batch_op.drop_column("active_config_has_model") diff --git a/api/migrations/versions/2026_06_15_1052-c167a72a00eb_add_ask_human_pause_correlation_to_.py b/api/migrations/versions/2026_06_15_1052-c167a72a00eb_add_ask_human_pause_correlation_to_.py new file mode 100644 index 00000000000..6bf0bae4bb5 --- /dev/null +++ b/api/migrations/versions/2026_06_15_1052-c167a72a00eb_add_ask_human_pause_correlation_to_.py @@ -0,0 +1,32 @@ +"""add ask_human pause correlation to agent_runtime_sessions + +Revision ID: c167a72a00eb +Revises: c4d5e6f7a8b9 +Create Date: 2026-06-15 10:52:15.736666 + +""" +from alembic import op +import models as models +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = 'c167a72a00eb' +down_revision = 'c4d5e6f7a8b9' +branch_labels = None +depends_on = None + + +def upgrade(): + # ENG-637: correlate a paused dify.ask_human session to its awaiting HITL + # form and the deferred tool_call id, so the resumed Agent node can rebuild + # deferred_tool_results from the submitted form. Both columns are nullable + # and NULL whenever the session is not paused on human input. + with op.batch_alter_table('agent_runtime_sessions', schema=None) as batch_op: + batch_op.add_column(sa.Column('pending_form_id', models.types.StringUUID(), nullable=True)) + batch_op.add_column(sa.Column('pending_tool_call_id', sa.String(length=255), nullable=True)) + + +def downgrade(): + with op.batch_alter_table('agent_runtime_sessions', schema=None) as batch_op: + batch_op.drop_column('pending_tool_call_id') + batch_op.drop_column('pending_form_id') diff --git a/api/migrations/versions/2026_06_15_1100-b7c2d9e8a1f4_add_tenant_last_opened_at.py b/api/migrations/versions/2026_06_15_1100-b7c2d9e8a1f4_add_tenant_last_opened_at.py new file mode 100644 index 00000000000..ce2fd2b79ca --- /dev/null +++ b/api/migrations/versions/2026_06_15_1100-b7c2d9e8a1f4_add_tenant_last_opened_at.py @@ -0,0 +1,26 @@ +"""add tenant account join last opened at + +Revision ID: b7c2d9e8a1f4 +Revises: 9f4b7c2d1a80 +Create Date: 2026-06-05 11:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "b7c2d9e8a1f4" +down_revision = "9f4b7c2d1a80" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("tenant_account_joins", schema=None) as batch_op: + batch_op.add_column(sa.Column("last_opened_at", sa.DateTime(), nullable=True)) + + +def downgrade(): + with op.batch_alter_table("tenant_account_joins", schema=None) as batch_op: + batch_op.drop_column("last_opened_at") diff --git a/api/migrations/versions/2026_06_15_1110-d2f1a4b8c3e0_add_conversation_id_to_human_input_forms.py b/api/migrations/versions/2026_06_15_1110-d2f1a4b8c3e0_add_conversation_id_to_human_input_forms.py new file mode 100644 index 00000000000..3af00e3926f --- /dev/null +++ b/api/migrations/versions/2026_06_15_1110-d2f1a4b8c3e0_add_conversation_id_to_human_input_forms.py @@ -0,0 +1,29 @@ +"""add conversation_id to human_input_forms for agent v2 chat hitl + +Revision ID: d2f1a4b8c3e0 +Revises: c167a72a00eb +Create Date: 2026-06-15 11:10:00.000000 + +""" +from alembic import op +import models as models +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = 'd2f1a4b8c3e0' +down_revision = 'c167a72a00eb' +branch_labels = None +depends_on = None + + +def upgrade(): + # ENG-635: Agent v2 chat ask_human forms are owned by a conversation turn + # instead of a workflow run (the new Agent App has no workflow_run_id). + # Nullable; existing workflow-owned forms keep conversation_id NULL. + with op.batch_alter_table('human_input_forms', schema=None) as batch_op: + batch_op.add_column(sa.Column('conversation_id', models.types.StringUUID(), nullable=True)) + + +def downgrade(): + with op.batch_alter_table('human_input_forms', schema=None) as batch_op: + batch_op.drop_column('conversation_id') diff --git a/api/migrations/versions/2026_06_15_1200-f6a7b8c9d012_add_plugin_auto_upgrade_category.py b/api/migrations/versions/2026_06_15_1200-f6a7b8c9d012_add_plugin_auto_upgrade_category.py new file mode 100644 index 00000000000..491d8c201fa --- /dev/null +++ b/api/migrations/versions/2026_06_15_1200-f6a7b8c9d012_add_plugin_auto_upgrade_category.py @@ -0,0 +1,42 @@ +"""add plugin auto upgrade category + +Revision ID: f6a7b8c9d012 +Revises: b7c2d9e8a1f4 +Create Date: 2026-05-15 12:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "f6a7b8c9d012" +down_revision = "b7c2d9e8a1f4" +branch_labels = None +depends_on = None + + +LEGACY_CATEGORY = "tool" +UNIQUE_CONSTRAINT_NAME = "unique_tenant_plugin_auto_upgrade_strategy" +UPGRADE_TIME_INDEX_NAME = "idx_tenant_plugin_auto_upgrade_strategy_time" +STRATEGY_TABLE_NAME = "tenant_plugin_auto_upgrade_strategies" + + +def upgrade(): + with op.batch_alter_table(STRATEGY_TABLE_NAME, schema=None) as batch_op: + batch_op.add_column( + sa.Column("category", sa.String(length=32), server_default=LEGACY_CATEGORY, nullable=False) + ) + batch_op.drop_constraint(UNIQUE_CONSTRAINT_NAME, type_="unique") + batch_op.create_unique_constraint(UNIQUE_CONSTRAINT_NAME, ["tenant_id", "category"]) + batch_op.create_index(UPGRADE_TIME_INDEX_NAME, ["upgrade_time_of_day"]) + + +def downgrade(): + op.execute(sa.text(f"DELETE FROM {STRATEGY_TABLE_NAME} WHERE category != '{LEGACY_CATEGORY}'")) + + with op.batch_alter_table(STRATEGY_TABLE_NAME, schema=None) as batch_op: + batch_op.drop_index(UPGRADE_TIME_INDEX_NAME) + batch_op.drop_constraint(UNIQUE_CONSTRAINT_NAME, type_="unique") + batch_op.drop_column("category") + batch_op.create_unique_constraint(UNIQUE_CONSTRAINT_NAME, ["tenant_id"]) diff --git a/api/migrations/versions/2026_06_15_1300-f5e8a9c0d2b3_add_learn_dify_flag_to_recommended_apps.py b/api/migrations/versions/2026_06_15_1300-f5e8a9c0d2b3_add_learn_dify_flag_to_recommended_apps.py new file mode 100644 index 00000000000..f0af2ddcaab --- /dev/null +++ b/api/migrations/versions/2026_06_15_1300-f5e8a9c0d2b3_add_learn_dify_flag_to_recommended_apps.py @@ -0,0 +1,26 @@ +"""add learn dify flag to recommended apps + +Revision ID: f5e8a9c0d2b3 +Revises: f6a7b8c9d012 +Create Date: 2026-05-18 15:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "f5e8a9c0d2b3" +down_revision = "f6a7b8c9d012" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("recommended_apps", schema=None) as batch_op: + batch_op.add_column(sa.Column("is_learn_dify", sa.Boolean(), server_default=sa.text("false"), nullable=False)) + + +def downgrade(): + with op.batch_alter_table("recommended_apps", schema=None) as batch_op: + batch_op.drop_column("is_learn_dify") diff --git a/api/migrations/versions/2026_06_15_1400-c4d5e6f7a8b9_add_app_stars.py b/api/migrations/versions/2026_06_15_1400-c4d5e6f7a8b9_add_app_stars.py new file mode 100644 index 00000000000..780d94be9ab --- /dev/null +++ b/api/migrations/versions/2026_06_15_1400-c4d5e6f7a8b9_add_app_stars.py @@ -0,0 +1,38 @@ +"""add app stars + +Revision ID: c4d5e6f7a8b9 +Revises: f5e8a9c0d2b3 +Create Date: 2026-06-08 12:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +import models.types + +# revision identifiers, used by Alembic. +revision = "c4d5e6f7a8b9" +down_revision = "f5e8a9c0d2b3" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "app_stars", + sa.Column("id", models.types.StringUUID(), nullable=False), + sa.Column("tenant_id", models.types.StringUUID(), nullable=False), + sa.Column("app_id", models.types.StringUUID(), nullable=False), + sa.Column("account_id", models.types.StringUUID(), nullable=False), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.PrimaryKeyConstraint("id", name="app_star_pkey"), + sa.UniqueConstraint("tenant_id", "account_id", "app_id", name="app_star_tenant_account_app_unique"), + ) + with op.batch_alter_table("app_stars", schema=None) as batch_op: + batch_op.create_index("app_star_tenant_account_idx", ["tenant_id", "account_id"], unique=False) + batch_op.create_index("app_star_app_idx", ["app_id"], unique=False) + + +def downgrade() -> None: + op.drop_table("app_stars") diff --git a/api/models/__init__.py b/api/models/__init__.py index 55bc642566b..78ca43fa374 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -73,6 +73,7 @@ from .model import ( AppMCPServer, AppMode, AppModelConfig, + AppStar, Conversation, DatasetRetrieverResource, DifySetup, @@ -175,6 +176,7 @@ __all__ = [ "AppMCPServer", "AppMode", "AppModelConfig", + "AppStar", "AppTrigger", "AppTriggerStatus", "AppTriggerType", diff --git a/api/models/account.py b/api/models/account.py index a3074c6f637..66766693a5b 100644 --- a/api/models/account.py +++ b/api/models/account.py @@ -301,6 +301,7 @@ class TenantAccountJoin(TypeBase): updated_at: Mapped[datetime] = mapped_column( DateTime, server_default=func.current_timestamp(), nullable=False, init=False, onupdate=func.current_timestamp() ) + last_opened_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None) class AccountIntegrate(TypeBase): @@ -389,6 +390,14 @@ class TenantPluginPermission(TypeBase): class TenantPluginAutoUpgradeStrategy(TypeBase): + class PluginCategory(enum.StrEnum): + TOOL = "tool" + MODEL = "model" + EXTENSION = "extension" + AGENT_STRATEGY = "agent-strategy" + DATASOURCE = "datasource" + TRIGGER = "trigger" + class StrategySetting(enum.StrEnum): DISABLED = "disabled" FIX_ONLY = "fix_only" @@ -402,13 +411,20 @@ class TenantPluginAutoUpgradeStrategy(TypeBase): __tablename__ = "tenant_plugin_auto_upgrade_strategies" __table_args__ = ( sa.PrimaryKeyConstraint("id", name="tenant_plugin_auto_upgrade_strategy_pkey"), - sa.UniqueConstraint("tenant_id", name="unique_tenant_plugin_auto_upgrade_strategy"), + sa.UniqueConstraint("tenant_id", "category", name="unique_tenant_plugin_auto_upgrade_strategy"), + sa.Index("idx_tenant_plugin_auto_upgrade_strategy_time", "upgrade_time_of_day"), ) id: Mapped[str] = mapped_column( StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()), init=False ) tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + category: Mapped[PluginCategory] = mapped_column( + EnumText(PluginCategory, length=32), + nullable=False, + server_default="tool", + default=PluginCategory.TOOL, + ) strategy_setting: Mapped[StrategySetting] = mapped_column( EnumText(StrategySetting, length=16), nullable=False, diff --git a/api/models/agent.py b/api/models/agent.py index 8062ca6fe0a..649835f5220 100644 --- a/api/models/agent.py +++ b/api/models/agent.py @@ -38,6 +38,8 @@ class AgentScope(StrEnum): class AgentSource(StrEnum): """Origin that created or imported the Agent.""" + # Created directly as a reusable Agent Roster asset. + ROSTER = "roster" # Created from an Agent App composer. AGENT_APP = "agent_app" # Created from a Workflow Agent Composer flow. @@ -131,11 +133,20 @@ class Agent(DefaultFieldsMixin, Base): Index("agent_tenant_workflow_id_idx", "tenant_id", "workflow_id"), Index("agent_tenant_app_id_idx", "tenant_id", "app_id"), Index("agent_active_config_snapshot_id_idx", "active_config_snapshot_id"), + Index( + "agent_tenant_invitable_idx", + "tenant_id", + "scope", + "status", + "active_config_has_model", + "updated_at", + ), ) tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str] = mapped_column(LongText, nullable=False, default="") + role: Mapped[str] = mapped_column(String(255), nullable=False, default="") icon_type: Mapped[AgentIconType | None] = mapped_column(EnumText(AgentIconType, length=32), nullable=True) icon: Mapped[str | None] = mapped_column( String(255), @@ -152,6 +163,9 @@ class Agent(DefaultFieldsMixin, Base): workflow_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) workflow_node_id: Mapped[str | None] = mapped_column(String(255), nullable=True) active_config_snapshot_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + active_config_has_model: Mapped[bool] = mapped_column( + sa.Boolean, nullable=False, default=False, server_default=sa.text("false") + ) status: Mapped[AgentStatus] = mapped_column( EnumText(AgentStatus, length=32), nullable=False, default=AgentStatus.ACTIVE ) @@ -372,10 +386,9 @@ class AgentRuntimeSession(DefaultFieldsMixin, Base): node_execution_id: Mapped[str | None] = mapped_column(String(255), nullable=True) binding_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) agent_config_snapshot_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) - # JSON-encoded list of cleanup layer specs ({name, type, deps, config}). - # Drives Agent backend cleanup-only runs: the agenton compositor rejects a - # session snapshot whose layer names do not match the cleanup composition, - # so we replay the same layer graph (minus credential-bearing plugin layers). + # JSON-encoded list of non-sensitive runtime layer specs ({name, type, deps, + # config}). The persisted schema keeps its original name because the sandbox + # refactor intentionally avoids a storage migration. composition_layer_specs: Mapped[str] = mapped_column(LongText, nullable=False, server_default="[]") # Conversation-owner column (NULL for workflow owner). conversation_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) @@ -385,6 +398,12 @@ class AgentRuntimeSession(DefaultFieldsMixin, Base): default=AgentRuntimeSessionStatus.ACTIVE, ) cleaned_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + # ENG-637: when a run pauses for a dify.ask_human deferred call, these link + # the session to the awaiting HITL form and the deferred tool_call_id, so a + # resumed node can map the submitted form back into deferred_tool_results. + # Both NULL whenever the session is not paused on human input. + pending_form_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + pending_tool_call_id: Mapped[str | None] = mapped_column(String(255), nullable=True) # Back-compat alias for the shipped workflow lifecycle code (PR #36724). diff --git a/api/models/agent_config_entities.py b/api/models/agent_config_entities.py index 095108bbc7d..76108f271d4 100644 --- a/api/models/agent_config_entities.py +++ b/api/models/agent_config_entities.py @@ -2,9 +2,9 @@ from __future__ import annotations import re from enum import StrEnum -from typing import Any, Final, Literal +from typing import Annotated, Any, Final, Literal -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, WithJsonSchema, field_validator, model_validator from core.workflow.file_reference import is_canonical_file_reference from graphon.file import FileTransferMethod @@ -29,6 +29,44 @@ class DeclaredOutputType(StrEnum): FILE = "file" +_DECLARED_OUTPUT_CHILDREN_JSON_SCHEMA = { + "type": "array", + "items": { + "type": "object", + "additionalProperties": False, + "properties": { + "name": {"type": "string"}, + "type": { + "type": "string", + "enum": [item.value for item in DeclaredOutputType], + }, + "description": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "required": {"type": "boolean"}, + "file": {"type": "object", "additionalProperties": True}, + "array_item": { + "type": "object", + "additionalProperties": True, + "properties": { + "type": { + "type": "string", + "enum": [item.value for item in DeclaredOutputType], + }, + "description": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "children": {"type": "array", "items": {"type": "object", "additionalProperties": True}}, + }, + }, + "children": {"type": "array", "items": {"type": "object", "additionalProperties": True}}, + }, + "required": ["name", "type"], + }, +} + +DeclaredOutputChildren = Annotated[ + list["DeclaredOutputChildConfig"], + WithJsonSchema(_DECLARED_OUTPUT_CHILDREN_JSON_SCHEMA), +] + + class AgentCliToolAuthorizationStatus(StrEnum): """Authorization state for Agent-scoped CLI tools. @@ -76,7 +114,7 @@ RuntimeParameterValue = JsonPrimitive | list[str] | list[int] | list[float] | li class AgentFlexibleConfig(BaseModel): - model_config = ConfigDict(extra="allow", json_schema_extra={"x-dify-opaque": True}) + model_config = ConfigDict(extra="allow") def get(self, key: str, default: Any = None) -> Any: return self.model_dump(mode="python").get(key, default) @@ -99,6 +137,10 @@ class AgentFileRefConfig(AgentFlexibleConfig): transfer_method: str | None = Field(default=None, max_length=64) url: str | None = None remote_url: str | None = None + # Drive key once the file is committed to the agent drive ("files/", + # ENG-625). Files without it are plain upload references and stay invisible + # to the runtime drive manifest. + drive_key: str | None = Field(default=None, max_length=512) class AgentSkillRefConfig(AgentFlexibleConfig): @@ -107,6 +149,16 @@ class AgentSkillRefConfig(AgentFlexibleConfig): description: str | None = None file_id: str | None = Field(default=None, max_length=255) path: str | None = None + # Standardization outputs (ENG-594) — previously riding along via + # ``extra="allow"``, promoted to the explicit schema because the runtime + # drive manifest (ENG-623) keys off them. + skill_md_key: str | None = Field(default=None, max_length=512) + skill_md_file_id: str | None = Field(default=None, max_length=255) + full_archive_key: str | None = Field(default=None, max_length=512) + full_archive_file_id: str | None = Field(default=None, max_length=255) + # Zip member path listing from standardization (ENG-371): lets infer-tools + # show the model strong signals like ``scripts/*.sh`` without unpacking. + manifest_files: list[str] | None = None class AgentPermissionConfig(BaseModel): @@ -117,7 +169,44 @@ class AgentPermissionConfig(BaseModel): state: str | None = Field(default=None, max_length=64) +class AgentEnvVariableConfig(AgentFlexibleConfig): + name: str | None = Field(default=None, max_length=255) + key: str | None = Field(default=None, max_length=255) + env_name: str | None = Field(default=None, max_length=255) + variable: str | None = Field(default=None, max_length=255) + type: str | None = Field(default=None, max_length=64) + value: RuntimeParameterValue = None + default: RuntimeParameterValue = None + required: bool = False + + +class AgentSecretRefConfig(AgentFlexibleConfig): + name: str | None = Field(default=None, max_length=255) + key: str | None = Field(default=None, max_length=255) + env_name: str | None = Field(default=None, max_length=255) + variable: str | None = Field(default=None, max_length=255) + type: str | None = Field(default=None, max_length=64) + # UI-facing selected secret reference. This is a credential/ref id, not the + # plaintext secret value; runtime maps it to the shell-layer ``ref``. + value: str | None = Field(default=None, max_length=255) + id: str | None = Field(default=None, max_length=255) + ref: str | None = Field(default=None, max_length=255) + credential_id: str | None = Field(default=None, max_length=255) + provider_credential_id: str | None = Field(default=None, max_length=255) + provider: str | None = Field(default=None, max_length=255) + permission: AgentPermissionConfig | None = None + permission_status: str | None = Field(default=None, max_length=64) + + +class AgentCliToolEnvConfig(BaseModel): + variables: list[AgentEnvVariableConfig] = Field(default_factory=list) + secret_refs: list[AgentSecretRefConfig] = Field(default_factory=list) + + class AgentCliToolConfig(AgentFlexibleConfig): + # Stable mention/reference id (minted by the frontend on creation, backfilled at + # composer save) so renaming a CLI tool never breaks `[§cli_tool:§]` mentions. + id: str | None = Field(default=None, max_length=255) enabled: bool = True name: str | None = Field(default=None, max_length=255) tool_name: str | None = Field(default=None, max_length=255) @@ -128,7 +217,8 @@ class AgentCliToolConfig(AgentFlexibleConfig): install_command: str | None = None install: str | None = None setup_command: str | None = None - invoke_metadata: dict[str, Any] = Field(default_factory=dict, json_schema_extra={"x-dify-opaque": True}) + invoke_metadata: dict[str, Any] = Field(default_factory=dict) + env: AgentCliToolEnvConfig = Field(default_factory=AgentCliToolEnvConfig) pre_authorized: bool | None = None authorization_status: AgentCliToolAuthorizationStatus | None = None permission: AgentPermissionConfig | None = None @@ -140,6 +230,10 @@ class AgentCliToolConfig(AgentFlexibleConfig): risk_accepted: bool = False approved: bool = False risk_level: AgentCliToolRiskLevel | None = None + # Slug of the skill an infer-tools suggestion came from (ENG-371); drives + # the "inferred from " badge. Plain provenance metadata — saving an + # inferred tool still passes every composer validation rule. + inferred_from: str | None = Field(default=None, max_length=255) class AgentKnowledgeDatasetConfig(AgentFlexibleConfig): @@ -173,32 +267,6 @@ class AgentHumanToolConfig(AgentFlexibleConfig): description: str | None = None -class AgentEnvVariableConfig(AgentFlexibleConfig): - name: str | None = Field(default=None, max_length=255) - key: str | None = Field(default=None, max_length=255) - env_name: str | None = Field(default=None, max_length=255) - variable: str | None = Field(default=None, max_length=255) - type: str | None = Field(default=None, max_length=64) - value: RuntimeParameterValue = None - default: RuntimeParameterValue = None - required: bool = False - - -class AgentSecretRefConfig(AgentFlexibleConfig): - name: str | None = Field(default=None, max_length=255) - key: str | None = Field(default=None, max_length=255) - env_name: str | None = Field(default=None, max_length=255) - variable: str | None = Field(default=None, max_length=255) - type: str | None = Field(default=None, max_length=64) - id: str | None = Field(default=None, max_length=255) - ref: str | None = Field(default=None, max_length=255) - credential_id: str | None = Field(default=None, max_length=255) - provider_credential_id: str | None = Field(default=None, max_length=255) - provider: str | None = Field(default=None, max_length=255) - permission: AgentPermissionConfig | None = None - permission_status: str | None = Field(default=None, max_length=64) - - class AgentSandboxProviderConfig(AgentFlexibleConfig): image: str | None = None working_dir: str | None = None @@ -286,7 +354,7 @@ class WorkflowNodeJobMetadata(BaseModel): model_config = ConfigDict(extra="ignore") file_refs: list[AgentFileRefConfig] | None = None - agent_soul: dict[str, Any] | None = Field(default=None, json_schema_extra={"x-dify-opaque": True}) + agent_soul: dict[str, Any] | None = Field(default=None) class AgentSoulPromptConfig(BaseModel): @@ -337,7 +405,11 @@ class AgentSoulDifyToolConfig(BaseModel): provider_id: str | None = Field(default=None, max_length=255) plugin_id: str | None = Field(default=None, max_length=255) provider: str | None = Field(default=None, max_length=255) - tool_name: str = Field(min_length=1, max_length=255) + # ``None`` = provider-level entry selecting ALL tools of the provider (a + # provider hosts many tools, like an MCP server). The runtime expands the + # entry into every tool the provider currently declares; ``credential_ref`` + # applies to all of them. Mention form: ``[§tool:/*§]``. + tool_name: str | None = Field(default=None, min_length=1, max_length=255) credential_type: Literal["api-key", "oauth2", "unauthorized"] = "api-key" credential_ref: AgentSoulDifyToolCredentialRef | None = None # Reserved for a future user-rename UX. Accepted but currently rejected at @@ -434,7 +506,7 @@ class AppVariableConfig(BaseModel): name: str = Field(min_length=1, max_length=255) type: str = Field(min_length=1, max_length=64) required: bool = False - default: Any = Field(default=None, json_schema_extra={"x-dify-opaque": True}) + default: Any = Field(default=None) class AgentSoulConfig(BaseModel): @@ -476,11 +548,55 @@ class DeclaredArrayItem(BaseModel): type: DeclaredOutputType description: str | None = None + children: DeclaredOutputChildren = Field(default_factory=list) @model_validator(mode="after") def _reject_nested_array(self) -> DeclaredArrayItem: if self.type == DeclaredOutputType.ARRAY: raise ValueError("nested arrays are not supported as array_item.type") + if self.children and self.type != DeclaredOutputType.OBJECT: + raise ValueError("array_item.children is only allowed when array_item.type is object") + return self + + +class DeclaredOutputChildConfig(BaseModel): + """Nested field under an object-shaped declared output. + + The first backend version keeps child fields lightweight: they describe the + variable-picker/schema tree but do not own independent retry/check behavior. + """ + + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=255) + type: DeclaredOutputType + description: str | None = None + required: bool = True + file: DeclaredOutputFileConfig | None = None + array_item: DeclaredArrayItem | None = None + children: DeclaredOutputChildren = Field(default_factory=list) + + @model_validator(mode="after") + def _validate_shape(self) -> DeclaredOutputChildConfig: + if not _OUTPUT_NAME_PATTERN.fullmatch(self.name): + raise ValueError( + f"output child name {self.name!r} must match {_OUTPUT_NAME_PATTERN.pattern} " + "(JSON-schema-friendly identifier)" + ) + if self.type == DeclaredOutputType.FILE: + if self.file is None: + self.file = DeclaredOutputFileConfig() + elif self.file is not None: + raise ValueError("file metadata is only allowed for file output children") + + if self.type == DeclaredOutputType.ARRAY: + if self.array_item is None: + self.array_item = DeclaredArrayItem(type=DeclaredOutputType.OBJECT) + elif self.array_item is not None: + raise ValueError("array_item is only allowed when child type is array") + + if self.children and self.type != DeclaredOutputType.OBJECT: + raise ValueError("children is only allowed for object output children") return self @@ -533,7 +649,7 @@ class DeclaredOutputFailureStrategy(BaseModel): # When ``on_failure == DEFAULT_VALUE`` this value replaces the failed output. The # value's shape must match the owning ``DeclaredOutputConfig.type``; that match is # enforced at ``DeclaredOutputConfig`` level so the strategy stays type-agnostic. - default_value: Any = Field(default=None, json_schema_extra={"x-dify-opaque": True}) + default_value: Any = Field(default=None) @model_validator(mode="after") def _require_default_value_when_default_strategy(self) -> DeclaredOutputFailureStrategy: @@ -561,6 +677,7 @@ class DeclaredOutputConfig(BaseModel): required: bool = True file: DeclaredOutputFileConfig | None = None array_item: DeclaredArrayItem | None = None + children: DeclaredOutputChildren = Field(default_factory=list) check: DeclaredOutputCheckConfig | None = None failure_strategy: DeclaredOutputFailureStrategy = Field(default_factory=DeclaredOutputFailureStrategy) @@ -594,6 +711,9 @@ class DeclaredOutputConfig(BaseModel): elif self.array_item is not None: raise ValueError("array_item is only allowed when type is array") + if self.children and self.type != DeclaredOutputType.OBJECT: + raise ValueError("children is only allowed for object outputs") + # Per PRD §OUTPUT 配置框: output check is file-only. if self.check is not None and self.check.enabled and self.type != DeclaredOutputType.FILE: raise ValueError("output check is only allowed for file outputs") diff --git a/api/models/human_input.py b/api/models/human_input.py index 7b02e8d29d5..d11274bc921 100644 --- a/api/models/human_input.py +++ b/api/models/human_input.py @@ -40,6 +40,13 @@ class HumanInputForm(DefaultFieldsMixin, Base): tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) app_id: Mapped[str] = mapped_column(StringUUID, nullable=False) workflow_run_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + # ENG-635: a RUNTIME form is tagged with its owning workflow run and/or its + # conversation. Workflow / Human-Input / agent-node forms always set + # workflow_run_id, and ALSO set conversation_id when the run has a conversation + # (chatflow / advanced-chat). Agent v2 chat ask_human forms set only + # conversation_id (the new Agent App has no workflow_run_id). At least one is set; + # resume routing prefers workflow_run_id when both are present. + conversation_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) form_kind: Mapped[HumanInputFormKind] = mapped_column( EnumText(HumanInputFormKind), nullable=False, diff --git a/api/models/model.py b/api/models/model.py index 8eac8d5a322..f64cf6b04ec 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -397,6 +397,12 @@ class App(Base): __tablename__ = "apps" __table_args__ = (sa.PrimaryKeyConstraint("id", name="app_pkey"), sa.Index("app_tenant_id_idx", "tenant_id")) + if TYPE_CHECKING: + # Response-only attributes attached by app list/detail enrichers. + access_mode: str | None + has_draft_trigger: bool + is_starred: bool + id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuid4())) tenant_id: Mapped[str] = mapped_column(StringUUID) name: Mapped[str] = mapped_column(String(255)) @@ -654,6 +660,28 @@ class App(Base): return None +class AppStar(Base): + """Account-scoped star marker for apps in a workspace.""" + + __tablename__ = "app_stars" + __table_args__ = ( + sa.PrimaryKeyConstraint("id", name="app_star_pkey"), + sa.UniqueConstraint("tenant_id", "account_id", "app_id", name="app_star_tenant_account_app_unique"), + sa.Index("app_star_tenant_account_idx", "tenant_id", "account_id"), + sa.Index("app_star_app_idx", "app_id"), + ) + + id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuidv7())) + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + app_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + account_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + created_at: Mapped[datetime] = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp()) + + @override + def __repr__(self) -> str: + return f"" + + class AppModelConfig(TypeBase): __tablename__ = "app_model_configs" __table_args__ = (sa.PrimaryKeyConstraint("id", name="app_model_config_pkey"), sa.Index("app_app_id_idx", "app_id")) @@ -907,6 +935,9 @@ class RecommendedApp(TypeBase): custom_disclaimer: Mapped[str] = mapped_column(LongText, default="") position: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0) is_listed: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=True) + is_learn_dify: Mapped[bool] = mapped_column( + sa.Boolean, nullable=False, server_default=sa.text("false"), default=False + ) install_count: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0) language: Mapped[str] = mapped_column( String(255), @@ -1178,12 +1209,12 @@ class Conversation(Base): def inputs(self, value: Mapping[str, Any]): inputs = dict(value) for k, v in inputs.items(): - if isinstance(v, File): - inputs[k] = v.model_dump() - elif isinstance(v, list): - v_list = v - if all(isinstance(item, File) for item in v_list): - inputs[k] = [item.model_dump() for item in v_list if isinstance(item, File)] + match v: + case File(): + inputs[k] = v.model_dump() + case list(): + if all(isinstance(item, File) for item in v): + inputs[k] = [item.model_dump() for item in v if isinstance(item, File)] self._inputs = inputs @property diff --git a/api/models/utils/file_input_compat.py b/api/models/utils/file_input_compat.py index 04aea9f7f65..938ee6e7dd1 100644 --- a/api/models/utils/file_input_compat.py +++ b/api/models/utils/file_input_compat.py @@ -149,10 +149,10 @@ def build_file_from_mapping_without_lookup(*, file_mapping: Mapping[str, Any]) - def rebuild_serialized_graph_files_without_lookup(value: Any) -> Any: """Recursively rebuild serialized graph file payloads into `File` objects. - `graphon` 0.2.2 no longer accepts legacy serialized file mappings via - `model_validate_json()`. Dify keeps this recovery path at the model boundary - so historical JSON blobs remain readable without reintroducing global graph - patches or test-local coercion. + `graphon` no longer accepts legacy serialized file mappings via + `model_validate_json()`. Dify keeps this recovery path at the model + boundary so historical JSON blobs remain readable without reintroducing + global graph patches or test-local coercion. """ match value: case list(): diff --git a/api/openapi/markdown/console-swagger.md b/api/openapi/markdown/console-openapi.md similarity index 54% rename from api/openapi/markdown/console-swagger.md rename to api/openapi/markdown/console-openapi.md index 8fd1a57e200..b413b1c0170 100644 --- a/api/openapi/markdown/console-swagger.md +++ b/api/openapi/markdown/console-openapi.md @@ -3,1275 +3,1569 @@ Console management APIs for app configuration, monitoring, and administration ## Version: 1.0 -### Security -**Bearer** - -| apiKey | *API Key* | -| ------ | --------- | -| Description | Type: Bearer {your-api-key} | -| In | header | -| Name | Authorization | +### Available authorizations +#### Bearer (API Key Authentication) +Type: Bearer {your-api-key} +**Name:** Authorization +**In:** header --- ## console Console management API operations -### /account/avatar - -#### GET -##### Description - +### [GET] /account/avatar Get account avatar url -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | avatar | query | Avatar file ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [AvatarUrlResponse](#avatarurlresponse) | +| 200 | Success | **application/json**: [AvatarUrlResponse](#avatarurlresponse)
| -#### POST -##### Parameters +### [POST] /account/avatar +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AccountAvatarPayload](#accountavatarpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [Account](#account)
| + +### [POST] /account/change-email +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ChangeEmailSendPayload](#changeemailsendpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultDataResponse](#simpleresultdataresponse)
| + +### [POST] /account/change-email/check-email-unique +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [CheckEmailUniquePayload](#checkemailuniquepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| + +### [POST] /account/change-email/reset +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ChangeEmailResetPayload](#changeemailresetpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [Account](#account)
| + +### [POST] /account/change-email/validity +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ChangeEmailValidityPayload](#changeemailvaliditypayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [VerificationTokenResponse](#verificationtokenresponse)
| + +### [POST] /account/delete +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AccountDeletePayload](#accountdeletepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| + +### [POST] /account/delete/feedback +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AccountDeletionFeedbackPayload](#accountdeletionfeedbackpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| + +### [GET] /account/delete/verify +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultDataResponse](#simpleresultdataresponse)
| + +### [GET] /account/education +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [EducationStatusResponse](#educationstatusresponse)
| + +### [POST] /account/education +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [EducationActivatePayload](#educationactivatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [EducationActivateResponse](#educationactivateresponse)
| + +### [GET] /account/education/autocomplete +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [AccountAvatarPayload](#accountavatarpayload) | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | [Account](#account) | - -### /account/change-email - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ChangeEmailSendPayload](#changeemailsendpayload) | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | [SimpleResultDataResponse](#simpleresultdataresponse) | - -### /account/change-email/check-email-unique - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [CheckEmailUniquePayload](#checkemailuniquepayload) | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | [SimpleResultResponse](#simpleresultresponse) | - -### /account/change-email/reset - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ChangeEmailResetPayload](#changeemailresetpayload) | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | [Account](#account) | - -### /account/change-email/validity - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ChangeEmailValidityPayload](#changeemailvaliditypayload) | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | [VerificationTokenResponse](#verificationtokenresponse) | - -### /account/delete - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [AccountDeletePayload](#accountdeletepayload) | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | [SimpleResultResponse](#simpleresultresponse) | - -### /account/delete/feedback - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [AccountDeletionFeedbackPayload](#accountdeletionfeedbackpayload) | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | [SimpleResultResponse](#simpleresultresponse) | - -### /account/delete/verify - -#### GET -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | [SimpleResultDataResponse](#simpleresultdataresponse) | - -### /account/education - -#### GET -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | [EducationStatusResponse](#educationstatusresponse) | - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [EducationActivatePayload](#educationactivatepayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /account/education/autocomplete - -#### GET -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [EducationAutocompleteQuery](#educationautocompletequery) | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | [EducationAutocompleteResponse](#educationautocompleteresponse) | - -### /account/education/verify - -#### GET -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | [EducationVerifyResponse](#educationverifyresponse) | - -### /account/init - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [AccountInitPayload](#accountinitpayload) | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | [SimpleResultResponse](#simpleresultresponse) | - -### /account/integrates - -#### GET -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | [AccountIntegrateListResponse](#accountintegratelistresponse) | - -### /account/interface-language - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [AccountInterfaceLanguagePayload](#accountinterfacelanguagepayload) | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | [Account](#account) | - -### /account/interface-theme - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [AccountInterfaceThemePayload](#accountinterfacethemepayload) | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | [Account](#account) | - -### /account/name - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [AccountNamePayload](#accountnamepayload) | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | [Account](#account) | - -### /account/password - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [AccountPasswordPayload](#accountpasswordpayload) | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | [Account](#account) | - -### /account/profile - -#### GET -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | [Account](#account) | - -### /account/timezone - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [AccountTimezonePayload](#accounttimezonepayload) | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | [Account](#account) | - -### /activate - -#### POST -##### Description - -Activate account with invitation token - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ActivatePayload](#activatepayload) | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Account activated successfully | [ActivationResponse](#activationresponse) | -| 400 | Already activated or invalid token | | - -### /activate/check - -#### GET -##### Description - -Check if activation token is valid - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ActivateCheckQuery](#activatecheckquery) | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | [ActivationCheckResponse](#activationcheckresponse) | - -### /agents - -#### GET -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| keyword | query | | No | string | -| limit | query | | No | integer | +| keywords | query | | Yes | string | +| limit | query | | No | integer,
**Default:** 20 | | page | query | | No | integer | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Agent roster list | [AgentRosterListResponse](#agentrosterlistresponse) | +| 200 | Success | **application/json**: [EducationAutocompleteResponse](#educationautocompleteresponse)
| -#### POST -##### Parameters +### [GET] /account/education/verify +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [EducationVerifyResponse](#educationverifyresponse)
| + +### [POST] /account/init +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AccountInitPayload](#accountinitpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| + +### [GET] /account/integrates +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [AccountIntegrateListResponse](#accountintegratelistresponse)
| + +### [POST] /account/interface-language +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AccountInterfaceLanguagePayload](#accountinterfacelanguagepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [Account](#account)
| + +### [POST] /account/interface-theme +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AccountInterfaceThemePayload](#accountinterfacethemepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [Account](#account)
| + +### [POST] /account/name +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AccountNamePayload](#accountnamepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [Account](#account)
| + +### [POST] /account/password +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AccountPasswordPayload](#accountpasswordpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [Account](#account)
| + +### [GET] /account/profile +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [Account](#account)
| + +### [POST] /account/timezone +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AccountTimezonePayload](#accounttimezonepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [Account](#account)
| + +### [POST] /activate +Activate account with invitation token + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ActivatePayload](#activatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Account activated successfully | **application/json**: [ActivationResponse](#activationresponse)
| +| 400 | Already activated or invalid token | | + +### [GET] /activate/check +Check if activation token is valid + +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [RosterAgentCreatePayload](#rosteragentcreatepayload) | +| email | query | | No | string | +| token | query | | Yes | string | +| workspace_id | query | | No | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 201 | Agent created | [AgentRosterResponse](#agentrosterresponse) | +| 200 | Success | **application/json**: [ActivationCheckResponse](#activationcheckresponse)
| -### /agents/invite-options +### [GET] /agent +#### Parameters -#### GET -##### Parameters +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| creator_ids | query | Filter by creator account IDs | No | [ string ] | +| is_created_by_me | query | Filter by creator | No | boolean | +| limit | query | Page size (1-100) | No | integer,
**Default:** 20 | +| mode | query | App mode filter | No | string,
**Available values:** "advanced-chat", "agent", "agent-chat", "all", "channel", "chat", "completion", "workflow",
**Default:** all | +| name | query | Filter by app name | No | string | +| page | query | Page number (1-99999) | No | integer,
**Default:** 1 | +| sort_by | query | Sort apps by last modified, recently created, or earliest created | No | string,
**Available values:** "earliest_created", "last_modified", "recently_created",
**Default:** last_modified | +| tag_ids | query | Filter by tag IDs | No | [ string ] | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent app list | **application/json**: [AgentAppPagination](#agentapppagination)
| + +### [POST] /agent +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AgentAppCreatePayload](#agentappcreatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Agent app created successfully | **application/json**: [AppDetailWithSite](#appdetailwithsite)
| +| 400 | Invalid request parameters | | +| 403 | Insufficient permissions | | + +### [GET] /agent/invite-options +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | query | Workflow app id for in-current-workflow markers | No | string | | keyword | query | | No | string | -| limit | query | | No | integer | -| page | query | | No | integer | +| limit | query | | No | integer,
**Default:** 20 | +| page | query | | No | integer,
**Default:** 1 | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Agent invite options | [AgentInviteOptionsResponse](#agentinviteoptionsresponse) | +| 200 | Agent invite options | **application/json**: [AgentInviteOptionsResponse](#agentinviteoptionsresponse)
| -### /agents/{agent_id} - -#### DELETE -##### Parameters +### [DELETE] /agent/{agent_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | agent_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | -| 204 | Agent archived | +| 204 | Agent app deleted successfully | +| 403 | Insufficient permissions | -#### GET -##### Parameters +### [GET] /agent/{agent_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | agent_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Agent detail | [AgentRosterResponse](#agentrosterresponse) | +| 200 | Agent app detail | **application/json**: [AppDetailWithSite](#appdetailwithsite)
| -#### PATCH -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | | Yes | string | -| payload | body | | Yes | [RosterAgentUpdatePayload](#rosteragentupdatepayload) | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Agent updated | [AgentRosterResponse](#agentrosterresponse) | - -### /agents/{agent_id}/versions - -#### GET -##### Parameters +### [PUT] /agent/{agent_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | agent_id | path | | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AgentAppUpdatePayload](#agentappupdatepayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Agent versions | [AgentConfigSnapshotListResponse](#agentconfigsnapshotlistresponse) | +| 200 | Agent app updated successfully | **application/json**: [AppDetailWithSite](#appdetailwithsite)
| +| 400 | Invalid request parameters | | +| 403 | Insufficient permissions | | -### /agents/{agent_id}/versions/{version_id} +### [GET] /agent/{agent_id}/chat-messages +Get Agent App chat messages for a conversation with pagination -#### GET -##### Parameters +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string | +| conversation_id | query | Conversation ID | Yes | string | +| first_id | query | First message ID for pagination | No | string | +| limit | query | Number of messages to return (1-100) | No | integer,
**Default:** 20 | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [MessageInfiniteScrollPaginationResponse](#messageinfinitescrollpaginationresponse)
| +| 404 | Agent or conversation not found | | + +### [GET] /agent/{agent_id}/chat-messages/{message_id}/suggested-questions +Get suggested questions for an Agent App message + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string | +| message_id | path | Message ID | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Suggested questions retrieved successfully | **application/json**: [SuggestedQuestionsResponse](#suggestedquestionsresponse)
| +| 404 | Agent, message, or conversation not found | | + +### [POST] /agent/{agent_id}/chat-messages/{task_id}/stop +Stop a running Agent App chat message generation + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string | +| task_id | path | Task ID to stop | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Task stopped successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| + +### [GET] /agent/{agent_id}/composer +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent app composer state | **application/json**: [AgentAppComposerResponse](#agentappcomposerresponse)
| + +### [PUT] /agent/{agent_id}/composer +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ComposerSavePayload](#composersavepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent app composer saved | **application/json**: [AgentAppComposerResponse](#agentappcomposerresponse)
| + +### [GET] /agent/{agent_id}/composer/candidates +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent app composer candidates | **application/json**: [AgentComposerCandidatesResponse](#agentcomposercandidatesresponse)
| + +### [POST] /agent/{agent_id}/composer/validate +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ComposerSavePayload](#composersavepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent app composer validation result | **application/json**: [AgentComposerValidateResponse](#agentcomposervalidateresponse)
| + +### [GET] /agent/{agent_id}/drive/files +List agent drive entries for an Agent App + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string | +| prefix | query | Key prefix filter: '/' for one skill, 'files/' for files | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Drive entries | **application/json**: [AgentDriveListResponse](#agentdrivelistresponse)
| + +### [GET] /agent/{agent_id}/drive/files/download +Time-limited external signed URL for one Agent App drive value + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string | +| key | query | Drive key, e.g. tender-analyzer/SKILL.md | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Signed URL | **application/json**: [AgentDriveDownloadResponse](#agentdrivedownloadresponse)
| + +### [GET] /agent/{agent_id}/drive/files/preview +Truncated text preview of one Agent App drive value + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string | +| key | query | Drive key, e.g. tender-analyzer/SKILL.md | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Preview | **application/json**: [AgentDrivePreviewResponse](#agentdrivepreviewresponse)
| + +### [POST] /agent/{agent_id}/features +Update an Agent App's presentation features (opener, follow-up, citations, ...) + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AgentAppFeaturesPayload](#agentappfeaturespayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Features updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +| 400 | Invalid configuration | | +| 404 | Agent not found | | + +### [POST] /agent/{agent_id}/feedbacks +Create or update Agent App message feedback + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [MessageFeedbackPayload](#messagefeedbackpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Feedback updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +| 404 | Agent or message not found | | + +### [DELETE] /agent/{agent_id}/files +Delete one Agent App drive file by key + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string | +| key | query | Drive key, e.g. files/sample.pdf | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | File removed | **application/json**: [AgentDriveDeleteResponse](#agentdrivedeleteresponse)
| + +### [POST] /agent/{agent_id}/files +Commit an uploaded file into the Agent App drive under files/ + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AgentDriveFilePayload](#agentdrivefilepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | File committed into the agent drive | **application/json**: [AgentDriveFileCommitResponse](#agentdrivefilecommitresponse)
| + +### [GET] /agent/{agent_id}/logs +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| end | query | End date (YYYY-MM-DD HH:MM) | No | string | +| keyword | query | Search query, answer, or conversation name | No | string | +| limit | query | Page size | No | integer,
**Default:** 20 | +| page | query | Page number | No | integer,
**Default:** 1 | +| source | query | Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger | No | string | +| start | query | Start date (YYYY-MM-DD HH:MM) | No | string | +| status | query | Filter by success, failed, or paused | No | string | +| agent_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent logs | **application/json**: [AgentLogListResponse](#agentloglistresponse)
| + +### [GET] /agent/{agent_id}/messages/{message_id} +Get Agent App message details by ID + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string | +| message_id | path | Message ID | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Message retrieved successfully | **application/json**: [MessageDetailResponse](#messagedetailresponse)
| +| 404 | Agent or message not found | | + +### [GET] /agent/{agent_id}/referencing-workflows +List workflow apps that reference this Agent App's bound Agent (read-only) + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Referencing workflows listed successfully | **application/json**: [AgentReferencingWorkflowsResponse](#agentreferencingworkflowsresponse)
| +| 404 | Agent not found | | + +### [GET] /agent/{agent_id}/sandbox/files +List a directory in an Agent App conversation sandbox + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string | +| conversation_id | query | Agent App conversation ID | Yes | string | +| path | query | Directory path relative to the sandbox workspace | No | string,
**Default:** . | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Listing returned | **application/json**: [SandboxListResponse](#sandboxlistresponse)
| + +### [GET] /agent/{agent_id}/sandbox/files/read +Read a text/binary preview file in an Agent App conversation sandbox + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string | +| conversation_id | query | Agent App conversation ID | Yes | string | +| path | query | File path relative to the sandbox workspace | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Preview returned | **application/json**: [SandboxReadResponse](#sandboxreadresponse)
| + +### [POST] /agent/{agent_id}/sandbox/files/upload +Upload one Agent App sandbox file as a Dify ToolFile mapping + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AgentSandboxUploadPayload](#agentsandboxuploadpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Uploaded | **application/json**: [SandboxUploadResponse](#sandboxuploadresponse)
| + +### [POST] /agent/{agent_id}/skills/standardize +Validate + standardize a Skill into an Agent App drive + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Skill standardized into drive | **application/json**: [AgentSkillStandardizeResponse](#agentskillstandardizeresponse)
| +| 400 | Invalid skill package or no bound agent | | + +### [POST] /agent/{agent_id}/skills/upload +Upload + validate a Skill package for an Agent App + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Skill validated | **application/json**: [AgentSkillUploadResponse](#agentskilluploadresponse)
| +| 400 | Invalid skill package | | + +### [DELETE] /agent/{agent_id}/skills/{slug} +Delete a standardized skill from an Agent App drive + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string | +| slug | path | Skill slug (single path segment) | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Skill removed | **application/json**: [AgentDriveDeleteResponse](#agentdrivedeleteresponse)
| + +### [POST] /agent/{agent_id}/skills/{slug}/infer-tools +Infer CLI tool + ENV suggestions from a standardized Agent App skill + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string | +| slug | path | Skill slug (single path segment) | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Inference result (draft suggestions, nothing persisted) | **application/json**: [SkillToolInferenceResult](#skilltoolinferenceresult)
| + +### [GET] /agent/{agent_id}/statistics/summary +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| end | query | End date (YYYY-MM-DD HH:MM) | No | string | +| source | query | Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger | No | string | +| start | query | Start date (YYYY-MM-DD HH:MM) | No | string | +| agent_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent monitoring summary and chart data | **application/json**: [AgentStatisticSummaryEnvelopeResponse](#agentstatisticsummaryenveloperesponse)
| + +### [GET] /agent/{agent_id}/versions +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent versions | **application/json**: [AgentConfigSnapshotListResponse](#agentconfigsnapshotlistresponse)
| + +### [GET] /agent/{agent_id}/versions/{version_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | agent_id | path | | Yes | string | | version_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Agent version detail | [AgentConfigSnapshotDetailResponse](#agentconfigsnapshotdetailresponse) | +| 200 | Agent version detail | **application/json**: [AgentConfigSnapshotDetailResponse](#agentconfigsnapshotdetailresponse)
| -### /all-workspaces - -#### GET -##### Parameters +### [GET] /all-workspaces +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [WorkspaceListQuery](#workspacelistquery) | +| limit | query | | No | integer,
**Default:** 20 | +| page | query | | No | integer,
**Default:** 1 | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /api-based-extension - -#### GET -##### Description +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [WorkspaceListResponse](#workspacelistresponse)
| +### [GET] /api-based-extension Get all API-based extensions for current tenant -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [APIBasedExtensionListResponse](#apibasedextensionlistresponse) | - -#### POST -##### Description +| 200 | Success | **application/json**: [APIBasedExtensionListResponse](#apibasedextensionlistresponse)
| +### [POST] /api-based-extension Create a new API-based extension -##### Parameters +#### Request Body -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [APIBasedExtensionPayload](#apibasedextensionpayload) | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [APIBasedExtensionPayload](#apibasedextensionpayload)
| -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 201 | Extension created successfully | [APIBasedExtensionResponse](#apibasedextensionresponse) | - -### /api-based-extension/{id} - -#### DELETE -##### Description +| 201 | Extension created successfully | **application/json**: [APIBasedExtensionResponse](#apibasedextensionresponse)
| +### [DELETE] /api-based-extension/{id} Delete API-based extension -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | id | path | Extension ID | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | | 204 | Extension deleted successfully | -#### GET -##### Description - +### [GET] /api-based-extension/{id} Get API-based extension by ID -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | id | path | Extension ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [APIBasedExtensionResponse](#apibasedextensionresponse) | - -#### POST -##### Description +| 200 | Success | **application/json**: [APIBasedExtensionResponse](#apibasedextensionresponse)
| +### [POST] /api-based-extension/{id} Update API-based extension -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [APIBasedExtensionPayload](#apibasedextensionpayload) | | id | path | Extension ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [APIBasedExtensionPayload](#apibasedextensionpayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Extension updated successfully | [APIBasedExtensionResponse](#apibasedextensionresponse) | +| 200 | Extension updated successfully | **application/json**: [APIBasedExtensionResponse](#apibasedextensionresponse)
| -### /api-key-auth/data-source - -#### GET -##### Responses +### [GET] /api-key-auth/data-source +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [ApiKeyAuthDataSourceListResponse](#apikeyauthdatasourcelistresponse) | +| 200 | Success | **application/json**: [ApiKeyAuthDataSourceListResponse](#apikeyauthdatasourcelistresponse)
| -### /api-key-auth/data-source/binding +### [POST] /api-key-auth/data-source/binding +#### Request Body -#### POST -##### Parameters +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ApiKeyAuthBindingPayload](#apikeyauthbindingpayload)
| -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ApiKeyAuthBindingPayload](#apikeyauthbindingpayload) | +#### Responses -##### Responses +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /api-key-auth/data-source/{binding_id} - -#### DELETE -##### Parameters +### [DELETE] /api-key-auth/data-source/{binding_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | binding_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | | 204 | Binding deleted successfully | -### /app-dsl-version - -#### GET -##### Summary - -Get current app DSL version for workflow clipboard compatibility - -##### Description +### [GET] /app-dsl-version +**Get current app DSL version for workflow clipboard compatibility** Get current app DSL version -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [AppDslVersionResponse](#appdslversionresponse) | - -### /app/prompt-templates - -#### GET -##### Description +| 200 | Success | **application/json**: [AppDslVersionResponse](#appdslversionresponse)
| +### [GET] /app/prompt-templates Get advanced prompt templates based on app mode and model configuration -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [AdvancedPromptTemplateQuery](#advancedprompttemplatequery) | +| app_mode | query | Application mode | Yes | string | +| has_context | query | Whether has context | No | string,
**Default:** true | +| model_mode | query | Model mode | Yes | string | +| model_name | query | Model name | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Prompt templates retrieved successfully | [ object ] | +| 200 | Prompt templates retrieved successfully | **application/json**: [AdvancedPromptTemplateResponse](#advancedprompttemplateresponse)
| | 400 | Invalid request parameters | | -### /apps - -#### GET -##### Summary - -Get app list - -##### Description +### [GET] /apps +**Get app list** Get list of applications with pagination and filtering -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [AppListQuery](#applistquery) | +| creator_ids | query | Filter by creator account IDs | No | [ string ] | +| is_created_by_me | query | Filter by creator | No | boolean | +| limit | query | Page size (1-100) | No | integer,
**Default:** 20 | +| mode | query | App mode filter | No | string,
**Available values:** "advanced-chat", "agent", "agent-chat", "all", "channel", "chat", "completion", "workflow",
**Default:** all | +| name | query | Filter by app name | No | string | +| page | query | Page number (1-99999) | No | integer,
**Default:** 1 | +| sort_by | query | Sort apps by last modified, recently created, or earliest created | No | string,
**Available values:** "earliest_created", "last_modified", "recently_created",
**Default:** last_modified | +| tag_ids | query | Filter by tag IDs | No | [ string ] | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [AppPagination](#apppagination) | +| 200 | Success | **application/json**: [AppPagination](#apppagination)
| -#### POST -##### Summary - -Create app - -##### Description +### [POST] /apps +**Create app** Create a new application -##### Parameters +#### Request Body -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [CreateAppPayload](#createapppayload) | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [CreateAppPayload](#createapppayload)
| -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 201 | App created successfully | [AppDetail](#appdetail) | +| 201 | App created successfully | **application/json**: [AppDetailWithSite](#appdetailwithsite)
| | 400 | Invalid request parameters | | | 403 | Insufficient permissions | | -### /apps/imports +### [POST] /apps/imports +#### Request Body -#### POST -##### Parameters +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AppImportPayload](#appimportpayload)
| -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [AppImportPayload](#appimportpayload) | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Import completed | [Import](#import) | -| 202 | Import pending confirmation | [Import](#import) | -| 400 | Import failed | [Import](#import) | +| 200 | Import completed | **application/json**: [Import](#import)
| +| 202 | Import pending confirmation | **application/json**: [Import](#import)
| +| 400 | Import failed | **application/json**: [Import](#import)
| -### /apps/imports/{app_id}/check-dependencies - -#### GET -##### Parameters +### [GET] /apps/imports/{app_id}/check-dependencies +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Dependencies checked | [CheckDependenciesResult](#checkdependenciesresult) | +| 200 | Dependencies checked | **application/json**: [CheckDependenciesResult](#checkdependenciesresult)
| -### /apps/imports/{import_id}/confirm - -#### POST -##### Parameters +### [POST] /apps/imports/{import_id}/confirm +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | import_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Import confirmed | [Import](#import) | -| 400 | Import failed | [Import](#import) | +| 200 | Import confirmed | **application/json**: [Import](#import)
| +| 400 | Import failed | **application/json**: [Import](#import)
| -### /apps/workflows/online-users +### [GET] /apps/starred +Get applications starred by the current account -#### POST -##### Description - -Get workflow online users - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [WorkflowOnlineUsersPayload](#workflowonlineuserspayload) | +| creator_ids | query | Filter by creator account IDs | No | [ string ] | +| is_created_by_me | query | Filter by creator | No | boolean | +| limit | query | Page size (1-100) | No | integer,
**Default:** 20 | +| mode | query | App mode filter | No | string,
**Available values:** "advanced-chat", "agent", "agent-chat", "all", "channel", "chat", "completion", "workflow",
**Default:** all | +| name | query | Filter by app name | No | string | +| page | query | Page number (1-99999) | No | integer,
**Default:** 1 | +| sort_by | query | Sort apps by last modified, recently created, or earliest created | No | string,
**Available values:** "earliest_created", "last_modified", "recently_created",
**Default:** last_modified | +| tag_ids | query | Filter by tag IDs | No | [ string ] | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow online users retrieved successfully | [WorkflowOnlineUsersResponse](#workflowonlineusersresponse) | +| 200 | Success | **application/json**: [AppPagination](#apppagination)
| -### /apps/{app_id} +### [POST] /apps/workflows/online-users +Get workflow online users -#### DELETE -##### Summary +#### Request Body -Delete app +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [WorkflowOnlineUsersPayload](#workflowonlineuserspayload)
| -##### Description +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow online users retrieved successfully | **application/json**: [WorkflowOnlineUsersResponse](#workflowonlineusersresponse)
| + +### [DELETE] /apps/{app_id} +**Delete app** Delete application -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | | 204 | App deleted successfully | | 403 | Insufficient permissions | -#### GET -##### Summary - -Get app detail - -##### Description +### [GET] /apps/{app_id} +**Get app detail** Get application details -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [AppDetailWithSite](#appdetailwithsite) | +| 200 | Success | **application/json**: [AppDetailWithSite](#appdetailwithsite)
| -#### PUT -##### Summary - -Update app - -##### Description +### [PUT] /apps/{app_id} +**Update app** Update application details -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [UpdateAppPayload](#updateapppayload) | | app_id | path | Application ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [UpdateAppPayload](#updateapppayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | App updated successfully | [AppDetailWithSite](#appdetailwithsite) | +| 200 | App updated successfully | **application/json**: [AppDetailWithSite](#appdetailwithsite)
| | 400 | Invalid request parameters | | | 403 | Insufficient permissions | | -### /apps/{app_id}/advanced-chat/workflow-runs - -#### GET -##### Summary - -Get advanced chat app workflow run list - -##### Description +### [GET] /apps/{app_id}/advanced-chat/workflow-runs +**Get advanced chat app workflow run list** Get advanced chat workflow run list -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | | last_id | query | Last run ID for pagination | No | string | -| limit | query | Number of items per page (1-100) | No | integer | -| status | query | Workflow run status filter | No | string | -| triggered_from | query | Filter by trigger source: debugging or app-run. Default: debugging | No | string | +| limit | query | Number of items per page (1-100) | No | integer,
**Default:** 20 | +| status | query | Workflow run status filter | No | string,
**Available values:** "failed", "partial-succeeded", "running", "stopped", "succeeded" | +| triggered_from | query | Filter by trigger source: debugging or app-run. Default: debugging | No | string,
**Available values:** "app-run", "debugging" | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow runs retrieved successfully | [AdvancedChatWorkflowRunPaginationResponse](#advancedchatworkflowrunpaginationresponse) | +| 200 | Workflow runs retrieved successfully | **application/json**: [AdvancedChatWorkflowRunPaginationResponse](#advancedchatworkflowrunpaginationresponse)
| -### /apps/{app_id}/advanced-chat/workflow-runs/count +### [GET] /apps/{app_id}/advanced-chat/workflow-runs/count +**Get advanced chat workflow runs count statistics** -#### GET -##### Summary - -Get advanced chat workflow runs count statistics - -##### Description - -Get advanced chat workflow runs count statistics - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | -| status | query | Workflow run status filter | No | string | +| status | query | Workflow run status filter | No | string,
**Available values:** "failed", "partial-succeeded", "running", "stopped", "succeeded" | | time_range | query | Filter by time range (optional): e.g., 7d (7 days), 4h (4 hours), 30m (30 minutes), 30s (30 seconds). Filters by created_at field. | No | string | -| triggered_from | query | Filter by trigger source: debugging or app-run. Default: debugging | No | string | +| triggered_from | query | Filter by trigger source: debugging or app-run. Default: debugging | No | string,
**Available values:** "app-run", "debugging" | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow runs count retrieved successfully | [WorkflowRunCountResponse](#workflowruncountresponse) | +| 200 | Workflow runs count retrieved successfully | **application/json**: [WorkflowRunCountResponse](#workflowruncountresponse)
| -### /apps/{app_id}/advanced-chat/workflows/draft/human-input/nodes/{node_id}/form/preview - -#### POST -##### Summary - -Preview human input form content and placeholders - -##### Description +### [POST] /apps/{app_id}/advanced-chat/workflows/draft/human-input/nodes/{node_id}/form/preview +**Preview human input form content and placeholders** Get human input form preview for advanced chat workflow -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [HumanInputFormPreviewPayload](#humaninputformpreviewpayload) | | app_id | path | Application ID | Yes | string | | node_id | path | Node ID | Yes | string | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [HumanInputFormPreviewPayload](#humaninputformpreviewpayload)
| -### /apps/{app_id}/advanced-chat/workflows/draft/human-input/nodes/{node_id}/form/run +#### Responses -#### POST -##### Summary +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Human input form preview | **application/json**: [HumanInputFormPreviewResponse](#humaninputformpreviewresponse)
| -Submit human input form preview - -##### Description +### [POST] /apps/{app_id}/advanced-chat/workflows/draft/human-input/nodes/{node_id}/form/run +**Submit human input form preview** Submit human input form preview for advanced chat workflow -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [HumanInputFormSubmitPayload](#humaninputformsubmitpayload) | | app_id | path | Application ID | Yes | string | | node_id | path | Node ID | Yes | string | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [HumanInputFormSubmitPayload](#humaninputformsubmitpayload)
| -### /apps/{app_id}/advanced-chat/workflows/draft/iteration/nodes/{node_id}/run +#### Responses -#### POST -##### Summary +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Human input form submission result | **application/json**: [HumanInputFormSubmitResponse](#humaninputformsubmitresponse)
| -Run draft workflow iteration node - -##### Description +### [POST] /apps/{app_id}/advanced-chat/workflows/draft/iteration/nodes/{node_id}/run +**Run draft workflow iteration node** Run draft workflow iteration node for advanced chat -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [IterationNodeRunPayload](#iterationnoderunpayload) | | app_id | path | Application ID | Yes | string | | node_id | path | Node ID | Yes | string | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Iteration node run started successfully | -| 403 | Permission denied | -| 404 | Node not found | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [IterationNodeRunPayload](#iterationnoderunpayload)
| -### /apps/{app_id}/advanced-chat/workflows/draft/loop/nodes/{node_id}/run +#### Responses -#### POST -##### Summary +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Iteration node run started successfully | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| +| 403 | Permission denied | | +| 404 | Node not found | | -Run draft workflow loop node - -##### Description +### [POST] /apps/{app_id}/advanced-chat/workflows/draft/loop/nodes/{node_id}/run +**Run draft workflow loop node** Run draft workflow loop node for advanced chat -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [LoopNodeRunPayload](#loopnoderunpayload) | | app_id | path | Application ID | Yes | string | | node_id | path | Node ID | Yes | string | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Loop node run started successfully | -| 403 | Permission denied | -| 404 | Node not found | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [LoopNodeRunPayload](#loopnoderunpayload)
| -### /apps/{app_id}/advanced-chat/workflows/draft/run +#### Responses -#### POST -##### Summary +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Loop node run started successfully | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| +| 403 | Permission denied | | +| 404 | Node not found | | -Run draft workflow - -##### Description +### [POST] /apps/{app_id}/advanced-chat/workflows/draft/run +**Run draft workflow** Run draft workflow for advanced chat application -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [AdvancedChatWorkflowRunPayload](#advancedchatworkflowrunpayload) | -| app_id | path | Application ID | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Workflow run started successfully | -| 400 | Invalid request parameters | -| 403 | Permission denied | - -### /apps/{app_id}/agent-composer - -#### GET -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Agent app composer state | [AgentAppComposerResponse](#agentappcomposerresponse) | - -#### PUT -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | -| payload | body | | Yes | [ComposerSavePayload](#composersavepayload) | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Agent app composer saved | [AgentAppComposerResponse](#agentappcomposerresponse) | - -### /apps/{app_id}/agent-composer/candidates - -#### GET -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Agent app composer candidates | [AgentComposerCandidatesResponse](#agentcomposercandidatesresponse) | - -### /apps/{app_id}/agent-composer/validate - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | -| payload | body | | Yes | [ComposerSavePayload](#composersavepayload) | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Agent app composer validation result | [AgentComposerValidateResponse](#agentcomposervalidateresponse) | - -### /apps/{app_id}/agent-features - -#### POST -##### Description - -Update an Agent App's presentation features (opener, follow-up, citations, ...) - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [AgentAppFeaturesPayload](#agentappfeaturespayload) | -| app_id | path | Application ID | Yes | string | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Features updated successfully | [SimpleResultResponse](#simpleresultresponse) | -| 400 | Invalid configuration | | -| 404 | App not found | | - -### /apps/{app_id}/agent-referencing-workflows - -#### GET -##### Description - -List workflow apps that reference this Agent App's bound Agent (read-only) - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AdvancedChatWorkflowRunPayload](#advancedchatworkflowrunpayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Referencing workflows listed successfully | [AgentReferencingWorkflowsResponse](#agentreferencingworkflowsresponse) | -| 404 | App not found | | +| 200 | Workflow run started successfully | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| +| 400 | Invalid request parameters | | +| 403 | Permission denied | | -### /apps/{app_id}/agent-workspace/files +### [GET] /apps/{app_id}/agent/drive/files +List agent drive entries (read-only inspector; one endpoint for both tabs) -#### GET -##### Description - -List a directory in an Agent App conversation's sandbox workspace (read-only) - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | -| conversation_id | query | Agent App conversation ID | Yes | string | -| path | query | Directory path relative to the sandbox workspace | No | string | +| node_id | query | Workflow node ID (workflow composer variant) | No | string | +| prefix | query | Key prefix filter: '/' for one skill, 'files/' for files | No | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Listing returned | [WorkspaceListResponse](#workspacelistresponse) | +| 200 | Drive entries | **application/json**: [AgentDriveListResponse](#agentdrivelistresponse)
| -### /apps/{app_id}/agent-workspace/files/download +### [GET] /apps/{app_id}/agent/drive/files/download +Time-limited external signed URL for one drive value (no streaming proxy) -#### GET -##### Description - -Download a file from an Agent App conversation's sandbox workspace (read-only) - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | -| conversation_id | query | Agent App conversation ID | Yes | string | -| path | query | File path relative to the sandbox workspace | Yes | string | +| key | query | Drive key, e.g. tender-analyzer/SKILL.md | Yes | string | +| node_id | query | Workflow node ID (workflow composer variant) | No | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | File bytes | binary | -| 413 | File exceeds the workspace download limit | | +| 200 | Signed URL | **application/json**: [AgentDriveDownloadResponse](#agentdrivedownloadresponse)
| -### /apps/{app_id}/agent-workspace/files/preview +### [GET] /apps/{app_id}/agent/drive/files/preview +Truncated text preview of one drive value (binary-safe; SKILL.md is the main case) -#### GET -##### Description - -Preview a text/binary file in an Agent App conversation's sandbox workspace - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | -| conversation_id | query | Agent App conversation ID | Yes | string | -| path | query | File path relative to the sandbox workspace | Yes | string | +| key | query | Drive key, e.g. tender-analyzer/SKILL.md | Yes | string | +| node_id | query | Workflow node ID (workflow composer variant) | No | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Preview returned | [WorkspacePreviewResponse](#workspacepreviewresponse) | +| 200 | Preview | **application/json**: [AgentDrivePreviewResponse](#agentdrivepreviewresponse)
| -### /apps/{app_id}/agent/logs +### [DELETE] /apps/{app_id}/agent/files +Delete one drive file by key; soul ref first, then the KV row (ENG-625 D5) -#### GET -##### Summary +#### Parameters -Get agent logs +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| key | query | Drive key, e.g. files/sample.pdf | Yes | string | +| node_id | query | Workflow node ID (workflow composer variant) | No | string | -##### Description +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | File removed | **application/json**: [AgentDriveDeleteResponse](#agentdrivedeleteresponse)
| + +### [POST] /apps/{app_id}/agent/files +**ADD FILE: commit one uploaded file into the bound agent's drive** + +Commit an uploaded file into the agent drive under files/ (ENG-625 D3) + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| node_id | query | Workflow node ID (workflow composer variant) | No | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AgentDriveFilePayload](#agentdrivefilepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | File committed into the agent drive | **application/json**: [AgentDriveFileCommitResponse](#agentdrivefilecommitresponse)
| + +### [GET] /apps/{app_id}/agent/logs +**Get agent logs** Get agent execution logs for an application -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [AgentLogQuery](#agentlogquery) | | app_id | path | Application ID | Yes | string | +| conversation_id | query | Conversation UUID | Yes | string | +| message_id | query | Message UUID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Agent logs retrieved successfully | [ object ] | +| 200 | Agent logs retrieved successfully | **application/json**: [AgentLogResponse](#agentlogresponse)
| | 400 | Invalid request parameters | | -### /apps/{app_id}/agent/skills/standardize - -#### POST -##### Summary - -Upload a Skill, validate it, and standardize it into the app agent's drive - -##### Description +### [POST] /apps/{app_id}/agent/skills/standardize +**Upload a Skill, validate it, and standardize it into the app agent's drive** Validate + standardize a Skill into the agent drive (ENG-594) -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | +| node_id | query | Workflow node ID (workflow composer variant) | No | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 201 | Skill standardized into drive | -| 400 | Invalid skill package or no bound agent | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Skill standardized into drive | **application/json**: [AgentSkillStandardizeResponse](#agentskillstandardizeresponse)
| +| 400 | Invalid skill package or no bound agent | | -### /apps/{app_id}/agent/skills/upload - -#### POST -##### Summary - -Validate an uploaded Skill package and persist the archive - -##### Description +### [POST] /apps/{app_id}/agent/skills/upload +**Validate an uploaded Skill package and persist the archive** Upload + validate a Skill package (.zip/.skill) and extract its manifest Returns a validated skill ref (to bind into the Agent soul config on save) plus its manifest. Standardizing into the agent drive is ENG-594. -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 201 | Skill validated | -| 400 | Invalid skill package | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Skill validated | **application/json**: [AgentSkillUploadResponse](#agentskilluploadresponse)
| +| 400 | Invalid skill package | | -### /apps/{app_id}/annotation-reply/{action} +### [DELETE] /apps/{app_id}/agent/skills/{slug} +Delete a standardized skill: soul ref first, then the / drive prefix (ENG-625 D5) -#### POST -##### Description - -Enable or disable annotation reply for an app - -##### Parameters +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| slug | path | Skill slug (single path segment) | Yes | string | +| node_id | query | Workflow node ID (workflow composer variant) | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Skill removed | **application/json**: [AgentDriveDeleteResponse](#agentdrivedeleteresponse)
| + +### [POST] /apps/{app_id}/agent/skills/{slug}/infer-tools +**Suggest CLI tools/env for a skill** + +Infer CLI tool + ENV suggestions from a standardized skill's SKILL.md (draft only, ENG-371) +Saving still goes through composer validation. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| slug | path | Skill slug (single path segment) | Yes | string | +| node_id | query | Workflow node ID (workflow composer variant) | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Inference result (draft suggestions, nothing persisted) | **application/json**: [SkillToolInferenceResult](#skilltoolinferenceresult)
| + +### [POST] /apps/{app_id}/annotation-reply/{action} +Enable or disable annotation reply for an app + +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [AnnotationReplyPayload](#annotationreplypayload) | | action | path | Action to perform (enable/disable) | Yes | string | | app_id | path | Application ID | Yes | string | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Action completed successfully | -| 403 | Insufficient permissions | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AnnotationReplyPayload](#annotationreplypayload)
| -### /apps/{app_id}/annotation-reply/{action}/status/{job_id} +#### Responses -#### GET -##### Description +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Action completed successfully | **application/json**: [AnnotationJobStatusResponse](#annotationjobstatusresponse)
| +| 403 | Insufficient permissions | | +### [GET] /apps/{app_id}/annotation-reply/{action}/status/{job_id} Get status of annotation reply action job -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | @@ -1279,329 +1573,303 @@ Get status of annotation reply action job | app_id | path | Application ID | Yes | string | | job_id | path | Job ID | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Job status retrieved successfully | -| 403 | Insufficient permissions | - -### /apps/{app_id}/annotation-setting - -#### GET -##### Description +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Job status retrieved successfully | **application/json**: [AnnotationJobStatusResponse](#annotationjobstatusresponse)
| +| 403 | Insufficient permissions | | +### [GET] /apps/{app_id}/annotation-setting Get annotation settings for an app -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Annotation settings retrieved successfully | -| 403 | Insufficient permissions | - -### /apps/{app_id}/annotation-settings/{annotation_setting_id} - -#### POST -##### Description +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Annotation settings retrieved successfully | **application/json**: [AnnotationSettingResponse](#annotationsettingresponse)
| +| 403 | Insufficient permissions | | +### [POST] /apps/{app_id}/annotation-settings/{annotation_setting_id} Update annotation settings for an app -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [AnnotationSettingUpdatePayload](#annotationsettingupdatepayload) | | annotation_setting_id | path | Annotation setting ID | Yes | string | | app_id | path | Application ID | Yes | string | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Settings updated successfully | -| 403 | Insufficient permissions | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AnnotationSettingUpdatePayload](#annotationsettingupdatepayload)
| -### /apps/{app_id}/annotations +#### Responses -#### DELETE -##### Parameters +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Settings updated successfully | **application/json**: [AnnotationSettingResponse](#annotationsettingresponse)
| +| 403 | Insufficient permissions | | + +### [DELETE] /apps/{app_id}/annotations +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | -| 200 | Success | - -#### GET -##### Description +| 204 | Annotations deleted successfully | +### [GET] /apps/{app_id}/annotations Get annotations for an app with pagination -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [AnnotationListQuery](#annotationlistquery) | | app_id | path | Application ID | Yes | string | +| keyword | query | Search keyword | No | string | +| limit | query | Page size | No | integer,
**Default:** 20 | +| page | query | Page number | No | integer,
**Default:** 1 | -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Annotations retrieved successfully | -| 403 | Insufficient permissions | - -#### POST -##### Description - -Create a new annotation for an app - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [CreateAnnotationPayload](#createannotationpayload) | -| app_id | path | Application ID | Yes | string | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 201 | Annotation created successfully | [Annotation](#annotation) | +| 200 | Annotations retrieved successfully | **application/json**: [AnnotationList](#annotationlist)
| | 403 | Insufficient permissions | | -### /apps/{app_id}/annotations/batch-import +### [POST] /apps/{app_id}/annotations +Create a new annotation for an app -#### POST -##### Description - -Batch import annotations from CSV file with rate limiting and security checks - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Batch import started successfully | -| 400 | No file uploaded or too many files | -| 403 | Insufficient permissions | -| 413 | File too large | -| 429 | Too many requests or concurrent imports | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [CreateAnnotationPayload](#createannotationpayload)
| -### /apps/{app_id}/annotations/batch-import-status/{job_id} +#### Responses -#### GET -##### Description +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Annotation created successfully | **application/json**: [Annotation](#annotation)
| +| 403 | Insufficient permissions | | +### [POST] /apps/{app_id}/annotations/batch-import +Batch import annotations from CSV file with rate limiting and security checks + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Batch import started successfully | **application/json**: [AnnotationJobStatusResponse](#annotationjobstatusresponse)
| +| 400 | No file uploaded or too many files | | +| 403 | Insufficient permissions | | +| 413 | File too large | | +| 429 | Too many requests or concurrent imports | | + +### [GET] /apps/{app_id}/annotations/batch-import-status/{job_id} Get status of batch import job -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | | job_id | path | Job ID | Yes | string | -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Job status retrieved successfully | -| 403 | Insufficient permissions | - -### /apps/{app_id}/annotations/count - -#### GET -##### Description - -Get count of message annotations for the app - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Annotation count retrieved successfully | [AnnotationCountResponse](#annotationcountresponse) | - -### /apps/{app_id}/annotations/export - -#### GET -##### Description - -Export all annotations for an app with CSV injection protection - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Annotations exported successfully | [AnnotationExportList](#annotationexportlist) | +| 200 | Job status retrieved successfully | **application/json**: [AnnotationJobStatusResponse](#annotationjobstatusresponse)
| | 403 | Insufficient permissions | | -### /apps/{app_id}/annotations/{annotation_id} +### [GET] /apps/{app_id}/annotations/count +Get count of message annotations for the app -#### DELETE -##### Parameters +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Annotation count retrieved successfully | **application/json**: [AnnotationCountResponse](#annotationcountresponse)
| + +### [GET] /apps/{app_id}/annotations/export +Export all annotations for an app with CSV injection protection + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Annotations exported successfully | **application/json**: [AnnotationExportList](#annotationexportlist)
| +| 403 | Insufficient permissions | | + +### [DELETE] /apps/{app_id}/annotations/{annotation_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | annotation_id | path | | Yes | string | | app_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | -| 200 | Success | - -#### POST -##### Description +| 204 | Annotation deleted successfully | +### [POST] /apps/{app_id}/annotations/{annotation_id} Update or delete an annotation -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [UpdateAnnotationPayload](#updateannotationpayload) | | annotation_id | path | Annotation ID | Yes | string | | app_id | path | Application ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [UpdateAnnotationPayload](#updateannotationpayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Annotation updated successfully | [Annotation](#annotation) | +| 200 | Annotation updated successfully | **application/json**: [Annotation](#annotation)
| | 204 | Annotation deleted successfully | | | 403 | Insufficient permissions | | -### /apps/{app_id}/annotations/{annotation_id}/hit-histories - -#### GET -##### Description - +### [GET] /apps/{app_id}/annotations/{annotation_id}/hit-histories Get hit histories for an annotation -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | annotation_id | path | Annotation ID | Yes | string | | app_id | path | Application ID | Yes | string | -| limit | query | Page size | No | integer | -| page | query | Page number | No | integer | +| limit | query | Page size | No | integer,
**Default:** 20 | +| page | query | Page number | No | integer,
**Default:** 1 | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Hit histories retrieved successfully | [AnnotationHitHistoryList](#annotationhithistorylist) | +| 200 | Hit histories retrieved successfully | **application/json**: [AnnotationHitHistoryList](#annotationhithistorylist)
| | 403 | Insufficient permissions | | -### /apps/{app_id}/api-enable - -#### POST -##### Description - +### [POST] /apps/{app_id}/api-enable Enable or disable app API -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [AppApiStatusPayload](#appapistatuspayload) | | app_id | path | Application ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AppApiStatusPayload](#appapistatuspayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | API status updated successfully | [AppDetail](#appdetail) | +| 200 | API status updated successfully | **application/json**: [AppDetail](#appdetail)
| | 403 | Insufficient permissions | | -### /apps/{app_id}/audio-to-text - -#### POST -##### Description - +### [POST] /apps/{app_id}/audio-to-text Transcript audio to text for chat messages -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | App ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Audio transcription successful | [AudioTranscriptResponse](#audiotranscriptresponse) | +| 200 | Audio transcription successful | **application/json**: [AudioTranscriptResponse](#audiotranscriptresponse)
| | 400 | Bad request - No audio uploaded or unsupported type | | | 413 | Audio file too large | | -### /apps/{app_id}/chat-conversations - -#### GET -##### Description - +### [GET] /apps/{app_id}/chat-conversations Get chat conversations with pagination, filtering and summary -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ChatConversationQuery](#chatconversationquery) | | app_id | path | Application ID | Yes | string | +| annotation_status | query | Annotation status filter | No | string,
**Available values:** "all", "annotated", "not_annotated",
**Default:** all | +| end | query | End date (YYYY-MM-DD HH:MM) | No | string | +| keyword | query | Search keyword | No | string | +| limit | query | Page size (1-100) | No | integer,
**Default:** 20 | +| page | query | Page number | No | integer,
**Default:** 1 | +| sort_by | query | Sort field and direction | No | string,
**Available values:** "-created_at", "-updated_at", "created_at", "updated_at",
**Default:** -updated_at | +| start | query | Start date (YYYY-MM-DD HH:MM) | No | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [ConversationWithSummaryPagination](#conversationwithsummarypagination) | +| 200 | Success | **application/json**: [ConversationWithSummaryPagination](#conversationwithsummarypagination)
| | 403 | Insufficient permissions | | -### /apps/{app_id}/chat-conversations/{conversation_id} - -#### DELETE -##### Description - +### [DELETE] /apps/{app_id}/chat-conversations/{conversation_id} Delete a chat conversation -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | | conversation_id | path | Conversation ID | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | @@ -1609,124 +1877,109 @@ Delete a chat conversation | 403 | Insufficient permissions | | 404 | Conversation not found | -#### GET -##### Description - +### [GET] /apps/{app_id}/chat-conversations/{conversation_id} Get chat conversation details -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | | conversation_id | path | Conversation ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [ConversationDetail](#conversationdetail) | +| 200 | Success | **application/json**: [ConversationDetail](#conversationdetail)
| | 403 | Insufficient permissions | | | 404 | Conversation not found | | -### /apps/{app_id}/chat-messages - -#### GET -##### Description - +### [GET] /apps/{app_id}/chat-messages Get chat messages for a conversation with pagination -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ChatMessagesQuery](#chatmessagesquery) | | app_id | path | Application ID | Yes | string | +| conversation_id | query | Conversation ID | Yes | string | +| first_id | query | First message ID for pagination | No | string | +| limit | query | Number of messages to return (1-100) | No | integer,
**Default:** 20 | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [MessageInfiniteScrollPaginationResponse](#messageinfinitescrollpaginationresponse) | +| 200 | Success | **application/json**: [MessageInfiniteScrollPaginationResponse](#messageinfinitescrollpaginationresponse)
| | 404 | Conversation not found | | -### /apps/{app_id}/chat-messages/{message_id}/suggested-questions - -#### GET -##### Description - +### [GET] /apps/{app_id}/chat-messages/{message_id}/suggested-questions Get suggested questions for a message -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | | message_id | path | Message ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Suggested questions retrieved successfully | [SuggestedQuestionsResponse](#suggestedquestionsresponse) | +| 200 | Suggested questions retrieved successfully | **application/json**: [SuggestedQuestionsResponse](#suggestedquestionsresponse)
| | 404 | Message or conversation not found | | -### /apps/{app_id}/chat-messages/{task_id}/stop - -#### POST -##### Description - +### [POST] /apps/{app_id}/chat-messages/{task_id}/stop Stop a running chat message generation -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | | task_id | path | Task ID to stop | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Task stopped successfully | [SimpleResultResponse](#simpleresultresponse) | - -### /apps/{app_id}/completion-conversations - -#### GET -##### Description +| 200 | Task stopped successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +### [GET] /apps/{app_id}/completion-conversations Get completion conversations with pagination and filtering -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [CompletionConversationQuery](#completionconversationquery) | | app_id | path | Application ID | Yes | string | +| annotation_status | query | Annotation status filter | No | string,
**Available values:** "all", "annotated", "not_annotated",
**Default:** all | +| end | query | End date (YYYY-MM-DD HH:MM) | No | string | +| keyword | query | Search keyword | No | string | +| limit | query | Page size (1-100) | No | integer,
**Default:** 20 | +| page | query | Page number | No | integer,
**Default:** 1 | +| start | query | Start date (YYYY-MM-DD HH:MM) | No | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [ConversationPagination](#conversationpagination) | +| 200 | Success | **application/json**: [ConversationPagination](#conversationpagination)
| | 403 | Insufficient permissions | | -### /apps/{app_id}/completion-conversations/{conversation_id} - -#### DELETE -##### Description - +### [DELETE] /apps/{app_id}/completion-conversations/{conversation_id} Delete a completion conversation -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | | conversation_id | path | Conversation ID | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | @@ -1734,1017 +1987,947 @@ Delete a completion conversation | 403 | Insufficient permissions | | 404 | Conversation not found | -#### GET -##### Description - +### [GET] /apps/{app_id}/completion-conversations/{conversation_id} Get completion conversation details with messages -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | | conversation_id | path | Conversation ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [ConversationMessageDetail](#conversationmessagedetail) | +| 200 | Success | **application/json**: [ConversationMessageDetail](#conversationmessagedetail)
| | 403 | Insufficient permissions | | | 404 | Conversation not found | | -### /apps/{app_id}/completion-messages - -#### POST -##### Description - +### [POST] /apps/{app_id}/completion-messages Generate completion message for debugging -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [CompletionMessagePayload](#completionmessagepayload) | | app_id | path | Application ID | Yes | string | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Completion generated successfully | -| 400 | Invalid request parameters | -| 404 | App not found | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [CompletionMessagePayload](#completionmessagepayload)
| -### /apps/{app_id}/completion-messages/{task_id}/stop +#### Responses -#### POST -##### Description +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Completion generated successfully | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| +| 400 | Invalid request parameters | | +| 404 | App not found | | +### [POST] /apps/{app_id}/completion-messages/{task_id}/stop Stop a running completion message generation -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | | task_id | path | Task ID to stop | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Task stopped successfully | [SimpleResultResponse](#simpleresultresponse) | - -### /apps/{app_id}/conversation-variables - -#### GET -##### Description +| 200 | Task stopped successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +### [GET] /apps/{app_id}/conversation-variables Get conversation variables for an application -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ConversationVariablesQuery](#conversationvariablesquery) | | app_id | path | Application ID | Yes | string | +| conversation_id | query | Conversation ID to filter variables | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Conversation variables retrieved successfully | [PaginatedConversationVariableResponse](#paginatedconversationvariableresponse) | +| 200 | Conversation variables retrieved successfully | **application/json**: [PaginatedConversationVariableResponse](#paginatedconversationvariableresponse)
| -### /apps/{app_id}/convert-to-workflow - -#### POST -##### Summary - -Convert basic mode of chatbot app to workflow mode - -##### Description +### [POST] /apps/{app_id}/convert-to-workflow +**Convert basic mode of chatbot app to workflow mode** Convert application to workflow mode Convert expert mode of chatbot app to workflow mode Convert Completion App to Workflow App -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ConvertToWorkflowPayload](#converttoworkflowpayload) | | app_id | path | Application ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ConvertToWorkflowPayload](#converttoworkflowpayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Application converted to workflow successfully | [NewAppResponse](#newappresponse) | +| 200 | Application converted to workflow successfully | **application/json**: [NewAppResponse](#newappresponse)
| | 400 | Application cannot be converted | | | 403 | Permission denied | | -### /apps/{app_id}/copy - -#### POST -##### Summary - -Copy app - -##### Description +### [POST] /apps/{app_id}/copy +**Copy app** Create a copy of an existing application -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [CopyAppPayload](#copyapppayload) | | app_id | path | Application ID to copy | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [CopyAppPayload](#copyapppayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 201 | App copied successfully | [AppDetailWithSite](#appdetailwithsite) | +| 201 | App copied successfully | **application/json**: [AppDetailWithSite](#appdetailwithsite)
| | 403 | Insufficient permissions | | -### /apps/{app_id}/export - -#### GET -##### Summary - -Export app - -##### Description +### [GET] /apps/{app_id}/export +**Export app** Export application configuration as DSL -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [AppExportQuery](#appexportquery) | | app_id | path | Application ID to export | Yes | string | +| include_secret | query | Include secrets in export | No | boolean | +| workflow_id | query | Specific workflow ID to export | No | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | App exported successfully | [AppExportResponse](#appexportresponse) | +| 200 | App exported successfully | **application/json**: [AppExportResponse](#appexportresponse)
| | 403 | Insufficient permissions | | -### /apps/{app_id}/feedbacks - -#### POST -##### Description - +### [POST] /apps/{app_id}/feedbacks Create or update message feedback (like/dislike) -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [MessageFeedbackPayload](#messagefeedbackpayload) | | app_id | path | Application ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [MessageFeedbackPayload](#messagefeedbackpayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Feedback updated successfully | [SimpleResultResponse](#simpleresultresponse) | +| 200 | Feedback updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| | 403 | Insufficient permissions | | | 404 | Message not found | | -### /apps/{app_id}/feedbacks/export - -#### GET -##### Description - +### [GET] /apps/{app_id}/feedbacks/export Export user feedback data for Google Sheets -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [FeedbackExportQuery](#feedbackexportquery) | | app_id | path | Application ID | Yes | string | +| end_date | query | End date (YYYY-MM-DD) | No | string | +| format | query | Export format | No | string,
**Available values:** "csv", "json",
**Default:** csv | +| from_source | query | Filter by feedback source | No | string,
**Available values:** "admin", "user" | +| has_comment | query | Only include feedback with comments | No | boolean | +| rating | query | Filter by rating | No | string,
**Available values:** "dislike", "like" | +| start_date | query | Start date (YYYY-MM-DD) | No | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Feedback data exported successfully | -| 400 | Invalid parameters | -| 500 | Internal server error | - -### /apps/{app_id}/icon - -#### POST -##### Description +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Feedback data exported successfully | **application/json**: [TextFileResponse](#textfileresponse)
| +| 400 | Invalid parameters | | +| 500 | Internal server error | | +### [POST] /apps/{app_id}/icon Update application icon -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [AppIconPayload](#appiconpayload) | | app_id | path | Application ID | Yes | string | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Icon updated successfully | -| 403 | Insufficient permissions | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AppIconPayload](#appiconpayload)
| -### /apps/{app_id}/messages/{message_id} +#### Responses -#### GET -##### Description +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Icon updated successfully | **application/json**: [AppDetail](#appdetail)
| +| 403 | Insufficient permissions | | +### [GET] /apps/{app_id}/messages/{message_id} Get message details by ID -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | | message_id | path | Message ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Message retrieved successfully | [MessageDetailResponse](#messagedetailresponse) | +| 200 | Message retrieved successfully | **application/json**: [MessageDetailResponse](#messagedetailresponse)
| | 404 | Message not found | | -### /apps/{app_id}/model-config - -#### POST -##### Summary - -Modify app model config - -##### Description +### [POST] /apps/{app_id}/model-config +**Modify app model config** Update application model configuration -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ModelConfigRequest](#modelconfigrequest) | | app_id | path | Application ID | Yes | string | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Model configuration updated successfully | -| 400 | Invalid configuration | -| 404 | App not found | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ModelConfigRequest](#modelconfigrequest)
| -### /apps/{app_id}/name - -#### POST -##### Description - -Check if app name is available - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [AppNamePayload](#appnamepayload) | -| app_id | path | Application ID | Yes | string | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Name availability checked | [AppDetail](#appdetail) | +| 200 | Model configuration updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +| 400 | Invalid configuration | | +| 404 | App not found | | -### /apps/{app_id}/publish-to-creators-platform +### [POST] /apps/{app_id}/name +Check if app name is available -#### POST -##### Summary +#### Parameters -Publish app to Creators Platform +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | -##### Parameters +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AppNamePayload](#appnamepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Name availability checked | **application/json**: [AppDetail](#appdetail)
| + +### [POST] /apps/{app_id}/publish-to-creators-platform +**Publish app to Creators Platform** + +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [RedirectUrlResponse](#redirecturlresponse) | - -### /apps/{app_id}/server - -#### GET -##### Description +| 200 | Success | **application/json**: [RedirectUrlResponse](#redirecturlresponse)
| +### [GET] /apps/{app_id}/server Get MCP server configuration for an application -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | MCP server configuration retrieved successfully | [AppMCPServerResponse](#appmcpserverresponse) | - -#### POST -##### Description +| 200 | MCP server configuration retrieved successfully | **application/json**: [AppMCPServerResponse](#appmcpserverresponse)
| +### [POST] /apps/{app_id}/server Create MCP server configuration for an application -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [MCPServerCreatePayload](#mcpservercreatepayload) | | app_id | path | Application ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [MCPServerCreatePayload](#mcpservercreatepayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 201 | MCP server configuration created successfully | [AppMCPServerResponse](#appmcpserverresponse) | +| 201 | MCP server configuration created successfully | **application/json**: [AppMCPServerResponse](#appmcpserverresponse)
| | 403 | Insufficient permissions | | -#### PUT -##### Description - +### [PUT] /apps/{app_id}/server Update MCP server configuration for an application -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [MCPServerUpdatePayload](#mcpserverupdatepayload) | | app_id | path | Application ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [MCPServerUpdatePayload](#mcpserverupdatepayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | MCP server configuration updated successfully | [AppMCPServerResponse](#appmcpserverresponse) | +| 200 | MCP server configuration updated successfully | **application/json**: [AppMCPServerResponse](#appmcpserverresponse)
| | 403 | Insufficient permissions | | | 404 | Server not found | | -### /apps/{app_id}/site - -#### POST -##### Description - +### [POST] /apps/{app_id}/site Update application site configuration -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [AppSiteUpdatePayload](#appsiteupdatepayload) | | app_id | path | Application ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AppSiteUpdatePayload](#appsiteupdatepayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Site configuration updated successfully | [AppSiteResponse](#appsiteresponse) | +| 200 | Site configuration updated successfully | **application/json**: [AppSiteResponse](#appsiteresponse)
| | 403 | Insufficient permissions | | | 404 | App not found | | -### /apps/{app_id}/site-enable - -#### POST -##### Description - +### [POST] /apps/{app_id}/site-enable Enable or disable app site -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [AppSiteStatusPayload](#appsitestatuspayload) | | app_id | path | Application ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AppSiteStatusPayload](#appsitestatuspayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Site status updated successfully | [AppDetail](#appdetail) | +| 200 | Site status updated successfully | **application/json**: [AppDetail](#appdetail)
| | 403 | Insufficient permissions | | -### /apps/{app_id}/site/access-token-reset - -#### POST -##### Description - +### [POST] /apps/{app_id}/site/access-token-reset Reset access token for application site -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Access token reset successfully | [AppSiteResponse](#appsiteresponse) | +| 200 | Access token reset successfully | **application/json**: [AppSiteResponse](#appsiteresponse)
| | 403 | Insufficient permissions (admin/owner required) | | | 404 | App or site not found | | -### /apps/{app_id}/statistics/average-response-time +### [DELETE] /apps/{app_id}/star +Remove the current account's star from an application -#### GET -##### Description +#### Parameters +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +| 404 | App not found | | + +### [POST] /apps/{app_id}/star +Star an application for the current account + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +| 404 | App not found | | + +### [GET] /apps/{app_id}/statistics/average-response-time Get average response time statistics for an application -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [StatisticTimeRangeQuery](#statistictimerangequery) | | app_id | path | Application ID | Yes | string | +| end | query | End date (YYYY-MM-DD HH:MM) | No | string | +| start | query | Start date (YYYY-MM-DD HH:MM) | No | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Average response time statistics retrieved successfully | [ object ] | - -### /apps/{app_id}/statistics/average-session-interactions - -#### GET -##### Description +| 200 | Average response time statistics retrieved successfully | **application/json**: [AverageResponseTimeStatisticResponse](#averageresponsetimestatisticresponse)
| +### [GET] /apps/{app_id}/statistics/average-session-interactions Get average session interaction statistics for an application -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [StatisticTimeRangeQuery](#statistictimerangequery) | | app_id | path | Application ID | Yes | string | +| end | query | End date (YYYY-MM-DD HH:MM) | No | string | +| start | query | Start date (YYYY-MM-DD HH:MM) | No | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Average session interaction statistics retrieved successfully | [ object ] | - -### /apps/{app_id}/statistics/daily-conversations - -#### GET -##### Description +| 200 | Average session interaction statistics retrieved successfully | **application/json**: [AverageSessionInteractionStatisticResponse](#averagesessioninteractionstatisticresponse)
| +### [GET] /apps/{app_id}/statistics/daily-conversations Get daily conversation statistics for an application -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [StatisticTimeRangeQuery](#statistictimerangequery) | | app_id | path | Application ID | Yes | string | +| end | query | End date (YYYY-MM-DD HH:MM) | No | string | +| start | query | Start date (YYYY-MM-DD HH:MM) | No | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Daily conversation statistics retrieved successfully | [ object ] | - -### /apps/{app_id}/statistics/daily-end-users - -#### GET -##### Description +| 200 | Daily conversation statistics retrieved successfully | **application/json**: [DailyConversationStatisticResponse](#dailyconversationstatisticresponse)
| +### [GET] /apps/{app_id}/statistics/daily-end-users Get daily terminal/end-user statistics for an application -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [StatisticTimeRangeQuery](#statistictimerangequery) | | app_id | path | Application ID | Yes | string | +| end | query | End date (YYYY-MM-DD HH:MM) | No | string | +| start | query | Start date (YYYY-MM-DD HH:MM) | No | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Daily terminal statistics retrieved successfully | [ object ] | - -### /apps/{app_id}/statistics/daily-messages - -#### GET -##### Description +| 200 | Daily terminal statistics retrieved successfully | **application/json**: [DailyTerminalStatisticResponse](#dailyterminalstatisticresponse)
| +### [GET] /apps/{app_id}/statistics/daily-messages Get daily message statistics for an application -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [StatisticTimeRangeQuery](#statistictimerangequery) | | app_id | path | Application ID | Yes | string | +| end | query | End date (YYYY-MM-DD HH:MM) | No | string | +| start | query | Start date (YYYY-MM-DD HH:MM) | No | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Daily message statistics retrieved successfully | [ object ] | - -### /apps/{app_id}/statistics/token-costs - -#### GET -##### Description +| 200 | Daily message statistics retrieved successfully | **application/json**: [DailyMessageStatisticResponse](#dailymessagestatisticresponse)
| +### [GET] /apps/{app_id}/statistics/token-costs Get daily token cost statistics for an application -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [StatisticTimeRangeQuery](#statistictimerangequery) | | app_id | path | Application ID | Yes | string | +| end | query | End date (YYYY-MM-DD HH:MM) | No | string | +| start | query | Start date (YYYY-MM-DD HH:MM) | No | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Daily token cost statistics retrieved successfully | [ object ] | - -### /apps/{app_id}/statistics/tokens-per-second - -#### GET -##### Description +| 200 | Daily token cost statistics retrieved successfully | **application/json**: [DailyTokenCostStatisticResponse](#dailytokencoststatisticresponse)
| +### [GET] /apps/{app_id}/statistics/tokens-per-second Get tokens per second statistics for an application -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [StatisticTimeRangeQuery](#statistictimerangequery) | | app_id | path | Application ID | Yes | string | +| end | query | End date (YYYY-MM-DD HH:MM) | No | string | +| start | query | Start date (YYYY-MM-DD HH:MM) | No | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Tokens per second statistics retrieved successfully | [ object ] | - -### /apps/{app_id}/statistics/user-satisfaction-rate - -#### GET -##### Description +| 200 | Tokens per second statistics retrieved successfully | **application/json**: [TokensPerSecondStatisticResponse](#tokenspersecondstatisticresponse)
| +### [GET] /apps/{app_id}/statistics/user-satisfaction-rate Get user satisfaction rate statistics for an application -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [StatisticTimeRangeQuery](#statistictimerangequery) | | app_id | path | Application ID | Yes | string | +| end | query | End date (YYYY-MM-DD HH:MM) | No | string | +| start | query | Start date (YYYY-MM-DD HH:MM) | No | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | User satisfaction rate statistics retrieved successfully | [ object ] | - -### /apps/{app_id}/text-to-audio - -#### POST -##### Description +| 200 | User satisfaction rate statistics retrieved successfully | **application/json**: [UserSatisfactionRateStatisticResponse](#usersatisfactionratestatisticresponse)
| +### [POST] /apps/{app_id}/text-to-audio Convert text to speech for chat messages -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [TextToSpeechPayload](#texttospeechpayload) | | app_id | path | App ID | Yes | string | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Text to speech conversion successful | -| 400 | Bad request - Invalid parameters | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [TextToSpeechPayload](#texttospeechpayload)
| -### /apps/{app_id}/text-to-audio/voices - -#### GET -##### Description - -Get available TTS voices for a specific language - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [TextToSpeechVoiceQuery](#texttospeechvoicequery) | -| app_id | path | App ID | Yes | string | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | TTS voices retrieved successfully | [ object ] | +| 200 | Text to speech conversion successful | **application/json**: [AudioBinaryResponse](#audiobinaryresponse)
| +| 400 | Bad request - Invalid parameters | | + +### [GET] /apps/{app_id}/text-to-audio/voices +Get available TTS voices for a specific language + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | App ID | Yes | string | +| language | query | Language code | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | TTS voices retrieved successfully | **application/json**: [TextToSpeechVoiceListResponse](#texttospeechvoicelistresponse)
| | 400 | Invalid language parameter | | -### /apps/{app_id}/trace - -#### GET -##### Summary - -Get app trace - -##### Description +### [GET] /apps/{app_id}/trace +**Get app trace** Get app tracing configuration -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Trace configuration retrieved successfully | - -#### POST -##### Description - -Update app tracing configuration - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [AppTracePayload](#apptracepayload) | -| app_id | path | Application ID | Yes | string | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Trace configuration updated successfully | [SimpleResultResponse](#simpleresultresponse) | -| 403 | Insufficient permissions | | +| 200 | Trace configuration retrieved successfully | **application/json**: [AppTraceResponse](#apptraceresponse)
| -### /apps/{app_id}/trace-config +### [POST] /apps/{app_id}/trace +Update app tracing configuration -#### DELETE -##### Summary - -Delete an existing trace app configuration - -##### Description - -Delete an existing tracing configuration for an application - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [TraceProviderQuery](#traceproviderquery) | | app_id | path | Application ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AppTracePayload](#apptracepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Trace configuration updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +| 403 | Insufficient permissions | | + +### [DELETE] /apps/{app_id}/trace-config +**Delete an existing trace app configuration** + +Delete an existing tracing configuration for an application + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| tracing_provider | query | Tracing provider name | Yes | string | + +#### Responses | Code | Description | | ---- | ----------- | | 204 | Tracing configuration deleted successfully | | 400 | Invalid request parameters or configuration not found | -#### GET -##### Description - +### [GET] /apps/{app_id}/trace-config Get tracing configuration for an application -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [TraceProviderQuery](#traceproviderquery) | | app_id | path | Application ID | Yes | string | +| tracing_provider | query | Tracing provider name | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Tracing configuration retrieved successfully | object | +| 200 | Tracing configuration retrieved successfully | **application/json**: [TraceAppConfigResponse](#traceappconfigresponse)
| | 400 | Invalid request parameters | | -#### PATCH -##### Summary - -Update an existing trace app configuration - -##### Description +### [PATCH] /apps/{app_id}/trace-config +**Update an existing trace app configuration** Update an existing tracing configuration for an application -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [TraceConfigPayload](#traceconfigpayload) | | app_id | path | Application ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [TraceConfigPayload](#traceconfigpayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Tracing configuration updated successfully | object | +| 200 | Tracing configuration updated successfully | **application/json**: [TraceAppConfigResponse](#traceappconfigresponse)
| | 400 | Invalid request parameters or configuration not found | | -#### POST -##### Summary - -Create a new trace app configuration - -##### Description +### [POST] /apps/{app_id}/trace-config +**Create a new trace app configuration** Create a new tracing configuration for an application -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [TraceConfigPayload](#traceconfigpayload) | | app_id | path | Application ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [TraceConfigPayload](#traceconfigpayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 201 | Tracing configuration created successfully | object | +| 201 | Tracing configuration created successfully | **application/json**: [TraceAppConfigResponse](#traceappconfigresponse)
| | 400 | Invalid request parameters or configuration already exists | | -### /apps/{app_id}/trigger-enable +### [POST] /apps/{app_id}/trigger-enable +**Update app trigger (enable/disable)** -#### POST -##### Summary - -Update app trigger (enable/disable) - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | -| payload | body | | Yes | [ParserEnable](#parserenable) | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | [WorkflowTriggerResponse](#workflowtriggerresponse) | - -### /apps/{app_id}/triggers - -#### GET -##### Summary - -Get app triggers list - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ParserEnable](#parserenable)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [WorkflowTriggerListResponse](#workflowtriggerlistresponse) | +| 200 | Success | **application/json**: [WorkflowTriggerResponse](#workflowtriggerresponse)
| -### /apps/{app_id}/workflow-app-logs +### [GET] /apps/{app_id}/triggers +**Get app triggers list** -#### GET -##### Summary +#### Parameters -Get workflow app logs +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | -##### Description +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [WorkflowTriggerListResponse](#workflowtriggerlistresponse)
| + +### [GET] /apps/{app_id}/workflow-app-logs +**Get workflow app logs** Get workflow application execution logs -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [WorkflowAppLogQuery](#workflowapplogquery) | | app_id | path | Application ID | Yes | string | +| created_at__after | query | Filter logs created after this timestamp | No | dateTime | +| created_at__before | query | Filter logs created before this timestamp | No | dateTime | +| created_by_account | query | Filter by account | No | string | +| created_by_end_user_session_id | query | Filter by end user session ID | No | string | +| detail | query | Whether to return detailed logs | No | boolean | +| keyword | query | Search keyword for filtering logs | No | string | +| limit | query | Number of items per page (1-100) | No | integer,
**Default:** 20 | +| page | query | Page number (1-99999) | No | integer,
**Default:** 1 | +| status | query | Execution status filter (succeeded, failed, stopped, partial-succeeded) | No | string,
**Available values:** "failed", "partial-succeeded", "paused", "running", "scheduled", "stopped", "succeeded" | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow app logs retrieved successfully | [WorkflowAppLogPaginationResponse](#workflowapplogpaginationresponse) | +| 200 | Workflow app logs retrieved successfully | **application/json**: [WorkflowAppLogPaginationResponse](#workflowapplogpaginationresponse)
| -### /apps/{app_id}/workflow-archived-logs - -#### GET -##### Summary - -Get workflow archived logs - -##### Description +### [GET] /apps/{app_id}/workflow-archived-logs +**Get workflow archived logs** Get workflow archived execution logs -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [WorkflowAppLogQuery](#workflowapplogquery) | | app_id | path | Application ID | Yes | string | +| created_at__after | query | Filter logs created after this timestamp | No | dateTime | +| created_at__before | query | Filter logs created before this timestamp | No | dateTime | +| created_by_account | query | Filter by account | No | string | +| created_by_end_user_session_id | query | Filter by end user session ID | No | string | +| detail | query | Whether to return detailed logs | No | boolean | +| keyword | query | Search keyword for filtering logs | No | string | +| limit | query | Number of items per page (1-100) | No | integer,
**Default:** 20 | +| page | query | Page number (1-99999) | No | integer,
**Default:** 1 | +| status | query | Execution status filter (succeeded, failed, stopped, partial-succeeded) | No | string,
**Available values:** "failed", "partial-succeeded", "paused", "running", "scheduled", "stopped", "succeeded" | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow archived logs retrieved successfully | [WorkflowArchivedLogPaginationResponse](#workflowarchivedlogpaginationresponse) | +| 200 | Workflow archived logs retrieved successfully | **application/json**: [WorkflowArchivedLogPaginationResponse](#workflowarchivedlogpaginationresponse)
| -### /apps/{app_id}/workflow-runs +### [GET] /apps/{app_id}/workflow-runs +**Get workflow run list** -#### GET -##### Summary - -Get workflow run list - -##### Description - -Get workflow run list - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | | last_id | query | Last run ID for pagination | No | string | -| limit | query | Number of items per page (1-100) | No | integer | -| status | query | Workflow run status filter | No | string | -| triggered_from | query | Filter by trigger source: debugging or app-run. Default: debugging | No | string | +| limit | query | Number of items per page (1-100) | No | integer,
**Default:** 20 | +| status | query | Workflow run status filter | No | string,
**Available values:** "failed", "partial-succeeded", "running", "stopped", "succeeded" | +| triggered_from | query | Filter by trigger source: debugging or app-run. Default: debugging | No | string,
**Available values:** "app-run", "debugging" | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow runs retrieved successfully | [WorkflowRunPaginationResponse](#workflowrunpaginationresponse) | +| 200 | Workflow runs retrieved successfully | **application/json**: [WorkflowRunPaginationResponse](#workflowrunpaginationresponse)
| -### /apps/{app_id}/workflow-runs/count +### [GET] /apps/{app_id}/workflow-runs/count +**Get workflow runs count statistics** -#### GET -##### Summary - -Get workflow runs count statistics - -##### Description - -Get workflow runs count statistics - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | -| status | query | Workflow run status filter | No | string | +| status | query | Workflow run status filter | No | string,
**Available values:** "failed", "partial-succeeded", "running", "stopped", "succeeded" | | time_range | query | Filter by time range (optional): e.g., 7d (7 days), 4h (4 hours), 30m (30 minutes), 30s (30 seconds). Filters by created_at field. | No | string | -| triggered_from | query | Filter by trigger source: debugging or app-run. Default: debugging | No | string | +| triggered_from | query | Filter by trigger source: debugging or app-run. Default: debugging | No | string,
**Available values:** "app-run", "debugging" | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow runs count retrieved successfully | [WorkflowRunCountResponse](#workflowruncountresponse) | +| 200 | Workflow runs count retrieved successfully | **application/json**: [WorkflowRunCountResponse](#workflowruncountresponse)
| -### /apps/{app_id}/workflow-runs/tasks/{task_id}/stop - -#### POST -##### Summary - -Stop workflow task - -##### Description +### [POST] /apps/{app_id}/workflow-runs/tasks/{task_id}/stop +**Stop workflow task** Stop running workflow task -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | | task_id | path | Task ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Task stopped successfully | [SimpleResultResponse](#simpleresultresponse) | +| 200 | Task stopped successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| | 403 | Permission denied | | | 404 | Task not found | | -### /apps/{app_id}/workflow-runs/{run_id} +### [GET] /apps/{app_id}/workflow-runs/{run_id} +**Get workflow run detail** -#### GET -##### Summary - -Get workflow run detail - -##### Description - -Get workflow run detail - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | | run_id | path | Workflow run ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow run detail retrieved successfully | [WorkflowRunDetailResponse](#workflowrundetailresponse) | +| 200 | Workflow run detail retrieved successfully | **application/json**: [WorkflowRunDetailResponse](#workflowrundetailresponse)
| | 404 | Workflow run not found | | -### /apps/{app_id}/workflow-runs/{run_id}/export - -#### GET -##### Description - +### [GET] /apps/{app_id}/workflow-runs/{run_id}/export Generate a download URL for an archived workflow run. -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | | run_id | path | Workflow run ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Export URL generated | [WorkflowRunExportResponse](#workflowrunexportresponse) | +| 200 | Export URL generated | **application/json**: [WorkflowRunExportResponse](#workflowrunexportresponse)
| -### /apps/{app_id}/workflow-runs/{run_id}/node-executions +### [GET] /apps/{app_id}/workflow-runs/{run_id}/node-executions +**Get workflow run node execution list** -#### GET -##### Summary - -Get workflow run node execution list - -##### Description - -Get workflow run node execution list - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | | run_id | path | Workflow run ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Node executions retrieved successfully | [WorkflowRunNodeExecutionListResponse](#workflowrunnodeexecutionlistresponse) | +| 200 | Node executions retrieved successfully | **application/json**: [WorkflowRunNodeExecutionListResponse](#workflowrunnodeexecutionlistresponse)
| | 404 | Workflow run not found | | -### /apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/workspace/files +### [GET] /apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files +List a directory in a workflow Agent node sandbox -#### GET -##### Description - -List a directory in a Workflow Agent node's sandbox workspace (read-only) - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | @@ -2752,22 +2935,18 @@ List a directory in a Workflow Agent node's sandbox workspace (read-only) | node_id | path | Workflow Agent node ID | Yes | string | | workflow_run_id | path | Workflow run ID | Yes | string | | node_execution_id | query | Optional workflow node execution ID. When omitted, the latest active session for the node is used. | No | string | -| path | query | Directory path relative to the sandbox workspace | No | string | +| path | query | Directory path relative to the sandbox workspace | No | string,
**Default:** . | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Listing returned | [WorkspaceListResponse](#workspacelistresponse) | +| 200 | Listing returned | **application/json**: [SandboxListResponse](#sandboxlistresponse)
| -### /apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/workspace/files/download +### [GET] /apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files/read +Read a text/binary preview file in a workflow Agent node sandbox -#### GET -##### Description - -Download a file from a Workflow Agent node's sandbox workspace (read-only) - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | @@ -2777,210 +2956,166 @@ Download a file from a Workflow Agent node's sandbox workspace (read-only) | node_execution_id | query | Optional workflow node execution ID. When omitted, the latest active session for the node is used. | No | string | | path | query | File path relative to the sandbox workspace | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | File bytes | binary | -| 413 | File exceeds the workspace download limit | | +| 200 | Preview returned | **application/json**: [SandboxReadResponse](#sandboxreadresponse)
| -### /apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/workspace/files/preview +### [POST] /apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files/upload +Upload one workflow Agent sandbox file as a Dify ToolFile mapping -#### GET -##### Description - -Preview a text/binary file in a Workflow Agent node's sandbox workspace - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | -| node_id | path | Workflow Agent node ID | Yes | string | -| workflow_run_id | path | Workflow run ID | Yes | string | -| node_execution_id | query | Optional workflow node execution ID. When omitted, the latest active session for the node is used. | No | string | -| path | query | File path relative to the sandbox workspace | Yes | string | +| app_id | path | | Yes | string | +| node_id | path | | Yes | string | +| workflow_run_id | path | | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [WorkflowAgentSandboxUploadPayload](#workflowagentsandboxuploadpayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Preview returned | [WorkspacePreviewResponse](#workspacepreviewresponse) | +| 200 | Uploaded | **application/json**: [SandboxUploadResponse](#sandboxuploadresponse)
| -### /apps/{app_id}/workflow/comments +### [GET] /apps/{app_id}/workflow/comments +**Get all comments for a workflow** -#### GET -##### Summary - -Get all comments for a workflow - -##### Description - -Get all comments for a workflow - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Comments retrieved successfully | [WorkflowCommentBasicList](#workflowcommentbasiclist) | +| 200 | Comments retrieved successfully | **application/json**: [WorkflowCommentBasicList](#workflowcommentbasiclist)
| -#### POST -##### Summary +### [POST] /apps/{app_id}/workflow/comments +**Create a new workflow comment** -Create a new workflow comment - -##### Description - -Create a new workflow comment - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [WorkflowCommentCreatePayload](#workflowcommentcreatepayload) | -| app_id | path | Application ID | Yes | string | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 201 | Comment created successfully | [WorkflowCommentCreate](#workflowcommentcreate) | - -### /apps/{app_id}/workflow/comments/mention-users - -#### GET -##### Summary - -Get all users in current tenant for mentions - -##### Description - -Get all users in current tenant for mentions - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [WorkflowCommentCreatePayload](#workflowcommentcreatepayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Mentionable users retrieved successfully | [WorkflowCommentMentionUsersPayload](#workflowcommentmentionuserspayload) | +| 201 | Comment created successfully | **application/json**: [WorkflowCommentCreate](#workflowcommentcreate)
| -### /apps/{app_id}/workflow/comments/{comment_id} +### [GET] /apps/{app_id}/workflow/comments/mention-users +**Get all users in current tenant for mentions** -#### DELETE -##### Summary +#### Parameters -Delete a workflow comment +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | -##### Description +#### Responses -Delete a workflow comment +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Mentionable users retrieved successfully | **application/json**: [WorkflowCommentMentionUsersPayload](#workflowcommentmentionuserspayload)
| -##### Parameters +### [DELETE] /apps/{app_id}/workflow/comments/{comment_id} +**Delete a workflow comment** + +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | | comment_id | path | Comment ID | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | | 204 | Comment deleted successfully | -#### GET -##### Summary +### [GET] /apps/{app_id}/workflow/comments/{comment_id} +**Get a specific workflow comment** -Get a specific workflow comment - -##### Description - -Get a specific workflow comment - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | | comment_id | path | Comment ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Comment retrieved successfully | [WorkflowCommentDetail](#workflowcommentdetail) | +| 200 | Comment retrieved successfully | **application/json**: [WorkflowCommentDetail](#workflowcommentdetail)
| -#### PUT -##### Summary +### [PUT] /apps/{app_id}/workflow/comments/{comment_id} +**Update a workflow comment** -Update a workflow comment - -##### Description - -Update a workflow comment - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [WorkflowCommentUpdatePayload](#workflowcommentupdatepayload) | | app_id | path | Application ID | Yes | string | | comment_id | path | Comment ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [WorkflowCommentUpdatePayload](#workflowcommentupdatepayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Comment updated successfully | [WorkflowCommentUpdate](#workflowcommentupdate) | +| 200 | Comment updated successfully | **application/json**: [WorkflowCommentUpdate](#workflowcommentupdate)
| -### /apps/{app_id}/workflow/comments/{comment_id}/replies +### [POST] /apps/{app_id}/workflow/comments/{comment_id}/replies +**Add a reply to a workflow comment** -#### POST -##### Summary - -Add a reply to a workflow comment - -##### Description - -Add a reply to a workflow comment - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [WorkflowCommentReplyPayload](#workflowcommentreplypayload) | | app_id | path | Application ID | Yes | string | | comment_id | path | Comment ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [WorkflowCommentReplyPayload](#workflowcommentreplypayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 201 | Reply created successfully | [WorkflowCommentReplyCreate](#workflowcommentreplycreate) | +| 201 | Reply created successfully | **application/json**: [WorkflowCommentReplyCreate](#workflowcommentreplycreate)
| -### /apps/{app_id}/workflow/comments/{comment_id}/replies/{reply_id} +### [DELETE] /apps/{app_id}/workflow/comments/{comment_id}/replies/{reply_id} +**Delete a comment reply** -#### DELETE -##### Summary - -Delete a comment reply - -##### Description - -Delete a comment reply - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | @@ -2988,776 +3123,697 @@ Delete a comment reply | comment_id | path | Comment ID | Yes | string | | reply_id | path | Reply ID | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | | 204 | Reply deleted successfully | -#### PUT -##### Summary +### [PUT] /apps/{app_id}/workflow/comments/{comment_id}/replies/{reply_id} +**Update a comment reply** -Update a comment reply - -##### Description - -Update a comment reply - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [WorkflowCommentReplyPayload](#workflowcommentreplypayload) | | app_id | path | Application ID | Yes | string | | comment_id | path | Comment ID | Yes | string | | reply_id | path | Reply ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [WorkflowCommentReplyPayload](#workflowcommentreplypayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Reply updated successfully | [WorkflowCommentReplyUpdate](#workflowcommentreplyupdate) | +| 200 | Reply updated successfully | **application/json**: [WorkflowCommentReplyUpdate](#workflowcommentreplyupdate)
| -### /apps/{app_id}/workflow/comments/{comment_id}/resolve +### [POST] /apps/{app_id}/workflow/comments/{comment_id}/resolve +**Resolve a workflow comment** -#### POST -##### Summary - -Resolve a workflow comment - -##### Description - -Resolve a workflow comment - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | | comment_id | path | Comment ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Comment resolved successfully | [WorkflowCommentResolve](#workflowcommentresolve) | - -### /apps/{app_id}/workflow/statistics/average-app-interactions - -#### GET -##### Description +| 200 | Comment resolved successfully | **application/json**: [WorkflowCommentResolve](#workflowcommentresolve)
| +### [GET] /apps/{app_id}/workflow/statistics/average-app-interactions Get workflow average app interaction statistics -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [WorkflowStatisticQuery](#workflowstatisticquery) | | app_id | path | Application ID | Yes | string | +| end | query | End date and time (YYYY-MM-DD HH:MM) | No | string | +| start | query | Start date and time (YYYY-MM-DD HH:MM) | No | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Average app interaction statistics retrieved successfully | - -### /apps/{app_id}/workflow/statistics/daily-conversations - -#### GET -##### Description +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Average app interaction statistics retrieved successfully | **application/json**: [WorkflowAverageAppInteractionStatisticResponse](#workflowaverageappinteractionstatisticresponse)
| +### [GET] /apps/{app_id}/workflow/statistics/daily-conversations Get workflow daily runs statistics -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [WorkflowStatisticQuery](#workflowstatisticquery) | | app_id | path | Application ID | Yes | string | +| end | query | End date and time (YYYY-MM-DD HH:MM) | No | string | +| start | query | Start date and time (YYYY-MM-DD HH:MM) | No | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Daily runs statistics retrieved successfully | - -### /apps/{app_id}/workflow/statistics/daily-terminals - -#### GET -##### Description +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Daily runs statistics retrieved successfully | **application/json**: [WorkflowDailyRunsStatisticResponse](#workflowdailyrunsstatisticresponse)
| +### [GET] /apps/{app_id}/workflow/statistics/daily-terminals Get workflow daily terminals statistics -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [WorkflowStatisticQuery](#workflowstatisticquery) | | app_id | path | Application ID | Yes | string | +| end | query | End date and time (YYYY-MM-DD HH:MM) | No | string | +| start | query | Start date and time (YYYY-MM-DD HH:MM) | No | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Daily terminals statistics retrieved successfully | - -### /apps/{app_id}/workflow/statistics/token-costs - -#### GET -##### Description +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Daily terminals statistics retrieved successfully | **application/json**: [WorkflowDailyTerminalsStatisticResponse](#workflowdailyterminalsstatisticresponse)
| +### [GET] /apps/{app_id}/workflow/statistics/token-costs Get workflow daily token cost statistics -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [WorkflowStatisticQuery](#workflowstatisticquery) | | app_id | path | Application ID | Yes | string | +| end | query | End date and time (YYYY-MM-DD HH:MM) | No | string | +| start | query | Start date and time (YYYY-MM-DD HH:MM) | No | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Daily token cost statistics retrieved successfully | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Daily token cost statistics retrieved successfully | **application/json**: [WorkflowDailyTokenCostStatisticResponse](#workflowdailytokencoststatisticresponse)
| -### /apps/{app_id}/workflows - -#### GET -##### Summary - -Get published workflows - -##### Description +### [GET] /apps/{app_id}/workflows +**Get published workflows** Get all published workflows for an application -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [WorkflowListQuery](#workflowlistquery) | | app_id | path | Application ID | Yes | string | +| limit | query | | No | integer,
**Default:** 10 | +| named_only | query | | No | boolean | +| page | query | | No | integer,
**Default:** 1 | +| user_id | query | | No | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Published workflows retrieved successfully | [WorkflowPaginationResponse](#workflowpaginationresponse) | +| 200 | Published workflows retrieved successfully | **application/json**: [WorkflowPaginationResponse](#workflowpaginationresponse)
| -### /apps/{app_id}/workflows/default-workflow-block-configs - -#### GET -##### Summary - -Get default block config - -##### Description +### [GET] /apps/{app_id}/workflows/default-workflow-block-configs +**Get default block config** Get default block configurations for workflow -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Default block configurations retrieved successfully | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Default block configurations retrieved successfully | **application/json**: [DefaultBlockConfigsResponse](#defaultblockconfigsresponse)
| -### /apps/{app_id}/workflows/default-workflow-block-configs/{block_type} - -#### GET -##### Summary - -Get default block config - -##### Description +### [GET] /apps/{app_id}/workflows/default-workflow-block-configs/{block_type} +**Get default block config** Get default block configuration by type -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [DefaultBlockConfigQuery](#defaultblockconfigquery) | | app_id | path | Application ID | Yes | string | | block_type | path | Block type | Yes | string | +| q | query | | No | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Default block configuration retrieved successfully | -| 404 | Block type not found | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Default block configuration retrieved successfully | **application/json**: [DefaultBlockConfigResponse](#defaultblockconfigresponse)
| +| 404 | Block type not found | | -### /apps/{app_id}/workflows/draft - -#### GET -##### Summary - -Get draft workflow - -##### Description +### [GET] /apps/{app_id}/workflows/draft +**Get draft workflow** Get draft workflow for an application -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Draft workflow retrieved successfully | [WorkflowResponse](#workflowresponse) | +| 200 | Draft workflow retrieved successfully | **application/json**: [WorkflowResponse](#workflowresponse)
| | 404 | Draft workflow not found | | -#### POST -##### Summary - -Sync draft workflow - -##### Description +### [POST] /apps/{app_id}/workflows/draft +**Sync draft workflow** Sync draft workflow configuration -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [SyncDraftWorkflowPayload](#syncdraftworkflowpayload) | | app_id | path | | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [SyncDraftWorkflowPayload](#syncdraftworkflowpayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Draft workflow synced successfully | [SyncDraftWorkflowResponse](#syncdraftworkflowresponse) | +| 200 | Draft workflow synced successfully | **application/json**: [SyncDraftWorkflowResponse](#syncdraftworkflowresponse)
| | 400 | Invalid workflow configuration | | | 403 | Permission denied | | -### /apps/{app_id}/workflows/draft/conversation-variables - -#### GET -##### Description - +### [GET] /apps/{app_id}/workflows/draft/conversation-variables Get conversation variables for workflow -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Conversation variables retrieved successfully | [WorkflowDraftVariableList](#workflowdraftvariablelist) | +| 200 | Conversation variables retrieved successfully | **application/json**: [WorkflowDraftVariableList](#workflowdraftvariablelist)
| | 404 | Draft workflow not found | | -#### POST -##### Description - +### [POST] /apps/{app_id}/workflows/draft/conversation-variables Update conversation variables for workflow draft -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ConversationVariableUpdatePayload](#conversationvariableupdatepayload) | | app_id | path | Application ID | Yes | string | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Conversation variables updated successfully | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ConversationVariableUpdatePayload](#conversationvariableupdatepayload)
| -### /apps/{app_id}/workflows/draft/environment-variables +#### Responses -#### GET -##### Summary +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Conversation variables updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| -Get draft workflow - -##### Description +### [GET] /apps/{app_id}/workflows/draft/environment-variables +**Get draft workflow** Get environment variables for workflow -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Environment variables retrieved successfully | -| 404 | Draft workflow not found | - -#### POST -##### Description - -Update environment variables for workflow draft - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [EnvironmentVariableUpdatePayload](#environmentvariableupdatepayload) | -| app_id | path | Application ID | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Environment variables updated successfully | - -### /apps/{app_id}/workflows/draft/features - -#### POST -##### Description - -Update draft workflow features - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [WorkflowFeaturesPayload](#workflowfeaturespayload) | -| app_id | path | Application ID | Yes | string | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow features updated successfully | [SimpleResultResponse](#simpleresultresponse) | +| 200 | Environment variables retrieved successfully | **application/json**: [EnvironmentVariableListResponse](#environmentvariablelistresponse)
| +| 404 | Draft workflow not found | | -### /apps/{app_id}/workflows/draft/human-input/nodes/{node_id}/delivery-test +### [POST] /apps/{app_id}/workflows/draft/environment-variables +Update environment variables for workflow draft -#### POST -##### Summary +#### Parameters -Test human input delivery +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | -##### Description +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [EnvironmentVariableUpdatePayload](#environmentvariableupdatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Environment variables updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| + +### [POST] /apps/{app_id}/workflows/draft/features +Update draft workflow features + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [WorkflowFeaturesPayload](#workflowfeaturespayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow features updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| + +### [POST] /apps/{app_id}/workflows/draft/human-input/nodes/{node_id}/delivery-test +**Test human input delivery** Test human input delivery for workflow -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [HumanInputDeliveryTestPayload](#humaninputdeliverytestpayload) | | app_id | path | Application ID | Yes | string | | node_id | path | Node ID | Yes | string | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [HumanInputDeliveryTestPayload](#humaninputdeliverytestpayload)
| -### /apps/{app_id}/workflows/draft/human-input/nodes/{node_id}/form/preview +#### Responses -#### POST -##### Summary +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Human input delivery test result | **application/json**: [EmptyObjectResponse](#emptyobjectresponse)
| -Preview human input form content and placeholders - -##### Description +### [POST] /apps/{app_id}/workflows/draft/human-input/nodes/{node_id}/form/preview +**Preview human input form content and placeholders** Get human input form preview for workflow -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [HumanInputFormPreviewPayload](#humaninputformpreviewpayload) | | app_id | path | Application ID | Yes | string | | node_id | path | Node ID | Yes | string | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [HumanInputFormPreviewPayload](#humaninputformpreviewpayload)
| -### /apps/{app_id}/workflows/draft/human-input/nodes/{node_id}/form/run +#### Responses -#### POST -##### Summary +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Human input form preview | **application/json**: [HumanInputFormPreviewResponse](#humaninputformpreviewresponse)
| -Submit human input form preview - -##### Description +### [POST] /apps/{app_id}/workflows/draft/human-input/nodes/{node_id}/form/run +**Submit human input form preview** Submit human input form preview for workflow -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [HumanInputFormSubmitPayload](#humaninputformsubmitpayload) | -| app_id | path | Application ID | Yes | string | -| node_id | path | Node ID | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /apps/{app_id}/workflows/draft/iteration/nodes/{node_id}/run - -#### POST -##### Summary - -Run draft workflow iteration node - -##### Description - -Run draft workflow iteration node - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [IterationNodeRunPayload](#iterationnoderunpayload) | -| app_id | path | Application ID | Yes | string | -| node_id | path | Node ID | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Workflow iteration node run started successfully | -| 403 | Permission denied | -| 404 | Node not found | - -### /apps/{app_id}/workflows/draft/loop/nodes/{node_id}/run - -#### POST -##### Summary - -Run draft workflow loop node - -##### Description - -Run draft workflow loop node - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [LoopNodeRunPayload](#loopnoderunpayload) | -| app_id | path | Application ID | Yes | string | -| node_id | path | Node ID | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Workflow loop node run started successfully | -| 403 | Permission denied | -| 404 | Node not found | - -### /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer - -#### GET -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | -| node_id | path | | Yes | string | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Workflow agent composer state | [WorkflowAgentComposerResponse](#workflowagentcomposerresponse) | - -#### PUT -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | -| node_id | path | | Yes | string | -| payload | body | | Yes | [ComposerSavePayload](#composersavepayload) | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Workflow agent composer saved | [WorkflowAgentComposerResponse](#workflowagentcomposerresponse) | - -### /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/candidates - -#### GET -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | -| node_id | path | | Yes | string | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Workflow agent composer candidates | [AgentComposerCandidatesResponse](#agentcomposercandidatesresponse) | - -### /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/impact - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | -| node_id | path | | Yes | string | -| payload | body | | Yes | [ComposerSavePayload](#composersavepayload) | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Workflow agent composer impact | [AgentComposerImpactResponse](#agentcomposerimpactresponse) | - -### /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/save-to-roster - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | -| node_id | path | | Yes | string | -| payload | body | | Yes | [ComposerSavePayload](#composersavepayload) | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Workflow agent composer saved to roster | [WorkflowAgentComposerResponse](#workflowagentcomposerresponse) | - -### /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/validate - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | -| node_id | path | | Yes | string | -| payload | body | | Yes | [ComposerSavePayload](#composersavepayload) | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Workflow agent composer validation result | [AgentComposerValidateResponse](#agentcomposervalidateresponse) | - -### /apps/{app_id}/workflows/draft/nodes/{node_id}/last-run - -#### GET -##### Description - -Get last run result for draft workflow node - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | | node_id | path | Node ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [HumanInputFormSubmitPayload](#humaninputformsubmitpayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Node last run retrieved successfully | [WorkflowRunNodeExecutionResponse](#workflowrunnodeexecutionresponse) | -| 403 | Permission denied | | -| 404 | Node last run not found | | +| 200 | Human input form submission result | **application/json**: [HumanInputFormSubmitResponse](#humaninputformsubmitresponse)
| -### /apps/{app_id}/workflows/draft/nodes/{node_id}/run +### [POST] /apps/{app_id}/workflows/draft/iteration/nodes/{node_id}/run +**Run draft workflow iteration node** -#### POST -##### Summary - -Run draft workflow node - -##### Description - -Run draft workflow node - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [DraftWorkflowNodeRunPayload](#draftworkflownoderunpayload) | | app_id | path | Application ID | Yes | string | | node_id | path | Node ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [IterationNodeRunPayload](#iterationnoderunpayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Node run started successfully | [WorkflowRunNodeExecutionResponse](#workflowrunnodeexecutionresponse) | +| 200 | Workflow iteration node run started successfully | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| | 403 | Permission denied | | | 404 | Node not found | | -### /apps/{app_id}/workflows/draft/nodes/{node_id}/trigger/run +### [POST] /apps/{app_id}/workflows/draft/loop/nodes/{node_id}/run +**Run draft workflow loop node** -#### POST -##### Summary - -Poll for trigger events and execute single node when event arrives - -##### Description - -Poll for trigger events and execute single node when event arrives - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | | node_id | path | Node ID | Yes | string | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Trigger event received and node executed successfully | -| 403 | Permission denied | -| 500 | Internal server error | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [LoopNodeRunPayload](#loopnoderunpayload)
| -### /apps/{app_id}/workflows/draft/nodes/{node_id}/variables +#### Responses -#### DELETE -##### Description +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow loop node run started successfully | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| +| 403 | Permission denied | | +| 404 | Node not found | | -Delete all variables for a specific node - -##### Parameters +### [GET] /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | | Yes | string | | node_id | path | | Yes | string | -##### Responses +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow agent composer state | **application/json**: [WorkflowAgentComposerResponse](#workflowagentcomposerresponse)
| + +### [PUT] /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| node_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ComposerSavePayload](#composersavepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow agent composer saved | **application/json**: [WorkflowAgentComposerResponse](#workflowagentcomposerresponse)
| + +### [GET] /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/candidates +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| node_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow agent composer candidates | **application/json**: [AgentComposerCandidatesResponse](#agentcomposercandidatesresponse)
| + +### [POST] /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/impact +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| node_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ComposerSavePayload](#composersavepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow agent composer impact | **application/json**: [AgentComposerImpactResponse](#agentcomposerimpactresponse)
| + +### [POST] /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/save-to-roster +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| node_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ComposerSavePayload](#composersavepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow agent composer saved to roster | **application/json**: [WorkflowAgentComposerResponse](#workflowagentcomposerresponse)
| + +### [POST] /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/validate +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| node_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ComposerSavePayload](#composersavepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow agent composer validation result | **application/json**: [AgentComposerValidateResponse](#agentcomposervalidateresponse)
| + +### [GET] /apps/{app_id}/workflows/draft/nodes/{node_id}/last-run +Get last run result for draft workflow node + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| node_id | path | Node ID | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Node last run retrieved successfully | **application/json**: [WorkflowRunNodeExecutionResponse](#workflowrunnodeexecutionresponse)
| +| 403 | Permission denied | | +| 404 | Node last run not found | | + +### [POST] /apps/{app_id}/workflows/draft/nodes/{node_id}/run +**Run draft workflow node** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| node_id | path | Node ID | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DraftWorkflowNodeRunPayload](#draftworkflownoderunpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Node run started successfully | **application/json**: [WorkflowRunNodeExecutionResponse](#workflowrunnodeexecutionresponse)
| +| 403 | Permission denied | | +| 404 | Node not found | | + +### [POST] /apps/{app_id}/workflows/draft/nodes/{node_id}/trigger/run +**Poll for trigger events and execute single node when event arrives** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| node_id | path | Node ID | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Trigger event received and node executed successfully | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| +| 403 | Permission denied | | +| 500 | Internal server error | | + +### [DELETE] /apps/{app_id}/workflows/draft/nodes/{node_id}/variables +Delete all variables for a specific node + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| node_id | path | | Yes | string | + +#### Responses | Code | Description | | ---- | ----------- | | 204 | Node variables deleted successfully | -#### GET -##### Description - +### [GET] /apps/{app_id}/workflows/draft/nodes/{node_id}/variables Get variables for a specific node -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | | node_id | path | Node ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Node variables retrieved successfully | [WorkflowDraftVariableList](#workflowdraftvariablelist) | +| 200 | Node variables retrieved successfully | **application/json**: [WorkflowDraftVariableList](#workflowdraftvariablelist)
| -### /apps/{app_id}/workflows/draft/run +### [POST] /apps/{app_id}/workflows/draft/run +**Run draft workflow** -#### POST -##### Summary - -Run draft workflow - -##### Description - -Run draft workflow - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [DraftWorkflowRunPayload](#draftworkflowrunpayload) | | app_id | path | Application ID | Yes | string | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Draft workflow run started successfully | -| 403 | Permission denied | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DraftWorkflowRunPayload](#draftworkflowrunpayload)
| -### /apps/{app_id}/workflows/draft/runs/{run_id}/node-outputs +#### Responses -#### GET -##### Description +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Draft workflow run started successfully | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| +| 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. -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | | run_id | path | Workflow run ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow run node outputs | [WorkflowRunSnapshotView](#workflowrunsnapshotview) | +| 200 | Workflow run node outputs | **application/json**: [WorkflowRunSnapshotView](#workflowrunsnapshotview)
| | 404 | Workflow run not found | | -### /apps/{app_id}/workflows/draft/runs/{run_id}/node-outputs/events - -#### GET -##### Description - +### [GET] /apps/{app_id}/workflows/draft/runs/{run_id}/node-outputs/events Server-Sent Events stream of inspector deltas for a draft workflow run. -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | | run_id | path | Workflow run ID | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Workflow run node output event stream | -| 404 | Workflow run not found | - -### /apps/{app_id}/workflows/draft/runs/{run_id}/node-outputs/{node_id} - -#### GET -##### Description +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow run node output event stream | **application/json**: [EventStreamResponse](#eventstreamresponse)
| +| 404 | Workflow run not found | | +### [GET] /apps/{app_id}/workflows/draft/runs/{run_id}/node-outputs/{node_id} One node's declared outputs for a draft workflow run. -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | @@ -3765,21 +3821,17 @@ One node's declared outputs for a draft workflow run. | node_id | path | Node ID inside the workflow graph | Yes | string | | run_id | path | Workflow run ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow run node output detail | [NodeOutputsView](#nodeoutputsview) | +| 200 | Workflow run node output detail | **application/json**: [NodeOutputsView](#nodeoutputsview)
| | 404 | Workflow run / node not found | | -### /apps/{app_id}/workflows/draft/runs/{run_id}/node-outputs/{node_id}/{output_name}/preview - -#### GET -##### Description - +### [GET] /apps/{app_id}/workflows/draft/runs/{run_id}/node-outputs/{node_id}/{output_name}/preview Full value for one declared output, including signed download URL for files. -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | @@ -3788,300 +3840,259 @@ Full value for one declared output, including signed download URL for files. | output_name | path | Declared output name as exposed by Composer | Yes | string | | run_id | path | Workflow run ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow run node output preview | [OutputPreviewView](#outputpreviewview) | +| 200 | Workflow run node output preview | **application/json**: [OutputPreviewView](#outputpreviewview)
| | 404 | Workflow run / node / output not found | | -### /apps/{app_id}/workflows/draft/system-variables - -#### GET -##### Description - +### [GET] /apps/{app_id}/workflows/draft/system-variables Get system variables for workflow -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | System variables retrieved successfully | [WorkflowDraftVariableList](#workflowdraftvariablelist) | +| 200 | System variables retrieved successfully | **application/json**: [WorkflowDraftVariableList](#workflowdraftvariablelist)
| -### /apps/{app_id}/workflows/draft/trigger/run +### [POST] /apps/{app_id}/workflows/draft/trigger/run +**Poll for trigger events and execute full workflow when event arrives** -#### POST -##### Summary - -Poll for trigger events and execute full workflow when event arrives - -##### Description - -Poll for trigger events and execute full workflow when event arrives - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [DraftWorkflowTriggerRunRequest](#draftworkflowtriggerrunrequest) | | app_id | path | Application ID | Yes | string | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Trigger event received and workflow executed successfully | -| 403 | Permission denied | -| 500 | Internal server error | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DraftWorkflowTriggerRunRequest](#draftworkflowtriggerrunrequest)
| -### /apps/{app_id}/workflows/draft/trigger/run-all +#### Responses -#### POST -##### Summary +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Trigger event received and workflow executed successfully | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| +| 403 | Permission denied | | +| 500 | Internal server error | | -Full workflow debug when the start node is a trigger +### [POST] /apps/{app_id}/workflows/draft/trigger/run-all +**Full workflow debug when the start node is a trigger** -##### Description - -Full workflow debug when the start node is a trigger - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [DraftWorkflowTriggerRunAllPayload](#draftworkflowtriggerrunallpayload) | | app_id | path | Application ID | Yes | string | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Workflow executed successfully | -| 403 | Permission denied | -| 500 | Internal server error | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DraftWorkflowTriggerRunAllPayload](#draftworkflowtriggerrunallpayload)
| -### /apps/{app_id}/workflows/draft/variables +#### Responses -#### DELETE -##### Description +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow executed successfully | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| +| 403 | Permission denied | | +| 500 | Internal server error | | +### [DELETE] /apps/{app_id}/workflows/draft/variables Delete all draft workflow variables -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | | 204 | Workflow variables deleted successfully | -#### GET -##### Summary - -Get draft workflow - -##### Description +### [GET] /apps/{app_id}/workflows/draft/variables +**Get draft workflow** Get draft workflow variables -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [WorkflowDraftVariableListQuery](#workflowdraftvariablelistquery) | | app_id | path | Application ID | Yes | string | -| limit | query | Number of items per page (1-100) | No | string | -| page | query | Page number (1-100000) | No | string | +| limit | query | Items per page | No | integer,
**Default:** 20 | +| page | query | Page number | No | integer,
**Default:** 1 | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow variables retrieved successfully | [WorkflowDraftVariableListWithoutValue](#workflowdraftvariablelistwithoutvalue) | - -### /apps/{app_id}/workflows/draft/variables/{variable_id} - -#### DELETE -##### Description +| 200 | Workflow variables retrieved successfully | **application/json**: [WorkflowDraftVariableListWithoutValue](#workflowdraftvariablelistwithoutvalue)
| +### [DELETE] /apps/{app_id}/workflows/draft/variables/{variable_id} Delete a workflow variable -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | | Yes | string | | variable_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | | 204 | Variable deleted successfully | | 404 | Variable not found | -#### GET -##### Description - +### [GET] /apps/{app_id}/workflows/draft/variables/{variable_id} Get a specific workflow variable -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | | variable_id | path | Variable ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Variable retrieved successfully | [WorkflowDraftVariable](#workflowdraftvariable) | +| 200 | Variable retrieved successfully | **application/json**: [WorkflowDraftVariable](#workflowdraftvariable)
| | 404 | Variable not found | | -#### PATCH -##### Description - +### [PATCH] /apps/{app_id}/workflows/draft/variables/{variable_id} Update a workflow variable -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [WorkflowDraftVariableUpdatePayload](#workflowdraftvariableupdatepayload) | | app_id | path | | Yes | string | | variable_id | path | | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [WorkflowDraftVariableUpdatePayload](#workflowdraftvariableupdatepayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Variable updated successfully | [WorkflowDraftVariable](#workflowdraftvariable) | +| 200 | Variable updated successfully | **application/json**: [WorkflowDraftVariable](#workflowdraftvariable)
| | 404 | Variable not found | | -### /apps/{app_id}/workflows/draft/variables/{variable_id}/reset - -#### PUT -##### Description - +### [PUT] /apps/{app_id}/workflows/draft/variables/{variable_id}/reset Reset a workflow variable to its default value -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | | variable_id | path | Variable ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Variable reset successfully | [WorkflowDraftVariable](#workflowdraftvariable) | +| 200 | Variable reset successfully | **application/json**: [WorkflowDraftVariable](#workflowdraftvariable)
| | 204 | Variable reset (no content) | | | 404 | Variable not found | | -### /apps/{app_id}/workflows/publish - -#### GET -##### Summary - -Get published workflow - -##### Description +### [GET] /apps/{app_id}/workflows/publish +**Get published workflow** Get published workflow for an application -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Published workflow retrieved successfully, or null if not found | [WorkflowResponse](#workflowresponse) | +| 200 | Published workflow retrieved successfully, or null if not found | **application/json**: [WorkflowResponse](#workflowresponse)
| -#### POST -##### Summary +### [POST] /apps/{app_id}/workflows/publish +**Publish workflow** -Publish workflow - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [PublishWorkflowPayload](#publishworkflowpayload) | | app_id | path | | Yes | string | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [PublishWorkflowPayload](#publishworkflowpayload)
| -### /apps/{app_id}/workflows/published/runs/{run_id}/node-outputs - -#### GET -##### Description - -Snapshot of every node's declared outputs for a published workflow run. - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | -| run_id | path | Workflow run ID | Yes | string | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow run node outputs | [WorkflowRunSnapshotView](#workflowrunsnapshotview) | -| 404 | Workflow run not found | | +| 200 | Workflow published successfully | **application/json**: [WorkflowPublishResponse](#workflowpublishresponse)
| -### /apps/{app_id}/workflows/published/runs/{run_id}/node-outputs/events +### [GET] /apps/{app_id}/workflows/published/runs/{run_id}/node-outputs +Snapshot of every node's declared outputs for a published workflow run. -#### GET -##### Description - -Server-Sent Events stream of inspector deltas for a published workflow run. - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | | run_id | path | Workflow run ID | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Workflow run node output event stream | -| 404 | Workflow run not found | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow run node outputs | **application/json**: [WorkflowRunSnapshotView](#workflowrunsnapshotview)
| +| 404 | Workflow run not found | | -### /apps/{app_id}/workflows/published/runs/{run_id}/node-outputs/{node_id} +### [GET] /apps/{app_id}/workflows/published/runs/{run_id}/node-outputs/events +Server-Sent Events stream of inspector deltas for a published workflow run. -#### GET -##### Description +#### Parameters +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| run_id | path | Workflow run ID | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow run node output event stream | **application/json**: [EventStreamResponse](#eventstreamresponse)
| +| 404 | Workflow run not found | | + +### [GET] /apps/{app_id}/workflows/published/runs/{run_id}/node-outputs/{node_id} One node's declared outputs for a published workflow run. -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | @@ -4089,21 +4100,17 @@ One node's declared outputs for a published workflow run. | node_id | path | Node ID inside the workflow graph | Yes | string | | run_id | path | Workflow run ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow run node output detail | [NodeOutputsView](#nodeoutputsview) | +| 200 | Workflow run node output detail | **application/json**: [NodeOutputsView](#nodeoutputsview)
| | 404 | Workflow run / node not found | | -### /apps/{app_id}/workflows/published/runs/{run_id}/node-outputs/{node_id}/{output_name}/preview - -#### GET -##### Description - +### [GET] /apps/{app_id}/workflows/published/runs/{run_id}/node-outputs/{node_id}/{output_name}/preview Full value for one declared output of a published run. -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | @@ -4112,955 +4119,859 @@ Full value for one declared output of a published run. | output_name | path | Declared output name as exposed by Composer | Yes | string | | run_id | path | Workflow run ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow run node output preview | [OutputPreviewView](#outputpreviewview) | +| 200 | Workflow run node output preview | **application/json**: [OutputPreviewView](#outputpreviewview)
| | 404 | Workflow run / node / output not found | | -### /apps/{app_id}/workflows/triggers/webhook +### [GET] /apps/{app_id}/workflows/triggers/webhook +**Get webhook trigger for a node** -#### GET -##### Summary - -Get webhook trigger for a node - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | +| node_id | query | | Yes | string | | app_id | path | | Yes | string | -| payload | body | | Yes | [Parser](#parser) | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [WebhookTriggerResponse](#webhooktriggerresponse) | +| 200 | Success | **application/json**: [WebhookTriggerResponse](#webhooktriggerresponse)
| -### /apps/{app_id}/workflows/{workflow_id} +### [DELETE] /apps/{app_id}/workflows/{workflow_id} +**Delete workflow** -#### DELETE -##### Summary - -Delete workflow - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | | Yes | string | | workflow_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | -| 200 | Success | +| 204 | Workflow deleted successfully | -#### PATCH -##### Summary - -Update workflow attributes - -##### Description +### [PATCH] /apps/{app_id}/workflows/{workflow_id} +**Update workflow attributes** Update workflow by ID -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [WorkflowUpdatePayload](#workflowupdatepayload) | | app_id | path | Application ID | Yes | string | | workflow_id | path | Workflow ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [WorkflowUpdatePayload](#workflowupdatepayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow updated successfully | [WorkflowResponse](#workflowresponse) | +| 200 | Workflow updated successfully | **application/json**: [WorkflowResponse](#workflowresponse)
| | 403 | Permission denied | | | 404 | Workflow not found | | -### /apps/{app_id}/workflows/{workflow_id}/restore - -#### POST -##### Description - +### [POST] /apps/{app_id}/workflows/{workflow_id}/restore Restore a published workflow version into the draft workflow -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | | workflow_id | path | Published workflow ID | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Workflow restored successfully | -| 400 | Source workflow must be published | -| 404 | Workflow not found | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow restored successfully | **application/json**: [WorkflowRestoreResponse](#workflowrestoreresponse)
| +| 400 | Source workflow must be published | | +| 404 | Workflow not found | | -### /apps/{resource_id}/api-keys +### [GET] /apps/{resource_id}/api-keys +**Get all API keys for an app** -#### GET -##### Summary - -Get all API keys for an app - -##### Description - -Get all API keys for an app - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | resource_id | path | App ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | API keys retrieved successfully | [ApiKeyList](#apikeylist) | +| 200 | API keys retrieved successfully | **application/json**: [ApiKeyList](#apikeylist)
| -#### POST -##### Summary +### [POST] /apps/{resource_id}/api-keys +**Create a new API key for an app** -Create a new API key for an app - -##### Description - -Create a new API key for an app - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | resource_id | path | App ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 201 | API key created successfully | [ApiKeyItem](#apikeyitem) | +| 201 | API key created successfully | **application/json**: [ApiKeyItem](#apikeyitem)
| | 400 | Maximum keys exceeded | | -### /apps/{resource_id}/api-keys/{api_key_id} +### [DELETE] /apps/{resource_id}/api-keys/{api_key_id} +**Delete an API key for an app** -#### DELETE -##### Summary - -Delete an API key for an app - -##### Description - -Delete an API key for an app - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | api_key_id | path | API key ID | Yes | string | | resource_id | path | App ID | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | | 204 | API key deleted successfully | -### /apps/{server_id}/server/refresh - -#### GET -##### Description - +### [GET] /apps/{server_id}/server/refresh Refresh MCP server configuration and regenerate server code -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | server_id | path | Server ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | MCP server refreshed successfully | [AppMCPServerResponse](#appmcpserverresponse) | +| 200 | MCP server refreshed successfully | **application/json**: [AppMCPServerResponse](#appmcpserverresponse)
| | 403 | Insufficient permissions | | | 404 | Server not found | | -### /auth/plugin/datasource/default-list - -#### GET -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /auth/plugin/datasource/list - -#### GET -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /auth/plugin/datasource/{provider_id} - -#### GET -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider_id | path | | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider_id | path | | Yes | string | -| payload | body | | Yes | [DatasourceCredentialPayload](#datasourcecredentialpayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /auth/plugin/datasource/{provider_id}/custom-client - -#### DELETE -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider_id | path | | Yes | string | - -##### Responses +### [GET] /auth/plugin/datasource/default-list +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SimpleResultResponse](#simpleresultresponse) | +| 200 | Success | **application/json**: [DatasourceCredentialsResponse](#datasourcecredentialsresponse)
| -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider_id | path | | Yes | string | -| payload | body | | Yes | [DatasourceCustomClientPayload](#datasourcecustomclientpayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /auth/plugin/datasource/{provider_id}/default - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider_id | path | | Yes | string | -| payload | body | | Yes | [DatasourceDefaultPayload](#datasourcedefaultpayload) | - -##### Responses +### [GET] /auth/plugin/datasource/list +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SimpleResultResponse](#simpleresultresponse) | +| 200 | Success | **application/json**: [DatasourceCredentialsResponse](#datasourcecredentialsresponse)
| -### /auth/plugin/datasource/{provider_id}/delete - -#### POST -##### Parameters +### [GET] /auth/plugin/datasource/{provider_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | provider_id | path | | Yes | string | -| payload | body | | Yes | [DatasourceCredentialDeletePayload](#datasourcecredentialdeletepayload) | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SimpleResultResponse](#simpleresultresponse) | +| 200 | Success | **application/json**: [DatasourceCredentialsResponse](#datasourcecredentialsresponse)
| -### /auth/plugin/datasource/{provider_id}/update - -#### POST -##### Parameters +### [POST] /auth/plugin/datasource/{provider_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | provider_id | path | | Yes | string | -| payload | body | | Yes | [DatasourceCredentialUpdatePayload](#datasourcecredentialupdatepayload) | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DatasourceCredentialPayload](#datasourcecredentialpayload)
| -### /auth/plugin/datasource/{provider_id}/update-name - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider_id | path | | Yes | string | -| payload | body | | Yes | [DatasourceUpdateNamePayload](#datasourceupdatenamepayload) | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SimpleResultResponse](#simpleresultresponse) | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| -### /billing/invoices +### [DELETE] /auth/plugin/datasource/{provider_id}/custom-client +#### Parameters -#### GET -##### Responses +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider_id | path | | Yes | string | -| Code | Description | -| ---- | ----------- | -| 200 | Success | +#### Responses -### /billing/partners/{partner_key}/tenants +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| -#### PUT -##### Description +### [POST] /auth/plugin/datasource/{provider_id}/custom-client +#### Parameters +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DatasourceCustomClientPayload](#datasourcecustomclientpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| + +### [POST] /auth/plugin/datasource/{provider_id}/default +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DatasourceDefaultPayload](#datasourcedefaultpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| + +### [POST] /auth/plugin/datasource/{provider_id}/delete +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DatasourceCredentialDeletePayload](#datasourcecredentialdeletepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| + +### [POST] /auth/plugin/datasource/{provider_id}/update +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DatasourceCredentialUpdatePayload](#datasourcecredentialupdatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| + +### [POST] /auth/plugin/datasource/{provider_id}/update-name +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DatasourceUpdateNamePayload](#datasourceupdatenamepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| + +### [GET] /billing/invoices +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [BillingResponse](#billingresponse)
| + +### [PUT] /billing/partners/{partner_key}/tenants Sync partner tenants bindings -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [PartnerTenantsPayload](#partnertenantspayload) | | partner_key | path | Partner key | Yes | string | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Tenants synced to partner successfully | -| 400 | Invalid partner information | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [PartnerTenantsPayload](#partnertenantspayload)
| -### /billing/subscription +#### Responses -#### GET -##### Responses +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Tenants synced to partner successfully | **application/json**: [BillingResponse](#billingresponse)
| +| 400 | Invalid partner information | | -| Code | Description | -| ---- | ----------- | -| 200 | Success | +### [GET] /billing/subscription +#### Parameters -### /code-based-extension +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| interval | query | Billing interval | Yes | string,
**Available values:** "month", "year" | +| plan | query | Subscription plan | Yes | string,
**Available values:** "professional", "team" | -#### GET -##### Description +#### Responses +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [BillingResponse](#billingresponse)
| + +### [GET] /code-based-extension Get code-based extension data by module name -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| module | query | Extension module name | No | string | +| module | query | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [CodeBasedExtensionResponse](#codebasedextensionresponse) | - -### /compliance/download - -#### GET -##### Description +| 200 | Success | **application/json**: [CodeBasedExtensionResponse](#codebasedextensionresponse)
| +### [GET] /compliance/download Get compliance document download link -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ComplianceDownloadQuery](#compliancedownloadquery) | +| doc_name | query | Compliance document name | Yes | string | -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /data-source/integrates - -#### GET -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [DataSourceIntegrateListResponse](#datasourceintegratelistresponse) | +| 200 | Success | **application/json**: [ComplianceDownloadResponse](#compliancedownloadresponse)
| -#### PATCH -##### Responses +### [GET] /data-source/integrates +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SimpleResultResponse](#simpleresultresponse) | +| 200 | Success | **application/json**: [DataSourceIntegrateListResponse](#datasourceintegratelistresponse)
| -### /data-source/integrates/{binding_id}/{action} +### [PATCH] /data-source/integrates +#### Responses -#### GET -##### Parameters +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| + +### [GET] /data-source/integrates/{binding_id}/{action} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | action | path | | Yes | string | | binding_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [DataSourceIntegrateListResponse](#datasourceintegratelistresponse) | +| 200 | Success | **application/json**: [DataSourceIntegrateListResponse](#datasourceintegratelistresponse)
| -#### PATCH -##### Parameters +### [PATCH] /data-source/integrates/{binding_id}/{action} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | action | path | | Yes | string | | binding_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SimpleResultResponse](#simpleresultresponse) | - -### /datasets - -#### GET -##### Description +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +### [GET] /datasets Get list of datasets -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | ids | query | Filter by dataset IDs | No | [ string ] | | include_all | query | Include all datasets | No | boolean | | keyword | query | Search keyword | No | string | -| limit | query | Number of items per page | No | integer | -| page | query | Page number | No | integer | +| limit | query | Number of items per page | No | integer,
**Default:** 20 | +| page | query | Page number | No | integer,
**Default:** 1 | | tag_ids | query | Filter by tag IDs | No | [ string ] | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Datasets retrieved successfully | [DatasetListResponse](#datasetlistresponse) | - -#### POST -##### Description +| 200 | Datasets retrieved successfully | **application/json**: [DatasetListResponse](#datasetlistresponse)
| +### [POST] /datasets Create a new dataset -##### Parameters +#### Request Body -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [DatasetCreatePayload](#datasetcreatepayload) | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DatasetCreatePayload](#datasetcreatepayload)
| -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 201 | Dataset created successfully | [DatasetDetailResponse](#datasetdetailresponse) | +| 201 | Dataset created successfully | **application/json**: [DatasetDetailResponse](#datasetdetailresponse)
| | 400 | Invalid request parameters | | -### /datasets/api-base-info - -#### GET -##### Description - +### [GET] /datasets/api-base-info Get dataset API base information -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | API base info retrieved successfully | [ApiBaseUrlResponse](#apibaseurlresponse) | - -### /datasets/api-keys - -#### GET -##### Description +| 200 | API base info retrieved successfully | **application/json**: [ApiBaseUrlResponse](#apibaseurlresponse)
| +### [GET] /datasets/api-keys Get dataset API keys -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | API keys retrieved successfully | [ApiKeyList](#apikeylist) | +| 200 | API keys retrieved successfully | **application/json**: [ApiKeyList](#apikeylist)
| -#### POST -##### Responses +### [POST] /datasets/api-keys +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | API key created successfully | [ApiKeyItem](#apikeyitem) | +| 200 | API key created successfully | **application/json**: [ApiKeyItem](#apikeyitem)
| | 400 | Maximum keys exceeded | | -### /datasets/api-keys/{api_key_id} - -#### DELETE -##### Description - +### [DELETE] /datasets/api-keys/{api_key_id} Delete dataset API key -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | api_key_id | path | API key ID | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | | 204 | API key deleted successfully | -### /datasets/batch_import_status/{job_id} - -#### GET -##### Parameters +### [GET] /datasets/batch_import_status/{job_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | job_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Batch import status | [SegmentBatchImportStatusResponse](#segmentbatchimportstatusresponse) | +| 200 | Batch import status | **application/json**: [SegmentBatchImportStatusResponse](#segmentbatchimportstatusresponse)
| -#### POST -##### Parameters +### [POST] /datasets/batch_import_status/{job_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | job_id | path | | Yes | string | -| payload | body | | Yes | [BatchImportPayload](#batchimportpayload) | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [BatchImportPayload](#batchimportpayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Batch import started | [SegmentBatchImportStatusResponse](#segmentbatchimportstatusresponse) | - -### /datasets/external - -#### POST -##### Description +| 200 | Batch import started | **application/json**: [SegmentBatchImportStatusResponse](#segmentbatchimportstatusresponse)
| +### [POST] /datasets/external Create external knowledge dataset -##### Parameters +#### Request Body -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ExternalDatasetCreatePayload](#externaldatasetcreatepayload) | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ExternalDatasetCreatePayload](#externaldatasetcreatepayload)
| -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 201 | External dataset created successfully | [DatasetDetail](#datasetdetail) | +| 201 | External dataset created successfully | **application/json**: [DatasetDetail](#datasetdetail)
| | 400 | Invalid parameters | | | 403 | Permission denied | | -### /datasets/external-knowledge-api - -#### GET -##### Description - +### [GET] /datasets/external-knowledge-api Get external knowledge API templates -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | keyword | query | Search keyword | No | string | -| limit | query | Number of items per page (default: 20) | No | string | -| page | query | Page number (default: 1) | No | string | +| limit | query | Number of items per page | No | integer,
**Default:** 20 | +| page | query | Page number | No | integer,
**Default:** 1 | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | External API templates retrieved successfully | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | External API templates retrieved successfully | **application/json**: [ExternalKnowledgeApiListResponse](#externalknowledgeapilistresponse)
| -#### POST -##### Parameters +### [POST] /datasets/external-knowledge-api +#### Request Body -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ExternalKnowledgeApiPayload](#externalknowledgeapipayload) | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ExternalKnowledgeApiPayload](#externalknowledgeapipayload)
| -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | External API template created successfully | **application/json**: [ExternalKnowledgeApiResponse](#externalknowledgeapiresponse)
| -### /datasets/external-knowledge-api/{external_knowledge_api_id} - -#### DELETE -##### Parameters +### [DELETE] /datasets/external-knowledge-api/{external_knowledge_api_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | external_knowledge_api_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | | 204 | External knowledge API deleted successfully | -#### GET -##### Description - +### [GET] /datasets/external-knowledge-api/{external_knowledge_api_id} Get external knowledge API template details -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | external_knowledge_api_id | path | External knowledge API ID | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | External API template retrieved successfully | -| 404 | Template not found | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | External API template retrieved successfully | **application/json**: [ExternalKnowledgeApiResponse](#externalknowledgeapiresponse)
| +| 404 | Template not found | | -#### PATCH -##### Parameters +### [PATCH] /datasets/external-knowledge-api/{external_knowledge_api_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ExternalKnowledgeApiPayload](#externalknowledgeapipayload) | | external_knowledge_api_id | path | | Yes | string | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ExternalKnowledgeApiPayload](#externalknowledgeapipayload)
| -### /datasets/external-knowledge-api/{external_knowledge_api_id}/use-check +#### Responses -#### GET -##### Description +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | External API template updated successfully | **application/json**: [ExternalKnowledgeApiResponse](#externalknowledgeapiresponse)
| +### [GET] /datasets/external-knowledge-api/{external_knowledge_api_id}/use-check Check if external knowledge API is being used -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | external_knowledge_api_id | path | External knowledge API ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Usage check completed successfully | [UsageCountResponse](#usagecountresponse) | - -### /datasets/indexing-estimate - -#### POST -##### Description +| 200 | Usage check completed successfully | **application/json**: [UsageCountResponse](#usagecountresponse)
| +### [POST] /datasets/indexing-estimate Estimate dataset indexing cost -##### Parameters +#### Request Body -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [IndexingEstimatePayload](#indexingestimatepayload) | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [IndexingEstimatePayload](#indexingestimatepayload)
| -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Indexing estimate calculated successfully | [IndexingEstimateResponse](#indexingestimateresponse) | - -### /datasets/init - -#### POST -##### Description +| 200 | Indexing estimate calculated successfully | **application/json**: [IndexingEstimateResponse](#indexingestimateresponse)
| +### [POST] /datasets/init Initialize dataset with documents -##### Parameters +#### Request Body -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [KnowledgeConfig](#knowledgeconfig) | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [KnowledgeConfig](#knowledgeconfig)
| -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 201 | Dataset initialized successfully | [DatasetAndDocumentResponse](#datasetanddocumentresponse) | +| 201 | Dataset initialized successfully | **application/json**: [DatasetAndDocumentResponse](#datasetanddocumentresponse)
| | 400 | Invalid request parameters | | -### /datasets/metadata/built-in - -#### GET -##### Responses +### [GET] /datasets/metadata/built-in +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Built-in fields retrieved successfully | [DatasetMetadataBuiltInFieldsResponse](#datasetmetadatabuiltinfieldsresponse) | +| 200 | Built-in fields retrieved successfully | **application/json**: [DatasetMetadataBuiltInFieldsResponse](#datasetmetadatabuiltinfieldsresponse)
| -### /datasets/notion-indexing-estimate +### [POST] /datasets/notion-indexing-estimate +#### Request Body -#### POST -##### Parameters +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [NotionEstimatePayload](#notionestimatepayload)
| -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [NotionEstimatePayload](#notionestimatepayload) | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [IndexingEstimate](#indexingestimate) | - -### /datasets/process-rule - -#### GET -##### Description +| 200 | Success | **application/json**: [IndexingEstimate](#indexingestimate)
| +### [GET] /datasets/process-rule Get dataset document processing rules -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | document_id | query | Document ID (optional) | No | string | -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Process rules retrieved successfully | - -### /datasets/retrieval-setting - -#### GET -##### Description - -Get dataset retrieval settings - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Retrieval settings retrieved successfully | [RetrievalSettingResponse](#retrievalsettingresponse) | +| 200 | Process rules retrieved successfully | **application/json**: [OpaqueObjectResponse](#opaqueobjectresponse)
| -### /datasets/retrieval-setting/{vector_type} +### [GET] /datasets/retrieval-setting +Get dataset retrieval settings -#### GET -##### Description +#### Responses +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Retrieval settings retrieved successfully | **application/json**: [RetrievalSettingResponse](#retrievalsettingresponse)
| + +### [GET] /datasets/retrieval-setting/{vector_type} Get mock dataset retrieval settings by vector type -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | vector_type | path | Vector store type | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Mock retrieval settings retrieved successfully | [RetrievalSettingResponse](#retrievalsettingresponse) | +| 200 | Mock retrieval settings retrieved successfully | **application/json**: [RetrievalSettingResponse](#retrievalsettingresponse)
| -### /datasets/{dataset_id} - -#### DELETE -##### Parameters +### [DELETE] /datasets/{dataset_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | | 204 | Dataset deleted successfully | -#### GET -##### Description - +### [GET] /datasets/{dataset_id} Get dataset details -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | Dataset ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Dataset retrieved successfully | [DatasetDetailWithPartialMembersResponse](#datasetdetailwithpartialmembersresponse) | +| 200 | Dataset retrieved successfully | **application/json**: [DatasetDetailWithPartialMembersResponse](#datasetdetailwithpartialmembersresponse)
| | 403 | Permission denied | | | 404 | Dataset not found | | -#### PATCH -##### Description - +### [PATCH] /datasets/{dataset_id} Update dataset details -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [DatasetUpdatePayload](#datasetupdatepayload) | | dataset_id | path | | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DatasetUpdatePayload](#datasetupdatepayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Dataset updated successfully | [DatasetDetailWithPartialMembersResponse](#datasetdetailwithpartialmembersresponse) | +| 200 | Dataset updated successfully | **application/json**: [DatasetDetailWithPartialMembersResponse](#datasetdetailwithpartialmembersresponse)
| | 403 | Permission denied | | | 404 | Dataset not found | | -### /datasets/{dataset_id}/api-keys/{status} - -#### POST -##### Parameters +### [POST] /datasets/{dataset_id}/api-keys/{status} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | | Yes | string | | status | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SimpleResultResponse](#simpleresultresponse) | - -### /datasets/{dataset_id}/auto-disable-logs - -#### GET -##### Description +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +### [GET] /datasets/{dataset_id}/auto-disable-logs Get dataset auto disable logs -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | Dataset ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Auto disable logs retrieved successfully | [AutoDisableLogsResponse](#autodisablelogsresponse) | +| 200 | Auto disable logs retrieved successfully | **application/json**: [AutoDisableLogsResponse](#autodisablelogsresponse)
| | 404 | Dataset not found | | -### /datasets/{dataset_id}/batch/{batch}/indexing-estimate - -#### GET -##### Parameters +### [GET] /datasets/{dataset_id}/batch/{batch}/indexing-estimate +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | batch | path | | Yes | string | | dataset_id | path | | Yes | string | -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /datasets/{dataset_id}/batch/{batch}/indexing-status - -#### GET -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| batch | path | | Yes | string | -| dataset_id | path | | Yes | string | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Indexing status retrieved successfully | [DocumentStatusListResponse](#documentstatuslistresponse) | +| 200 | Batch indexing estimate calculated successfully | **application/json**: [OpaqueObjectResponse](#opaqueobjectresponse)
| -### /datasets/{dataset_id}/documents +### [GET] /datasets/{dataset_id}/batch/{batch}/indexing-status +#### Parameters -#### DELETE -##### Parameters +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| batch | path | | Yes | string | +| dataset_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Indexing status retrieved successfully | **application/json**: [DocumentStatusListResponse](#documentstatuslistresponse)
| + +### [DELETE] /datasets/{dataset_id}/documents +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | | 204 | Documents deleted successfully | -#### GET -##### Description - +### [GET] /datasets/{dataset_id}/documents Get documents in a dataset -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | @@ -5072,134 +4983,134 @@ Get documents in a dataset | sort | query | Sort order (default: -created_at) | No | string | | status | query | Filter documents by display status | No | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Documents retrieved successfully | [DocumentWithSegmentsListResponse](#documentwithsegmentslistresponse) | +| 200 | Documents retrieved successfully | **application/json**: [DocumentWithSegmentsListResponse](#documentwithsegmentslistresponse)
| -#### POST -##### Parameters +### [POST] /datasets/{dataset_id}/documents +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [KnowledgeConfig](#knowledgeconfig) | | dataset_id | path | | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [KnowledgeConfig](#knowledgeconfig)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Documents created successfully | [DatasetAndDocumentResponse](#datasetanddocumentresponse) | +| 200 | Documents created successfully | **application/json**: [DatasetAndDocumentResponse](#datasetanddocumentresponse)
| -### /datasets/{dataset_id}/documents/download-zip - -#### POST -##### Summary - -Stream a ZIP archive containing the requested uploaded documents - -##### Description +### [POST] /datasets/{dataset_id}/documents/download-zip +**Stream a ZIP archive containing the requested uploaded documents** Download selected dataset documents as a single ZIP archive (upload-file only) -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | | Yes | string | -| payload | body | | Yes | [DocumentBatchDownloadZipPayload](#documentbatchdownloadzippayload) | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DocumentBatchDownloadZipPayload](#documentbatchdownloadzippayload)
| -### /datasets/{dataset_id}/documents/generate-summary +#### Responses -#### POST -##### Summary +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | ZIP archive generated successfully | **application/json**: [BinaryFileResponse](#binaryfileresponse)
| -Generate summary index for specified documents - -##### Description +### [POST] /datasets/{dataset_id}/documents/generate-summary +**Generate summary index for specified documents** Generate summary index for documents This endpoint checks if the dataset configuration supports summary generation (indexing_technique must be 'high_quality' and summary_index_setting.enable must be true), then asynchronously generates summary indexes for the provided documents. -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [GenerateSummaryPayload](#generatesummarypayload) | | dataset_id | path | Dataset ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [GenerateSummaryPayload](#generatesummarypayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Summary generation started successfully | [SimpleResultResponse](#simpleresultresponse) | +| 200 | Summary generation started successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| | 400 | Invalid request or dataset configuration | | | 403 | Permission denied | | | 404 | Dataset not found | | -### /datasets/{dataset_id}/documents/metadata - -#### POST -##### Parameters +### [POST] /datasets/{dataset_id}/documents/metadata +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | | Yes | string | -| payload | body | | Yes | [MetadataOperationData](#metadataoperationdata) | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [MetadataOperationData](#metadataoperationdata)
| + +#### Responses | Code | Description | | ---- | ----------- | | 204 | Documents metadata updated successfully | -### /datasets/{dataset_id}/documents/status/{action}/batch - -#### PATCH -##### Parameters +### [PATCH] /datasets/{dataset_id}/documents/status/{action}/batch +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | action | path | | Yes | string | | dataset_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SimpleResultResponse](#simpleresultresponse) | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| -### /datasets/{dataset_id}/documents/{document_id} - -#### DELETE -##### Parameters +### [DELETE] /datasets/{dataset_id}/documents/{document_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | | Yes | string | | document_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | | 204 | Document deleted successfully | -#### GET -##### Description - +### [GET] /datasets/{dataset_id}/documents/{document_id} Get document details -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | @@ -5207,179 +5118,152 @@ Get document details | document_id | path | Document ID | Yes | string | | metadata | query | Metadata inclusion (all/only/without) | No | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Document retrieved successfully | -| 404 | Document not found | - -### /datasets/{dataset_id}/documents/{document_id}/download - -#### GET -##### Description +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Document retrieved successfully | **application/json**: [OpaqueObjectResponse](#opaqueobjectresponse)
| +| 404 | Document not found | | +### [GET] /datasets/{dataset_id}/documents/{document_id}/download Get a signed download URL for a dataset document's original uploaded file -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | | Yes | string | | document_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Download URL generated successfully | [UrlResponse](#urlresponse) | - -### /datasets/{dataset_id}/documents/{document_id}/indexing-estimate - -#### GET -##### Description +| 200 | Download URL generated successfully | **application/json**: [UrlResponse](#urlresponse)
| +### [GET] /datasets/{dataset_id}/documents/{document_id}/indexing-estimate Estimate document indexing cost -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | Dataset ID | Yes | string | | document_id | path | Document ID | Yes | string | -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Indexing estimate calculated successfully | -| 400 | Document already finished | -| 404 | Document not found | - -### /datasets/{dataset_id}/documents/{document_id}/indexing-status - -#### GET -##### Description - -Get document indexing status - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | -| document_id | path | Document ID | Yes | string | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Indexing status retrieved successfully | [DocumentStatusResponse](#documentstatusresponse) | +| 200 | Indexing estimate calculated successfully | **application/json**: [OpaqueObjectResponse](#opaqueobjectresponse)
| +| 400 | Document already finished | | | 404 | Document not found | | -### /datasets/{dataset_id}/documents/{document_id}/metadata +### [GET] /datasets/{dataset_id}/documents/{document_id}/indexing-status +Get document indexing status -#### PUT -##### Description - -Update document metadata - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [DocumentMetadataUpdatePayload](#documentmetadataupdatepayload) | | dataset_id | path | Dataset ID | Yes | string | | document_id | path | Document ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Document metadata updated successfully | [SimpleResultMessageResponse](#simpleresultmessageresponse) | +| 200 | Indexing status retrieved successfully | **application/json**: [DocumentStatusResponse](#documentstatusresponse)
| +| 404 | Document not found | | + +### [PUT] /datasets/{dataset_id}/documents/{document_id}/metadata +Update document metadata + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Dataset ID | Yes | string | +| document_id | path | Document ID | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DocumentMetadataUpdatePayload](#documentmetadataupdatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Document metadata updated successfully | **application/json**: [SimpleResultMessageResponse](#simpleresultmessageresponse)
| | 403 | Permission denied | | | 404 | Document not found | | -### /datasets/{dataset_id}/documents/{document_id}/notion/sync - -#### GET -##### Parameters +### [GET] /datasets/{dataset_id}/documents/{document_id}/notion/sync +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | | Yes | string | | document_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SimpleResultResponse](#simpleresultresponse) | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| -### /datasets/{dataset_id}/documents/{document_id}/pipeline-execution-log - -#### GET -##### Parameters +### [GET] /datasets/{dataset_id}/documents/{document_id}/pipeline-execution-log +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | | Yes | string | | document_id | path | | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Document pipeline execution log retrieved successfully | **application/json**: [OpaqueObjectResponse](#opaqueobjectresponse)
| -### /datasets/{dataset_id}/documents/{document_id}/processing/pause +### [PATCH] /datasets/{dataset_id}/documents/{document_id}/processing/pause +**pause document** -#### PATCH -##### Summary - -pause document - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | | Yes | string | | document_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | | 204 | Document paused successfully | -### /datasets/{dataset_id}/documents/{document_id}/processing/resume +### [PATCH] /datasets/{dataset_id}/documents/{document_id}/processing/resume +**recover document** -#### PATCH -##### Summary - -recover document - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | | Yes | string | | document_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | | 204 | Document resumed successfully | -### /datasets/{dataset_id}/documents/{document_id}/processing/{action} - -#### PATCH -##### Description - +### [PATCH] /datasets/{dataset_id}/documents/{document_id}/processing/{action} Update document processing status (pause/resume) -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | @@ -5387,52 +5271,56 @@ Update document processing status (pause/resume) | dataset_id | path | Dataset ID | Yes | string | | document_id | path | Document ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Processing status updated successfully | [SimpleResultResponse](#simpleresultresponse) | +| 200 | Processing status updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| | 400 | Invalid action | | | 404 | Document not found | | -### /datasets/{dataset_id}/documents/{document_id}/rename - -#### POST -##### Parameters +### [POST] /datasets/{dataset_id}/documents/{document_id}/rename +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | | Yes | string | | document_id | path | | Yes | string | -| payload | body | | Yes | [DocumentRenamePayload](#documentrenamepayload) | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DocumentRenamePayload](#documentrenamepayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Document renamed successfully | [DocumentResponse](#documentresponse) | +| 200 | Document renamed successfully | **application/json**: [DocumentResponse](#documentresponse)
| -### /datasets/{dataset_id}/documents/{document_id}/segment - -#### POST -##### Parameters +### [POST] /datasets/{dataset_id}/documents/{document_id}/segment +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [SegmentCreatePayload](#segmentcreatepayload) | | dataset_id | path | Dataset ID | Yes | string | | document_id | path | Document ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [SegmentCreatePayload](#segmentcreatepayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Segment created successfully | [SegmentDetailResponse](#segmentdetailresponse) | +| 200 | Segment created successfully | **application/json**: [SegmentDetailResponse](#segmentdetailresponse)
| -### /datasets/{dataset_id}/documents/{document_id}/segment/{action} - -#### PATCH -##### Parameters +### [PATCH] /datasets/{dataset_id}/documents/{document_id}/segment/{action} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | @@ -5441,16 +5329,14 @@ Update document processing status (pause/resume) | document_id | path | Document ID | Yes | string | | segment_id | query | Segment IDs | No | [ string ] | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SimpleResultResponse](#simpleresultresponse) | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| -### /datasets/{dataset_id}/documents/{document_id}/segments - -#### DELETE -##### Parameters +### [DELETE] /datasets/{dataset_id}/documents/{document_id}/segments +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | @@ -5458,67 +5344,68 @@ Update document processing status (pause/resume) | document_id | path | Document ID | Yes | string | | segment_id | query | Segment IDs | No | [ string ] | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | | 204 | Segments deleted successfully | -#### GET -##### Parameters +### [GET] /datasets/{dataset_id}/documents/{document_id}/segments +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | Dataset ID | Yes | string | | document_id | path | Document ID | Yes | string | -| enabled | query | | No | string | +| enabled | query | | No | string,
**Default:** all | | hit_count_gte | query | | No | integer | | keyword | query | | No | string | -| limit | query | | No | integer | -| page | query | | No | integer | +| limit | query | | No | integer,
**Default:** 20 | +| page | query | | No | integer,
**Default:** 1 | | status | query | | No | [ string ] | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Segments retrieved successfully | [ConsoleSegmentListResponse](#consolesegmentlistresponse) | +| 200 | Segments retrieved successfully | **application/json**: [ConsoleSegmentListResponse](#consolesegmentlistresponse)
| -### /datasets/{dataset_id}/documents/{document_id}/segments/batch_import - -#### GET -##### Parameters +### [GET] /datasets/{dataset_id}/documents/{document_id}/segments/batch_import +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | | Yes | string | | document_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Batch import status | [SegmentBatchImportStatusResponse](#segmentbatchimportstatusresponse) | +| 200 | Batch import status | **application/json**: [SegmentBatchImportStatusResponse](#segmentbatchimportstatusresponse)
| -#### POST -##### Parameters +### [POST] /datasets/{dataset_id}/documents/{document_id}/segments/batch_import +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | | Yes | string | | document_id | path | | Yes | string | -| payload | body | | Yes | [BatchImportPayload](#batchimportpayload) | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [BatchImportPayload](#batchimportpayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Batch import started | [SegmentBatchImportStatusResponse](#segmentbatchimportstatusresponse) | +| 200 | Batch import started | **application/json**: [SegmentBatchImportStatusResponse](#segmentbatchimportstatusresponse)
| -### /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id} - -#### DELETE -##### Parameters +### [DELETE] /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | @@ -5526,32 +5413,35 @@ Update document processing status (pause/resume) | document_id | path | Document ID | Yes | string | | segment_id | path | Segment ID | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | | 204 | Segment deleted successfully | -#### PATCH -##### Parameters +### [PATCH] /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [SegmentUpdatePayload](#segmentupdatepayload) | | dataset_id | path | Dataset ID | Yes | string | | document_id | path | Document ID | Yes | string | | segment_id | path | Segment ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [SegmentUpdatePayload](#segmentupdatepayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Segment updated successfully | [SegmentDetailResponse](#segmentdetailresponse) | +| 200 | Segment updated successfully | **application/json**: [SegmentDetailResponse](#segmentdetailresponse)
| -### /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks - -#### GET -##### Parameters +### [GET] /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | @@ -5559,51 +5449,59 @@ Update document processing status (pause/resume) | document_id | path | Document ID | Yes | string | | segment_id | path | Parent segment ID | Yes | string | | keyword | query | | No | string | -| limit | query | | No | integer | -| page | query | | No | integer | +| limit | query | | No | integer,
**Default:** 20 | +| page | query | | No | integer,
**Default:** 1 | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Child chunks retrieved successfully | [ChildChunkListResponse](#childchunklistresponse) | +| 200 | Child chunks retrieved successfully | **application/json**: [ChildChunkListResponse](#childchunklistresponse)
| -#### PATCH -##### Parameters +### [PATCH] /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ChildChunkBatchUpdatePayload](#childchunkbatchupdatepayload) | | dataset_id | path | Dataset ID | Yes | string | | document_id | path | Document ID | Yes | string | | segment_id | path | Parent segment ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ChildChunkBatchUpdatePayload](#childchunkbatchupdatepayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Child chunks updated successfully | [ChildChunkBatchUpdateResponse](#childchunkbatchupdateresponse) | +| 200 | Child chunks updated successfully | **application/json**: [ChildChunkBatchUpdateResponse](#childchunkbatchupdateresponse)
| -#### POST -##### Parameters +### [POST] /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ChildChunkCreatePayload](#childchunkcreatepayload) | | dataset_id | path | Dataset ID | Yes | string | | document_id | path | Document ID | Yes | string | | segment_id | path | Parent segment ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ChildChunkCreatePayload](#childchunkcreatepayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Child chunk created successfully | [ChildChunkDetailResponse](#childchunkdetailresponse) | +| 200 | Child chunk created successfully | **application/json**: [ChildChunkDetailResponse](#childchunkdetailresponse)
| -### /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks/{child_chunk_id} - -#### DELETE -##### Parameters +### [DELETE] /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks/{child_chunk_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | @@ -5612,37 +5510,36 @@ Update document processing status (pause/resume) | document_id | path | Document ID | Yes | string | | segment_id | path | Parent segment ID | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | | 204 | Child chunk deleted successfully | -#### PATCH -##### Parameters +### [PATCH] /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks/{child_chunk_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ChildChunkUpdatePayload](#childchunkupdatepayload) | | child_chunk_id | path | Child chunk ID | Yes | string | | dataset_id | path | Dataset ID | Yes | string | | document_id | path | Document ID | Yes | string | | segment_id | path | Parent segment ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ChildChunkUpdatePayload](#childchunkupdatepayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Child chunk updated successfully | [ChildChunkDetailResponse](#childchunkdetailresponse) | +| 200 | Child chunk updated successfully | **application/json**: [ChildChunkDetailResponse](#childchunkdetailresponse)
| -### /datasets/{dataset_id}/documents/{document_id}/summary-status - -#### GET -##### Summary - -Get summary index generation status for a document - -##### Description +### [GET] /datasets/{dataset_id}/documents/{document_id}/summary-status +**Get summary index generation status for a document** Get summary index generation status for a document Returns: @@ -5654,631 +5551,559 @@ Returns: - not_started: Number of segments without summary records - summaries: List of summary records with status and content preview -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | Dataset ID | Yes | string | | document_id | path | Document ID | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Summary status retrieved successfully | -| 404 | Document not found | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Summary status retrieved successfully | **application/json**: [OpaqueObjectResponse](#opaqueobjectresponse)
| +| 404 | Document not found | | -### /datasets/{dataset_id}/documents/{document_id}/website-sync +### [GET] /datasets/{dataset_id}/documents/{document_id}/website-sync +**sync website document** -#### GET -##### Summary - -sync website document - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | | Yes | string | | document_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SimpleResultResponse](#simpleresultresponse) | - -### /datasets/{dataset_id}/error-docs - -#### GET -##### Description +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +### [GET] /datasets/{dataset_id}/error-docs Get dataset error documents -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | Dataset ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Error documents retrieved successfully | [ErrorDocsResponse](#errordocsresponse) | +| 200 | Error documents retrieved successfully | **application/json**: [ErrorDocsResponse](#errordocsresponse)
| | 404 | Dataset not found | | -### /datasets/{dataset_id}/external-hit-testing - -#### POST -##### Description - +### [POST] /datasets/{dataset_id}/external-hit-testing Test external knowledge retrieval for dataset -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ExternalHitTestingPayload](#externalhittestingpayload) | | dataset_id | path | Dataset ID | Yes | string | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | External hit testing completed successfully | -| 400 | Invalid parameters | -| 404 | Dataset not found | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ExternalHitTestingPayload](#externalhittestingpayload)
| -### /datasets/{dataset_id}/hit-testing - -#### POST -##### Description - -Test dataset knowledge retrieval - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [HitTestingPayload](#hittestingpayload) | -| dataset_id | path | Dataset ID | Yes | string | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Hit testing completed successfully | [HitTestingResponse](#hittestingresponse) | +| 200 | External hit testing completed successfully | **application/json**: [ExternalRetrievalTestResponse](#externalretrievaltestresponse)
| | 400 | Invalid parameters | | | 404 | Dataset not found | | -### /datasets/{dataset_id}/indexing-status +### [POST] /datasets/{dataset_id}/hit-testing +Test dataset knowledge retrieval -#### GET -##### Description - -Get dataset indexing status - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | Dataset ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [HitTestingPayload](#hittestingpayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Indexing status retrieved successfully | [DocumentStatusListResponse](#documentstatuslistresponse) | +| 200 | Hit testing completed successfully | **application/json**: [HitTestingResponse](#hittestingresponse)
| +| 400 | Invalid parameters | | +| 404 | Dataset not found | | -### /datasets/{dataset_id}/metadata +### [GET] /datasets/{dataset_id}/indexing-status +Get dataset indexing status -#### GET -##### Parameters +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Dataset ID | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Indexing status retrieved successfully | **application/json**: [DocumentStatusListResponse](#documentstatuslistresponse)
| + +### [GET] /datasets/{dataset_id}/metadata +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Metadata retrieved successfully | [DatasetMetadataListResponse](#datasetmetadatalistresponse) | +| 200 | Metadata retrieved successfully | **application/json**: [DatasetMetadataListResponse](#datasetmetadatalistresponse)
| -#### POST -##### Parameters +### [POST] /datasets/{dataset_id}/metadata +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | | Yes | string | -| payload | body | | Yes | [MetadataArgs](#metadataargs) | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [MetadataArgs](#metadataargs)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 201 | Metadata created successfully | [DatasetMetadataResponse](#datasetmetadataresponse) | +| 201 | Metadata created successfully | **application/json**: [DatasetMetadataResponse](#datasetmetadataresponse)
| -### /datasets/{dataset_id}/metadata/built-in/{action} - -#### POST -##### Parameters +### [POST] /datasets/{dataset_id}/metadata/built-in/{action} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | action | path | | Yes | string | | dataset_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | | 204 | Action completed successfully | -### /datasets/{dataset_id}/metadata/{metadata_id} - -#### DELETE -##### Parameters +### [DELETE] /datasets/{dataset_id}/metadata/{metadata_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | | Yes | string | | metadata_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | | 204 | Metadata deleted successfully | -#### PATCH -##### Parameters +### [PATCH] /datasets/{dataset_id}/metadata/{metadata_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | | Yes | string | | metadata_id | path | | Yes | string | -| payload | body | | Yes | [MetadataUpdatePayload](#metadataupdatepayload) | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [MetadataUpdatePayload](#metadataupdatepayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Metadata updated successfully | [DatasetMetadataResponse](#datasetmetadataresponse) | +| 200 | Metadata updated successfully | **application/json**: [DatasetMetadataResponse](#datasetmetadataresponse)
| -### /datasets/{dataset_id}/notion/sync - -#### GET -##### Parameters +### [GET] /datasets/{dataset_id}/notion/sync +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SimpleResultResponse](#simpleresultresponse) | - -### /datasets/{dataset_id}/permission-part-users - -#### GET -##### Description +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +### [GET] /datasets/{dataset_id}/permission-part-users Get dataset permission user list -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | Dataset ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Permission users retrieved successfully | [PartialMemberListResponse](#partialmemberlistresponse) | +| 200 | Permission users retrieved successfully | **application/json**: [PartialMemberListResponse](#partialmemberlistresponse)
| | 403 | Permission denied | | | 404 | Dataset not found | | -### /datasets/{dataset_id}/queries - -#### GET -##### Description - +### [GET] /datasets/{dataset_id}/queries Get dataset query history -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | Dataset ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Query history retrieved successfully | [DatasetQueryListResponse](#datasetquerylistresponse) | - -### /datasets/{dataset_id}/related-apps - -#### GET -##### Description +| 200 | Query history retrieved successfully | **application/json**: [DatasetQueryListResponse](#datasetquerylistresponse)
| +### [GET] /datasets/{dataset_id}/related-apps Get applications related to dataset -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | Dataset ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Related apps retrieved successfully | [RelatedAppListResponse](#relatedapplistresponse) | +| 200 | Related apps retrieved successfully | **application/json**: [RelatedAppListResponse](#relatedapplistresponse)
| -### /datasets/{dataset_id}/retry +### [POST] /datasets/{dataset_id}/retry +**retry document** -#### POST -##### Summary - -retry document - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | | Yes | string | -| payload | body | | Yes | [DocumentRetryPayload](#documentretrypayload) | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DocumentRetryPayload](#documentretrypayload)
| + +#### Responses | Code | Description | | ---- | ----------- | | 204 | Documents retry started successfully | -### /datasets/{dataset_id}/use-check - -#### GET -##### Description - +### [GET] /datasets/{dataset_id}/use-check Check if dataset is in use -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | Dataset ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Dataset use status retrieved successfully | [UsageCheckResponse](#usagecheckresponse) | +| 200 | Dataset use status retrieved successfully | **application/json**: [UsageCheckResponse](#usagecheckresponse)
| -### /datasets/{resource_id}/api-keys +### [GET] /datasets/{resource_id}/api-keys +**Get all API keys for a dataset** -#### GET -##### Summary - -Get all API keys for a dataset - -##### Description - -Get all API keys for a dataset - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | resource_id | path | Dataset ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | API keys retrieved successfully | [ApiKeyList](#apikeylist) | +| 200 | API keys retrieved successfully | **application/json**: [ApiKeyList](#apikeylist)
| -#### POST -##### Summary +### [POST] /datasets/{resource_id}/api-keys +**Create a new API key for a dataset** -Create a new API key for a dataset - -##### Description - -Create a new API key for a dataset - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | resource_id | path | Dataset ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 201 | API key created successfully | [ApiKeyItem](#apikeyitem) | +| 201 | API key created successfully | **application/json**: [ApiKeyItem](#apikeyitem)
| | 400 | Maximum keys exceeded | | -### /datasets/{resource_id}/api-keys/{api_key_id} +### [DELETE] /datasets/{resource_id}/api-keys/{api_key_id} +**Delete an API key for a dataset** -#### DELETE -##### Summary - -Delete an API key for a dataset - -##### Description - -Delete an API key for a dataset - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | api_key_id | path | API key ID | Yes | string | | resource_id | path | Dataset ID | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | | 204 | API key deleted successfully | -### /email-code-login +### [POST] /email-code-login +#### Request Body -#### POST -##### Parameters +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [EmailPayload](#emailpayload)
| -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [EmailPayload](#emailpayload) | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SimpleResultDataResponse](#simpleresultdataresponse) | +| 200 | Success | **application/json**: [SimpleResultDataResponse](#simpleresultdataresponse)
| -### /email-code-login/validity +### [POST] /email-code-login/validity +#### Request Body -#### POST -##### Parameters +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [EmailCodeLoginPayload](#emailcodeloginpayload)
| -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [EmailCodeLoginPayload](#emailcodeloginpayload) | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SimpleResultResponse](#simpleresultresponse) | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| -### /email-register +### [POST] /email-register +#### Request Body -#### POST -##### Responses +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [EmailRegisterResetPayload](#emailregisterresetpayload)
| -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /email-register/send-email - -#### POST -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SimpleResultDataResponse](#simpleresultdataresponse) | +| 200 | Success | **application/json**: [EmailRegisterResetResponse](#emailregisterresetresponse)
| -### /email-register/validity +### [POST] /email-register/send-email +#### Request Body -#### POST -##### Responses +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [EmailRegisterSendPayload](#emailregistersendpayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [VerificationTokenResponse](#verificationtokenresponse) | +| 200 | Success | **application/json**: [SimpleResultDataResponse](#simpleresultdataresponse)
| -### /explore/apps +### [POST] /email-register/validity +#### Request Body -#### GET -##### Parameters +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [EmailRegisterValidityPayload](#emailregistervaliditypayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [VerificationTokenResponse](#verificationtokenresponse)
| + +### [GET] /explore/apps +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | language | query | Language code for recommended app localization | No | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [RecommendedAppListResponse](#recommendedapplistresponse) | +| 200 | Success | **application/json**: [RecommendedAppListResponse](#recommendedapplistresponse)
| -### /explore/apps/{app_id} +### [GET] /explore/apps/learn-dify +#### Parameters -#### GET -##### Parameters +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| language | query | Language code for recommended app localization | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [LearnDifyAppListResponse](#learndifyapplistresponse)
| + +### [GET] /explore/apps/{app_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | | Yes | string | -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /features - -#### GET -##### Summary - -Get feature configuration for current tenant - -##### Description - -Get feature configuration for current tenant - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [FeatureModel](#featuremodel) | +| 200 | Success | **application/json**: [RecommendedAppDetailResponse](#recommendedappdetailresponse)
| -### /features/vector-space +### [GET] /features +**Get feature configuration for current tenant** -#### GET -##### Summary - -Get vector-space usage and limit for current tenant - -##### Description - -Get vector-space usage and limit for current tenant - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [LimitationModel](#limitationmodel) | +| 200 | Success | **application/json**: [FeatureModel](#featuremodel)
| -### /files/support-type +### [GET] /features/vector-space +**Get vector-space usage and limit for current tenant** -#### GET -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [AllowedExtensionsResponse](#allowedextensionsresponse) | +| 200 | Success | **application/json**: [LimitationModel](#limitationmodel)
| -### /files/upload - -#### GET -##### Responses +### [GET] /files/support-type +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [UploadConfig](#uploadconfig) | +| 200 | Success | **application/json**: [AllowedExtensionsResponse](#allowedextensionsresponse)
| -#### POST -##### Responses +### [GET] /files/upload +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 201 | File uploaded successfully | [FileResponse](#fileresponse) | +| 200 | Success | **application/json**: [UploadConfig](#uploadconfig)
| -### /files/{file_id}/preview +### [POST] /files/upload +#### Responses -#### GET -##### Parameters +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | File uploaded successfully | **application/json**: [FileResponse](#fileresponse)
| + +### [GET] /files/{file_id}/preview +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | file_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [TextContentResponse](#textcontentresponse) | - -### /forgot-password - -#### POST -##### Description +| 200 | Success | **application/json**: [TextContentResponse](#textcontentresponse)
| +### [POST] /forgot-password Send password reset email -##### Parameters +#### Request Body -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ForgotPasswordSendPayload](#forgotpasswordsendpayload) | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ForgotPasswordSendPayload](#forgotpasswordsendpayload)
| -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Email sent successfully | [ForgotPasswordEmailResponse](#forgotpasswordemailresponse) | +| 200 | Email sent successfully | **application/json**: [ForgotPasswordEmailResponse](#forgotpasswordemailresponse)
| | 400 | Invalid email or rate limit exceeded | | -### /forgot-password/resets - -#### POST -##### Description - +### [POST] /forgot-password/resets Reset password with verification token -##### Parameters +#### Request Body -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ForgotPasswordResetPayload](#forgotpasswordresetpayload) | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ForgotPasswordResetPayload](#forgotpasswordresetpayload)
| -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Password reset successfully | [ForgotPasswordResetResponse](#forgotpasswordresetresponse) | +| 200 | Password reset successfully | **application/json**: [ForgotPasswordResetResponse](#forgotpasswordresetresponse)
| | 400 | Invalid token or password mismatch | | -### /forgot-password/validity - -#### POST -##### Description - +### [POST] /forgot-password/validity Verify password reset code -##### Parameters +#### Request Body -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ForgotPasswordCheckPayload](#forgotpasswordcheckpayload) | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ForgotPasswordCheckPayload](#forgotpasswordcheckpayload)
| -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Code verified successfully | [ForgotPasswordCheckResponse](#forgotpasswordcheckresponse) | +| 200 | Code verified successfully | **application/json**: [ForgotPasswordCheckResponse](#forgotpasswordcheckresponse)
| | 400 | Invalid code or token | | -### /form/human_input/{form_token} - -#### GET -##### Summary - -Get human input form definition by form token - -##### Description +### [GET] /form/human_input/{form_token} +**Get human input form definition by form token** GET /console/api/form/human_input/ -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | form_token | path | | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ConsoleHumanInputFormDefinitionResponse](#consolehumaninputformdefinitionresponse)
| -#### POST -##### Summary - -Submit human input form by form token - -##### Description +### [POST] /form/human_input/{form_token} +**Submit human input form by form token** POST /console/api/form/human_input/ @@ -6290,628 +6115,608 @@ Request body: "action": "Approve" } -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | form_token | path | | Yes | string | -| payload | body | | Yes | [HumanInputFormSubmitPayload](#humaninputformsubmitpayload) | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [HumanInputFormSubmitPayload](#humaninputformsubmitpayload)
| -### /info - -#### POST -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [TenantInfoResponse](#tenantinforesponse) | +| 200 | Success | **application/json**: [ConsoleHumanInputFormSubmitResponse](#consolehumaninputformsubmitresponse)
| -### /installed-apps - -#### GET -##### Responses +### [POST] /info +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [InstalledAppListResponse](#installedapplistresponse) | +| 200 | Success | **application/json**: [TenantInfoResponse](#tenantinforesponse)
| -#### POST -##### Responses +### [GET] /installed-apps +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | query | App ID to filter by | No | string | + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SimpleMessageResponse](#simplemessageresponse) | +| 200 | Success | **application/json**: [InstalledAppListResponse](#installedapplistresponse)
| -### /installed-apps/{installed_app_id} +### [POST] /installed-apps +#### Request Body -#### DELETE -##### Parameters +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [InstalledAppCreatePayload](#installedappcreatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleMessageResponse](#simplemessageresponse)
| + +### [DELETE] /installed-apps/{installed_app_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | installed_app_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | | 204 | App uninstalled successfully | -#### PATCH -##### Parameters +### [PATCH] /installed-apps/{installed_app_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | installed_app_id | path | | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [InstalledAppUpdatePayload](#installedappupdatepayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SimpleResultMessageResponse](#simpleresultmessageresponse) | +| 200 | Success | **application/json**: [SimpleResultMessageResponse](#simpleresultmessageresponse)
| -### /installed-apps/{installed_app_id}/audio-to-text - -#### POST -##### Parameters +### [POST] /installed-apps/{installed_app_id}/audio-to-text +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | installed_app_id | path | | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [AudioTranscriptResponse](#audiotranscriptresponse)
| -### /installed-apps/{installed_app_id}/chat-messages - -#### POST -##### Parameters +### [POST] /installed-apps/{installed_app_id}/chat-messages +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | installed_app_id | path | | Yes | string | -| payload | body | | Yes | [ChatMessagePayload](#chatmessagepayload) | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ChatMessagePayload](#chatmessagepayload)
| -### /installed-apps/{installed_app_id}/chat-messages/{task_id}/stop +#### Responses -#### POST -##### Parameters +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| + +### [POST] /installed-apps/{installed_app_id}/chat-messages/{task_id}/stop +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | installed_app_id | path | | Yes | string | | task_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SimpleResultResponse](#simpleresultresponse) | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| -### /installed-apps/{installed_app_id}/completion-messages - -#### POST -##### Parameters +### [POST] /installed-apps/{installed_app_id}/completion-messages +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | installed_app_id | path | | Yes | string | -| payload | body | | Yes | [CompletionMessageExplorePayload](#completionmessageexplorepayload) | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [CompletionMessageExplorePayload](#completionmessageexplorepayload)
| -### /installed-apps/{installed_app_id}/completion-messages/{task_id}/stop +#### Responses -#### POST -##### Parameters +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| + +### [POST] /installed-apps/{installed_app_id}/completion-messages/{task_id}/stop +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | installed_app_id | path | | Yes | string | | task_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SimpleResultResponse](#simpleresultresponse) | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| -### /installed-apps/{installed_app_id}/conversations - -#### GET -##### Parameters +### [GET] /installed-apps/{installed_app_id}/conversations +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | +| last_id | query | | No | string | +| limit | query | | No | integer,
**Default:** 20 | +| pinned | query | | No | boolean | | installed_app_id | path | | Yes | string | -| payload | body | | Yes | [ConversationListQuery](#conversationlistquery) | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ConversationInfiniteScrollPagination](#conversationinfinitescrollpagination)
| -### /installed-apps/{installed_app_id}/conversations/{c_id} - -#### DELETE -##### Parameters +### [DELETE] /installed-apps/{installed_app_id}/conversations/{c_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | c_id | path | | Yes | string | | installed_app_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | | 204 | Conversation deleted successfully | -### /installed-apps/{installed_app_id}/conversations/{c_id}/name - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| c_id | path | | Yes | string | -| installed_app_id | path | | Yes | string | -| payload | body | | Yes | [ConversationRenamePayload](#conversationrenamepayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /installed-apps/{installed_app_id}/conversations/{c_id}/pin - -#### PATCH -##### Parameters +### [POST] /installed-apps/{installed_app_id}/conversations/{c_id}/name +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | c_id | path | | Yes | string | | installed_app_id | path | | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ConversationRenamePayload](#conversationrenamepayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [ResultResponse](#resultresponse) | +| 200 | Conversation renamed successfully | **application/json**: [SimpleConversation](#simpleconversation)
| -### /installed-apps/{installed_app_id}/conversations/{c_id}/unpin - -#### PATCH -##### Parameters +### [PATCH] /installed-apps/{installed_app_id}/conversations/{c_id}/pin +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | c_id | path | | Yes | string | | installed_app_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [ResultResponse](#resultresponse) | +| 200 | Success | **application/json**: [ResultResponse](#resultresponse)
| -### /installed-apps/{installed_app_id}/messages - -#### GET -##### Parameters +### [PATCH] /installed-apps/{installed_app_id}/conversations/{c_id}/unpin +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | +| c_id | path | | Yes | string | | installed_app_id | path | | Yes | string | -| payload | body | | Yes | [MessageListQuery](#messagelistquery) | -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /installed-apps/{installed_app_id}/messages/{message_id}/feedbacks - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| installed_app_id | path | | Yes | string | -| message_id | path | | Yes | string | -| payload | body | | Yes | [MessageFeedbackPayload](#messagefeedbackpayload) | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Feedback submitted successfully | [ResultResponse](#resultresponse) | +| 200 | Success | **application/json**: [ResultResponse](#resultresponse)
| -### /installed-apps/{installed_app_id}/messages/{message_id}/more-like-this - -#### GET -##### Parameters +### [GET] /installed-apps/{installed_app_id}/messages +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | +| conversation_id | query | Conversation UUID | Yes | string | +| first_id | query | First message ID for pagination | No | string | +| limit | query | Number of messages to return (1-100) | No | integer,
**Default:** 20 | | installed_app_id | path | | Yes | string | -| message_id | path | | Yes | string | -| payload | body | | Yes | [MoreLikeThisQuery](#morelikethisquery) | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [MessageInfiniteScrollPagination](#messageinfinitescrollpagination)
| -### /installed-apps/{installed_app_id}/messages/{message_id}/suggested-questions - -#### GET -##### Parameters +### [POST] /installed-apps/{installed_app_id}/messages/{message_id}/feedbacks +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | installed_app_id | path | | Yes | string | | message_id | path | | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [MessageFeedbackPayload](#messagefeedbackpayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SuggestedQuestionsResponse](#suggestedquestionsresponse) | +| 200 | Feedback submitted successfully | **application/json**: [ResultResponse](#resultresponse)
| -### /installed-apps/{installed_app_id}/meta - -#### GET -##### Summary - -Get app meta - -##### Parameters +### [GET] /installed-apps/{installed_app_id}/messages/{message_id}/more-like-this +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | +| response_mode | query | | Yes | string,
**Available values:** "blocking", "streaming" | | installed_app_id | path | | Yes | string | +| message_id | path | | Yes | string | -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /installed-apps/{installed_app_id}/parameters - -#### GET -##### Summary - -Retrieve app parameters - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| installed_app_id | path | | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /installed-apps/{installed_app_id}/saved-messages - -#### GET -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| installed_app_id | path | | Yes | string | -| payload | body | | Yes | [SavedMessageListQuery](#savedmessagelistquery) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| installed_app_id | path | | Yes | string | -| payload | body | | Yes | [SavedMessageCreatePayload](#savedmessagecreatepayload) | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [ResultResponse](#resultresponse) | +| 200 | Success | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| -### /installed-apps/{installed_app_id}/saved-messages/{message_id} - -#### DELETE -##### Parameters +### [GET] /installed-apps/{installed_app_id}/messages/{message_id}/suggested-questions +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | installed_app_id | path | | Yes | string | | message_id | path | | Yes | string | -##### Responses +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SuggestedQuestionsResponse](#suggestedquestionsresponse)
| + +### [GET] /installed-apps/{installed_app_id}/meta +**Get app meta** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| installed_app_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ExploreAppMetaResponse](#exploreappmetaresponse)
| + +### [GET] /installed-apps/{installed_app_id}/parameters +**Retrieve app parameters** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| installed_app_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [Parameters](#parameters)
| + +### [GET] /installed-apps/{installed_app_id}/saved-messages +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| last_id | query | | No | string | +| limit | query | | No | integer,
**Default:** 20 | +| installed_app_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SavedMessageInfiniteScrollPagination](#savedmessageinfinitescrollpagination)
| + +### [POST] /installed-apps/{installed_app_id}/saved-messages +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| installed_app_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [SavedMessageCreatePayload](#savedmessagecreatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ResultResponse](#resultresponse)
| + +### [DELETE] /installed-apps/{installed_app_id}/saved-messages/{message_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| installed_app_id | path | | Yes | string | +| message_id | path | | Yes | string | + +#### Responses | Code | Description | | ---- | ----------- | | 204 | Saved message deleted successfully | -### /installed-apps/{installed_app_id}/text-to-audio - -#### POST -##### Parameters +### [POST] /installed-apps/{installed_app_id}/text-to-audio +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | installed_app_id | path | | Yes | string | -| payload | body | | Yes | [TextToAudioPayload](#texttoaudiopayload) | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [TextToAudioPayload](#texttoaudiopayload)
| -### /installed-apps/{installed_app_id}/workflows/run +#### Responses -#### POST -##### Summary +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [AudioBinaryResponse](#audiobinaryresponse)
| -Run workflow +### [POST] /installed-apps/{installed_app_id}/workflows/run +**Run workflow** -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | installed_app_id | path | | Yes | string | -| payload | body | | Yes | [WorkflowRunPayload](#workflowrunpayload) | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [WorkflowRunPayload](#workflowrunpayload)
| -### /installed-apps/{installed_app_id}/workflows/tasks/{task_id}/stop +#### Responses -#### POST -##### Summary +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| -Stop workflow task +### [POST] /installed-apps/{installed_app_id}/workflows/tasks/{task_id}/stop +**Stop workflow task** -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | installed_app_id | path | | Yes | string | | task_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SimpleResultResponse](#simpleresultresponse) | - -### /instruction-generate - -#### POST -##### Description +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +### [POST] /instruction-generate Generate instruction for workflow nodes or general use -##### Parameters +#### Request Body -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [InstructionGeneratePayload](#instructiongeneratepayload) | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [InstructionGeneratePayload](#instructiongeneratepayload)
| -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Instruction generated successfully | -| 400 | Invalid request parameters or flow/workflow not found | -| 402 | Provider quota exceeded | - -### /instruction-generate/template - -#### POST -##### Description +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Instruction generated successfully | **application/json**: [GeneratorResponse](#generatorresponse)
| +| 400 | Invalid request parameters or flow/workflow not found | | +| 402 | Provider quota exceeded | | +### [POST] /instruction-generate/template Get instruction generation template -##### Parameters +#### Request Body -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [InstructionTemplatePayload](#instructiontemplatepayload) | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [InstructionTemplatePayload](#instructiontemplatepayload)
| -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Template retrieved successfully | -| 400 | Invalid request parameters | - -### /login - -#### POST -##### Summary - -Authenticate user and login - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [LoginPayload](#loginpayload) | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SimpleResultOptionalDataResponse](#simpleresultoptionaldataresponse) | +| 200 | Template retrieved successfully | **application/json**: [SimpleDataResponse](#simpledataresponse)
| +| 400 | Invalid request parameters | | -### /logout +### [POST] /login +**Authenticate user and login** -#### POST -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [LoginPayload](#loginpayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SimpleResultResponse](#simpleresultresponse) | +| 200 | Success | **application/json**: [SimpleResultOptionalDataResponse](#simpleresultoptionaldataresponse)
| -### /mcp/oauth/callback +### [POST] /logout +#### Responses -#### GET -##### Responses +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| -| Code | Description | -| ---- | ----------- | -| 200 | Success | +### [GET] /mcp/oauth/callback +#### Parameters -### /notification +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| code | query | | Yes | string | +| state | query | | Yes | string | -#### GET -##### Description +#### Responses +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 302 | Redirect to console OAuth callback page | **application/json**: [RedirectResponse](#redirectresponse)
| + +### [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. -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success — inspect should_show to decide whether to render the modal | -| 401 | Unauthorized | - -### /notification/dismiss - -#### POST -##### Description - -Mark a notification as dismissed for the current user. - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SimpleResultResponse](#simpleresultresponse) | +| 200 | Success — inspect should_show to decide whether to render the modal | **application/json**: [NotificationResponse](#notificationresponse)
| | 401 | Unauthorized | | -### /notion/pages/{page_id}/{page_type}/preview +### [POST] /notification/dismiss +Mark a notification as dismissed for the current user. -#### GET -##### Parameters +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DismissNotificationPayload](#dismissnotificationpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +| 401 | Unauthorized | | + +### [GET] /notion/pages/{page_id}/{page_type}/preview +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | +| credential_id | query | Credential ID | Yes | string | | page_id | path | | Yes | string | | page_type | path | | Yes | string | -| credential_id | query | Credential ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [TextContentResponse](#textcontentresponse) | +| 200 | Success | **application/json**: [TextContentResponse](#textcontentresponse)
| -### /notion/pre-import/pages - -#### GET -##### Parameters +### [GET] /notion/pre-import/pages +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | credential_id | query | Credential ID | Yes | string | | dataset_id | query | Dataset ID | No | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [NotionIntegrateInfoListResponse](#notionintegrateinfolistresponse) | - -### /oauth/authorize/{provider} - -#### GET -##### Description +| 200 | Success | **application/json**: [NotionIntegrateInfoListResponse](#notionintegrateinfolistresponse)
| +### [GET] /oauth/authorize/{provider} Handle OAuth callback and complete login process -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | provider | path | OAuth provider name (github/google) | Yes | string | -| code | query | Authorization code from OAuth provider | No | string | -| state | query | Optional state parameter (used for invite token) | No | string | +| code | query | Authorization code from OAuth provider | Yes | string | +| state | query | OAuth state parameter | No | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 302 | Redirect to console with access token | -| 400 | OAuth process failed | - -### /oauth/data-source/binding/{provider} - -#### GET -##### Description +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 302 | Redirect to console with access token | **application/json**: [RedirectResponse](#redirectresponse)
| +| 400 | OAuth process failed | | +### [GET] /oauth/data-source/binding/{provider} Bind OAuth data source with authorization code -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | provider | path | Data source provider name (notion) | Yes | string | -| code | query | Authorization code from OAuth provider | No | string | +| code | query | Authorization code from OAuth provider | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Data source binding success | [OAuthDataSourceBindingResponse](#oauthdatasourcebindingresponse) | +| 200 | Data source binding success | **application/json**: [OAuthDataSourceBindingResponse](#oauthdatasourcebindingresponse)
| | 400 | Invalid provider or code | | -### /oauth/data-source/callback/{provider} - -#### GET -##### Description - +### [GET] /oauth/data-source/callback/{provider} Handle OAuth callback from data source provider -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | @@ -6919,1851 +6724,1687 @@ Handle OAuth callback from data source provider | code | query | Authorization code from OAuth provider | No | string | | error | query | Error message from OAuth provider | No | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 302 | Redirect to console with result | -| 400 | Invalid provider | - -### /oauth/data-source/{provider} - -#### GET -##### Description +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 302 | Redirect to console with result | **application/json**: [RedirectResponse](#redirectresponse)
| +| 400 | Invalid provider | | +### [GET] /oauth/data-source/{provider} Get OAuth authorization URL for data source provider -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | provider | path | Data source provider name (notion) | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Authorization URL or internal setup success | [OAuthDataSourceResponse](#oauthdatasourceresponse) | +| 200 | Authorization URL or internal setup success | **application/json**: [OAuthDataSourceResponse](#oauthdatasourceresponse)
| | 400 | Invalid provider | | | 403 | Admin privileges required | | -### /oauth/data-source/{provider}/{binding_id}/sync - -#### GET -##### Description - +### [GET] /oauth/data-source/{provider}/{binding_id}/sync Sync data from OAuth data source -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | binding_id | path | Data source binding ID | Yes | string | | provider | path | Data source provider name (notion) | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Data source sync success | [OAuthDataSourceSyncResponse](#oauthdatasourcesyncresponse) | +| 200 | Data source sync success | **application/json**: [OAuthDataSourceSyncResponse](#oauthdatasourcesyncresponse)
| | 400 | Invalid provider or sync failed | | -### /oauth/login/{provider} - -#### GET -##### Description - +### [GET] /oauth/login/{provider} Initiate OAuth login process -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | provider | path | OAuth provider name (github/google) | Yes | string | | invite_token | query | Optional invitation token | No | string | +| language | query | Preferred interface language | No | string | +| timezone | query | Preferred timezone | No | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 302 | Redirect to OAuth authorization URL | -| 400 | Invalid provider | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 302 | Redirect to OAuth authorization URL | **application/json**: [RedirectResponse](#redirectresponse)
| +| 400 | Invalid provider | | -### /oauth/plugin/{provider_id}/datasource/callback - -#### GET -##### Parameters +### [GET] /oauth/plugin/{provider_id}/datasource/callback +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | +| code | query | Authorization code from OAuth provider | No | string | +| context_id | query | OAuth proxy context ID | No | string | +| error | query | Error message from OAuth provider | No | string | +| state | query | OAuth state parameter | No | string | | provider_id | path | | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 302 | Redirect to console OAuth callback page | **application/json**: [RedirectResponse](#redirectresponse)
| -### /oauth/plugin/{provider_id}/datasource/get-authorization-url - -#### GET -##### Parameters +### [GET] /oauth/plugin/{provider_id}/datasource/get-authorization-url +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | +| credential_id | query | Credential ID to reauthorize | No | string | | provider_id | path | | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Authorization URL retrieved successfully | **application/json**: [PluginOAuthAuthorizationUrlResponse](#pluginoauthauthorizationurlresponse)
| -### /oauth/plugin/{provider}/tool/authorization-url - -#### GET -##### Parameters +### [GET] /oauth/plugin/{provider}/tool/authorization-url +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | provider | path | | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Authorization URL retrieved successfully | **application/json**: [PluginOAuthAuthorizationUrlResponse](#pluginoauthauthorizationurlresponse)
| -### /oauth/plugin/{provider}/tool/callback - -#### GET -##### Parameters +### [GET] /oauth/plugin/{provider}/tool/callback +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | provider | path | | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 302 | Redirect to console OAuth callback page | **application/json**: [RedirectResponse](#redirectresponse)
| -### /oauth/plugin/{provider}/trigger/callback +### [GET] /oauth/plugin/{provider}/trigger/callback +**Handle OAuth callback for trigger provider** -#### GET -##### Summary - -Handle OAuth callback for trigger provider - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | provider | path | | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 302 | Redirect to console OAuth callback page | **application/json**: [RedirectResponse](#redirectresponse)
| -### /oauth/provider +### [POST] /oauth/provider +#### Request Body -#### POST -##### Responses +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [OAuthProviderRequest](#oauthproviderrequest)
| -| Code | Description | -| ---- | ----------- | -| 200 | Success | +#### Responses -### /oauth/provider/account +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [OAuthProviderAppResponse](#oauthproviderappresponse)
| -#### POST -##### Responses +### [POST] /oauth/provider/account +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [OAuthClientPayload](#oauthclientpayload)
| -### /oauth/provider/authorize +#### Responses -#### POST -##### Responses +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [OAuthProviderAccountResponse](#oauthprovideraccountresponse)
| -| Code | Description | -| ---- | ----------- | -| 200 | Success | +### [POST] /oauth/provider/authorize +#### Request Body -### /oauth/provider/token +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [OAuthClientPayload](#oauthclientpayload)
| -#### POST -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [OAuthProviderAuthorizeResponse](#oauthproviderauthorizeresponse)
| -### /rag/pipeline/customized/templates/{template_id} +### [POST] /oauth/provider/token +#### Request Body -#### DELETE -##### Parameters +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [OAuthTokenRequest](#oauthtokenrequest)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [OAuthProviderTokenResponse](#oauthprovidertokenresponse)
| + +### [DELETE] /rag/pipeline/customized/templates/{template_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | template_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | | 204 | Pipeline template deleted | -#### PATCH -##### Parameters +### [PATCH] /rag/pipeline/customized/templates/{template_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | template_id | path | | Yes | string | -| payload | body | | Yes | [CustomizedPipelineTemplatePayload](#customizedpipelinetemplatepayload) | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [CustomizedPipelineTemplatePayload](#customizedpipelinetemplatepayload)
| + +#### Responses | Code | Description | | ---- | ----------- | | 204 | Pipeline template updated | -#### POST -##### Parameters +### [POST] /rag/pipeline/customized/templates/{template_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | template_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SimpleDataResponse](#simpledataresponse) | +| 200 | Success | **application/json**: [SimpleDataResponse](#simpledataresponse)
| -### /rag/pipeline/dataset +### [POST] /rag/pipeline/dataset +#### Request Body -#### POST -##### Parameters +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [RagPipelineDatasetImportPayload](#ragpipelinedatasetimportpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | RAG pipeline dataset import started | **application/json**: [RagPipelineImportResponse](#ragpipelineimportresponse)
| + +### [POST] /rag/pipeline/empty-dataset +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | RAG pipeline dataset created | **application/json**: [DatasetDetailResponse](#datasetdetailresponse)
| + +### [GET] /rag/pipeline/templates +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [RagPipelineDatasetImportPayload](#ragpipelinedatasetimportpayload) | +| language | query | Template language | No | string,
**Default:** en-US | +| type | query | Template source: built-in or customized | No | string,
**Default:** built-in | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 201 | RAG pipeline dataset import started | [RagPipelineImportResponse](#ragpipelineimportresponse) | +| 200 | Pipeline templates | **application/json**: [PipelineTemplateListResponse](#pipelinetemplatelistresponse)
| -### /rag/pipeline/empty-dataset - -#### POST -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 201 | RAG pipeline dataset created | [DatasetDetailResponse](#datasetdetailresponse) | - -### /rag/pipeline/templates - -#### GET -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| language | query | Template language | No | string | -| type | query | Template source: built-in or customized | No | string | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Pipeline templates | [PipelineTemplateListResponse](#pipelinetemplatelistresponse) | - -### /rag/pipeline/templates/{template_id} - -#### GET -##### Parameters +### [GET] /rag/pipeline/templates/{template_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | +| type | query | Template source: built-in or customized | No | string,
**Default:** built-in | | template_id | path | | Yes | string | -| type | query | Template source: built-in or customized | No | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Pipeline template | [PipelineTemplateDetailResponse](#pipelinetemplatedetailresponse) | +| 200 | Pipeline template | **application/json**: [PipelineTemplateDetailResponse](#pipelinetemplatedetailresponse)
| -### /rag/pipelines/datasource-plugins - -#### GET -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /rag/pipelines/imports - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [RagPipelineImportPayload](#ragpipelineimportpayload) | - -##### Responses +### [GET] /rag/pipelines/datasource-plugins +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Import completed | [RagPipelineImportResponse](#ragpipelineimportresponse) | -| 202 | Import pending confirmation | [RagPipelineImportResponse](#ragpipelineimportresponse) | -| 400 | Import failed | [RagPipelineImportResponse](#ragpipelineimportresponse) | +| 200 | Success | **application/json**: [RagPipelineOpaqueResponse](#ragpipelineopaqueresponse)
| -### /rag/pipelines/imports/{import_id}/confirm +### [POST] /rag/pipelines/imports +#### Request Body -#### POST -##### Parameters +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [RagPipelineImportPayload](#ragpipelineimportpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Import completed | **application/json**: [RagPipelineImportResponse](#ragpipelineimportresponse)
| +| 202 | Import pending confirmation | **application/json**: [RagPipelineImportResponse](#ragpipelineimportresponse)
| +| 400 | Import failed | **application/json**: [RagPipelineImportResponse](#ragpipelineimportresponse)
| + +### [POST] /rag/pipelines/imports/{import_id}/confirm +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | import_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Import confirmed | [RagPipelineImportResponse](#ragpipelineimportresponse) | -| 400 | Import failed | [RagPipelineImportResponse](#ragpipelineimportresponse) | +| 200 | Import confirmed | **application/json**: [RagPipelineImportResponse](#ragpipelineimportresponse)
| +| 400 | Import failed | **application/json**: [RagPipelineImportResponse](#ragpipelineimportresponse)
| -### /rag/pipelines/imports/{pipeline_id}/check-dependencies - -#### GET -##### Parameters +### [GET] /rag/pipelines/imports/{pipeline_id}/check-dependencies +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | pipeline_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Dependencies checked | [RagPipelineImportCheckDependenciesResponse](#ragpipelineimportcheckdependenciesresponse) | +| 200 | Dependencies checked | **application/json**: [RagPipelineImportCheckDependenciesResponse](#ragpipelineimportcheckdependenciesresponse)
| -### /rag/pipelines/recommended-plugins +### [GET] /rag/pipelines/recommended-plugins +#### Parameters -#### GET -##### Responses +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| type | query | | No | string,
**Default:** all | -| Code | Description | -| ---- | ----------- | -| 200 | Success | +#### Responses -### /rag/pipelines/transform/datasets/{dataset_id} +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [RagPipelineOpaqueResponse](#ragpipelineopaqueresponse)
| -#### POST -##### Parameters +### [POST] /rag/pipelines/transform/datasets/{dataset_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [RagPipelineOpaqueResponse](#ragpipelineopaqueresponse)
| -### /rag/pipelines/{pipeline_id}/customized/publish - -#### POST -##### Parameters +### [POST] /rag/pipelines/{pipeline_id}/customized/publish +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | pipeline_id | path | | Yes | string | -| payload | body | | Yes | [CustomizedPipelineTemplatePayload](#customizedpipelinetemplatepayload) | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [CustomizedPipelineTemplatePayload](#customizedpipelinetemplatepayload)
| + +#### Responses | Code | Description | | ---- | ----------- | | 204 | Pipeline template published | -### /rag/pipelines/{pipeline_id}/exports - -#### GET -##### Parameters +### [GET] /rag/pipelines/{pipeline_id}/exports +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | +| include_secret | query | Whether to include secret values in the exported DSL | No | string,
**Default:** false | | pipeline_id | path | | Yes | string | -| include_secret | query | Whether to include secret values in the exported DSL | No | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Pipeline exported | [SimpleDataResponse](#simpledataresponse) | +| 200 | Pipeline exported | **application/json**: [SimpleDataResponse](#simpledataresponse)
| -### /rag/pipelines/{pipeline_id}/workflow-runs +### [GET] /rag/pipelines/{pipeline_id}/workflow-runs +**Get workflow run list** -#### GET -##### Summary - -Get workflow run list - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | +| last_id | query | | No | string | +| limit | query | | No | integer,
**Default:** 20 | | pipeline_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow runs retrieved successfully | [WorkflowRunPaginationResponse](#workflowrunpaginationresponse) | +| 200 | Workflow runs retrieved successfully | **application/json**: [WorkflowRunPaginationResponse](#workflowrunpaginationresponse)
| -### /rag/pipelines/{pipeline_id}/workflow-runs/tasks/{task_id}/stop +### [POST] /rag/pipelines/{pipeline_id}/workflow-runs/tasks/{task_id}/stop +**Stop workflow task** -#### POST -##### Summary - -Stop workflow task - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | pipeline_id | path | | Yes | string | | task_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Task stopped successfully | [SimpleResultResponse](#simpleresultresponse) | +| 200 | Task stopped successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| -### /rag/pipelines/{pipeline_id}/workflow-runs/{run_id} +### [GET] /rag/pipelines/{pipeline_id}/workflow-runs/{run_id} +**Get workflow run detail** -#### GET -##### Summary - -Get workflow run detail - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | pipeline_id | path | | Yes | string | | run_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow run detail retrieved successfully | [WorkflowRunDetailResponse](#workflowrundetailresponse) | +| 200 | Workflow run detail retrieved successfully | **application/json**: [WorkflowRunDetailResponse](#workflowrundetailresponse)
| -### /rag/pipelines/{pipeline_id}/workflow-runs/{run_id}/node-executions +### [GET] /rag/pipelines/{pipeline_id}/workflow-runs/{run_id}/node-executions +**Get workflow run node execution list** -#### GET -##### Summary - -Get workflow run node execution list - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | pipeline_id | path | | Yes | string | | run_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Node executions retrieved successfully | [WorkflowRunNodeExecutionListResponse](#workflowrunnodeexecutionlistresponse) | +| 200 | Node executions retrieved successfully | **application/json**: [WorkflowRunNodeExecutionListResponse](#workflowrunnodeexecutionlistresponse)
| -### /rag/pipelines/{pipeline_id}/workflows +### [GET] /rag/pipelines/{pipeline_id}/workflows +**Get published workflows** -#### GET -##### Summary - -Get published workflows - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | +| limit | query | | No | integer,
**Default:** 10 | +| named_only | query | | No | boolean | +| page | query | | No | integer,
**Default:** 1 | +| user_id | query | | No | string | | pipeline_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Published workflows retrieved successfully | [WorkflowPaginationResponse](#workflowpaginationresponse) | +| 200 | Published workflows retrieved successfully | **application/json**: [WorkflowPaginationResponse](#workflowpaginationresponse)
| | 403 | Permission denied | | -### /rag/pipelines/{pipeline_id}/workflows/default-workflow-block-configs +### [GET] /rag/pipelines/{pipeline_id}/workflows/default-workflow-block-configs +**Get default block config** -#### GET -##### Summary - -Get default block config - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | pipeline_id | path | | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Default block configs retrieved successfully | **application/json**: [DefaultBlockConfigsResponse](#defaultblockconfigsresponse)
| -### /rag/pipelines/{pipeline_id}/workflows/default-workflow-block-configs/{block_type} +### [GET] /rag/pipelines/{pipeline_id}/workflows/default-workflow-block-configs/{block_type} +**Get default block config** -#### GET -##### Summary - -Get default block config - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | +| q | query | | No | string | | block_type | path | | Yes | string | | pipeline_id | path | | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Default block config retrieved successfully | **application/json**: [DefaultBlockConfigResponse](#defaultblockconfigresponse)
| -### /rag/pipelines/{pipeline_id}/workflows/draft +### [GET] /rag/pipelines/{pipeline_id}/workflows/draft +**Get draft rag pipeline's workflow** -#### GET -##### Summary - -Get draft rag pipeline's workflow - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | pipeline_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Draft workflow retrieved successfully | [WorkflowResponse](#workflowresponse) | +| 200 | Draft workflow retrieved successfully | **application/json**: [WorkflowResponse](#workflowresponse)
| | 404 | Draft workflow not found | | -#### POST -##### Summary +### [POST] /rag/pipelines/{pipeline_id}/workflows/draft +**Sync draft workflow** -Sync draft workflow - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | pipeline_id | path | | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DraftWorkflowSyncPayload](#draftworkflowsyncpayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [RagPipelineWorkflowSyncResponse](#ragpipelineworkflowsyncresponse) | +| 200 | Success | **application/json**: [RagPipelineWorkflowSyncResponse](#ragpipelineworkflowsyncresponse)
| -### /rag/pipelines/{pipeline_id}/workflows/draft/datasource/nodes/{node_id}/run +### [POST] /rag/pipelines/{pipeline_id}/workflows/draft/datasource/nodes/{node_id}/run +**Run rag pipeline datasource** -#### POST -##### Summary - -Run rag pipeline datasource - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| node_id | path | | Yes | string | -| pipeline_id | path | | Yes | string | -| payload | body | | Yes | [DatasourceNodeRunPayload](#datasourcenoderunpayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /rag/pipelines/{pipeline_id}/workflows/draft/datasource/variables-inspect - -#### POST -##### Summary - -Set datasource variables - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | -| payload | body | | Yes | [DatasourceVariablesPayload](#datasourcevariablespayload) | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Datasource variables set successfully | [WorkflowRunNodeExecutionResponse](#workflowrunnodeexecutionresponse) | - -### /rag/pipelines/{pipeline_id}/workflows/draft/environment-variables - -#### GET -##### Summary - -Get draft workflow - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /rag/pipelines/{pipeline_id}/workflows/draft/iteration/nodes/{node_id}/run - -#### POST -##### Summary - -Run draft workflow iteration node - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| node_id | path | | Yes | string | -| pipeline_id | path | | Yes | string | -| payload | body | | Yes | [NodeRunPayload](#noderunpayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /rag/pipelines/{pipeline_id}/workflows/draft/loop/nodes/{node_id}/run - -#### POST -##### Summary - -Run draft workflow loop node - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| node_id | path | | Yes | string | -| pipeline_id | path | | Yes | string | -| payload | body | | Yes | [NodeRunPayload](#noderunpayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /rag/pipelines/{pipeline_id}/workflows/draft/nodes/{node_id}/last-run - -#### GET -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | node_id | path | | Yes | string | | pipeline_id | path | | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DatasourceNodeRunPayload](#datasourcenoderunpayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Node last run retrieved successfully | [WorkflowRunNodeExecutionResponse](#workflowrunnodeexecutionresponse) | +| 200 | Success | **application/json**: [RagPipelineOpaqueResponse](#ragpipelineopaqueresponse)
| -### /rag/pipelines/{pipeline_id}/workflows/draft/nodes/{node_id}/run +### [POST] /rag/pipelines/{pipeline_id}/workflows/draft/datasource/variables-inspect +**Set datasource variables** -#### POST -##### Summary - -Run draft workflow node - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| node_id | path | | Yes | string | | pipeline_id | path | | Yes | string | -| payload | body | | Yes | [NodeRunRequiredPayload](#noderunrequiredpayload) | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DatasourceVariablesPayload](#datasourcevariablespayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Node run started successfully | [WorkflowRunNodeExecutionResponse](#workflowrunnodeexecutionresponse) | +| 200 | Datasource variables set successfully | **application/json**: [WorkflowRunNodeExecutionResponse](#workflowrunnodeexecutionresponse)
| -### /rag/pipelines/{pipeline_id}/workflows/draft/nodes/{node_id}/variables +### [GET] /rag/pipelines/{pipeline_id}/workflows/draft/environment-variables +**Get draft workflow** -#### DELETE -##### Parameters +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Environment variables retrieved successfully | **application/json**: [EnvironmentVariableListResponse](#environmentvariablelistresponse)
| + +### [POST] /rag/pipelines/{pipeline_id}/workflows/draft/iteration/nodes/{node_id}/run +**Run draft workflow iteration node** + +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | node_id | path | | Yes | string | | pipeline_id | path | | Yes | string | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [NodeRunPayload](#noderunpayload)
| -#### GET -##### Parameters +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [RagPipelineOpaqueResponse](#ragpipelineopaqueresponse)
| + +### [POST] /rag/pipelines/{pipeline_id}/workflows/draft/loop/nodes/{node_id}/run +**Run draft workflow loop node** + +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | node_id | path | | Yes | string | | pipeline_id | path | | Yes | string | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [NodeRunPayload](#noderunpayload)
| -### /rag/pipelines/{pipeline_id}/workflows/draft/pre-processing/parameters - -#### GET -##### Summary - -Get first step parameters of rag pipeline - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /rag/pipelines/{pipeline_id}/workflows/draft/processing/parameters - -#### GET -##### Summary - -Get second step parameters of rag pipeline - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /rag/pipelines/{pipeline_id}/workflows/draft/run - -#### POST -##### Summary - -Run draft workflow - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | -| payload | body | | Yes | [DraftWorkflowRunPayload](#draftworkflowrunpayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /rag/pipelines/{pipeline_id}/workflows/draft/system-variables - -#### GET -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /rag/pipelines/{pipeline_id}/workflows/draft/variables - -#### DELETE -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -#### GET -##### Summary - -Get draft workflow - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /rag/pipelines/{pipeline_id}/workflows/draft/variables/{variable_id} - -#### DELETE -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | -| variable_id | path | | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -#### GET -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | -| variable_id | path | | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -#### PATCH -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | -| variable_id | path | | Yes | string | -| payload | body | | Yes | [WorkflowDraftVariablePatchPayload](#workflowdraftvariablepatchpayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /rag/pipelines/{pipeline_id}/workflows/draft/variables/{variable_id}/reset - -#### PUT -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | -| variable_id | path | | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /rag/pipelines/{pipeline_id}/workflows/publish - -#### GET -##### Summary - -Get published pipeline - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Published workflow retrieved successfully, or null if not exist | [WorkflowResponse](#workflowresponse) | +| 200 | Success | **application/json**: [RagPipelineOpaqueResponse](#ragpipelineopaqueresponse)
| -#### POST -##### Summary - -Publish workflow - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | [RagPipelineWorkflowPublishResponse](#ragpipelineworkflowpublishresponse) | - -### /rag/pipelines/{pipeline_id}/workflows/published/datasource/nodes/{node_id}/preview - -#### POST -##### Summary - -Run datasource content preview - -##### Parameters +### [GET] /rag/pipelines/{pipeline_id}/workflows/draft/nodes/{node_id}/last-run +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | node_id | path | | Yes | string | | pipeline_id | path | | Yes | string | -| payload | body | | Yes | [Parser](#parser) | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Node last run retrieved successfully | **application/json**: [WorkflowRunNodeExecutionResponse](#workflowrunnodeexecutionresponse)
| -### /rag/pipelines/{pipeline_id}/workflows/published/datasource/nodes/{node_id}/run +### [POST] /rag/pipelines/{pipeline_id}/workflows/draft/nodes/{node_id}/run +**Run draft workflow node** -#### POST -##### Summary - -Run rag pipeline datasource - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | node_id | path | | Yes | string | | pipeline_id | path | | Yes | string | -| payload | body | | Yes | [DatasourceNodeRunPayload](#datasourcenoderunpayload) | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [NodeRunRequiredPayload](#noderunrequiredpayload)
| -### /rag/pipelines/{pipeline_id}/workflows/published/pre-processing/parameters - -#### GET -##### Summary - -Get first step parameters of rag pipeline - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /rag/pipelines/{pipeline_id}/workflows/published/processing/parameters - -#### GET -##### Summary - -Get second step parameters of rag pipeline - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /rag/pipelines/{pipeline_id}/workflows/published/run - -#### POST -##### Summary - -Run published workflow - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | -| payload | body | | Yes | [PublishedWorkflowRunPayload](#publishedworkflowrunpayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /rag/pipelines/{pipeline_id}/workflows/{workflow_id} - -#### DELETE -##### Summary - -Delete a published workflow version that is not currently active on the pipeline - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | -| workflow_id | path | | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 204 | Workflow deleted successfully | - -#### PATCH -##### Summary - -Update workflow attributes - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | -| workflow_id | path | | Yes | string | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow updated successfully | [WorkflowResponse](#workflowresponse) | -| 400 | No valid fields to update | | -| 403 | Permission denied | | -| 404 | Workflow not found | | +| 200 | Node run started successfully | **application/json**: [WorkflowRunNodeExecutionResponse](#workflowrunnodeexecutionresponse)
| -### /rag/pipelines/{pipeline_id}/workflows/{workflow_id}/restore - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | -| workflow_id | path | | Yes | string | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | [RagPipelineWorkflowSyncResponse](#ragpipelineworkflowsyncresponse) | - -### /refresh-token - -#### POST -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | [SimpleResultResponse](#simpleresultresponse) | - -### /remote-files/upload - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [RemoteFileUploadPayload](#remotefileuploadpayload) | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 201 | File uploaded successfully | [FileWithSignedUrl](#filewithsignedurl) | - -### /remote-files/{url} - -#### GET -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| url | path | | Yes | string | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | [RemoteFileInfo](#remotefileinfo) | - -### /reset-password - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [EmailPayload](#emailpayload) | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | [SimpleResultDataResponse](#simpleresultdataresponse) | - -### /rule-code-generate - -#### POST -##### Description - -Generate code rules using LLM - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [RuleCodeGeneratePayload](#rulecodegeneratepayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Code rules generated successfully | -| 400 | Invalid request parameters | -| 402 | Provider quota exceeded | - -### /rule-generate - -#### POST -##### Description - -Generate rule configuration using LLM - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [RuleGeneratePayload](#rulegeneratepayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Rule configuration generated successfully | -| 400 | Invalid request parameters | -| 402 | Provider quota exceeded | - -### /rule-structured-output-generate - -#### POST -##### Description - -Generate structured output rules using LLM - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [RuleStructuredOutputPayload](#rulestructuredoutputpayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Structured output generated successfully | -| 400 | Invalid request parameters | -| 402 | Provider quota exceeded | - -### /snippets/{snippet_id}/workflow-runs - -#### GET -##### Summary - -List workflow runs for snippet - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| snippet_id | path | | Yes | string | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Workflow runs retrieved successfully | [WorkflowRunPaginationResponse](#workflowrunpaginationresponse) | - -### /snippets/{snippet_id}/workflow-runs/tasks/{task_id}/stop - -#### POST -##### Summary - -Stop a running snippet workflow task - -##### Description - -Uses both the legacy stop flag mechanism and the graph engine -command channel for backward compatibility. - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| snippet_id | path | | Yes | string | -| task_id | path | | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Task stopped successfully | -| 404 | Snippet not found | - -### /snippets/{snippet_id}/workflow-runs/{run_id} - -#### GET -##### Summary - -Get workflow run detail for snippet - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| run_id | path | | Yes | string | -| snippet_id | path | | Yes | string | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Workflow run detail retrieved successfully | [WorkflowRunDetailResponse](#workflowrundetailresponse) | -| 404 | Workflow run not found | | - -### /snippets/{snippet_id}/workflow-runs/{run_id}/node-executions - -#### GET -##### Summary - -List node executions for a workflow run - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| run_id | path | | Yes | string | -| snippet_id | path | | Yes | string | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Node executions retrieved successfully | [WorkflowRunNodeExecutionListResponse](#workflowrunnodeexecutionlistresponse) | - -### /snippets/{snippet_id}/workflows - -#### GET -##### Summary - -Get all published workflow versions for snippet - -##### Description - -Get all published workflows for a snippet - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [SnippetWorkflowListQuery](#snippetworkflowlistquery) | -| snippet_id | path | Snippet ID | Yes | string | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Published workflows retrieved successfully | [WorkflowPaginationResponse](#workflowpaginationresponse) | - -### /snippets/{snippet_id}/workflows/default-workflow-block-configs - -#### GET -##### Summary - -Get default block configurations for snippet workflow - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| snippet_id | path | | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Default block configs retrieved successfully | - -### /snippets/{snippet_id}/workflows/draft - -#### GET -##### Summary - -Get draft workflow for snippet - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| snippet_id | path | | Yes | string | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Draft workflow retrieved successfully | [SnippetWorkflowResponse](#snippetworkflowresponse) | -| 404 | Snippet or draft workflow not found | | - -#### POST -##### Summary - -Sync draft workflow for snippet - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| snippet_id | path | | Yes | string | -| payload | body | | Yes | [SnippetDraftSyncPayload](#snippetdraftsyncpayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Draft workflow synced successfully | -| 400 | Hash mismatch | - -### /snippets/{snippet_id}/workflows/draft/config - -#### GET -##### Summary - -Get snippet draft workflow configuration limits - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| snippet_id | path | | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Draft config retrieved successfully | - -### /snippets/{snippet_id}/workflows/draft/conversation-variables - -#### GET -##### Description - -Conversation variables are not used in snippet workflows; returns an empty list for API parity - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| snippet_id | path | | Yes | string | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Conversation variables retrieved successfully | [WorkflowDraftVariableList](#workflowdraftvariablelist) | - -### /snippets/{snippet_id}/workflows/draft/environment-variables - -#### GET -##### Description - -Get environment variables from snippet draft workflow graph - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| snippet_id | path | | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Environment variables retrieved successfully | -| 404 | Draft workflow not found | - -### /snippets/{snippet_id}/workflows/draft/iteration/nodes/{node_id}/run - -#### POST -##### Summary - -Run a draft workflow iteration node for snippet - -##### Description - -Run draft workflow iteration node for snippet -Iteration nodes execute their internal sub-graph multiple times over an input list. -Returns an SSE event stream with iteration progress and results. - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [SnippetIterationNodeRunPayload](#snippetiterationnoderunpayload) | -| node_id | path | Node ID | Yes | string | -| snippet_id | path | Snippet ID | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Iteration node run started successfully (SSE stream) | -| 404 | Snippet or draft workflow not found | - -### /snippets/{snippet_id}/workflows/draft/loop/nodes/{node_id}/run - -#### POST -##### Summary - -Run a draft workflow loop node for snippet - -##### Description - -Run draft workflow loop node for snippet -Loop nodes execute their internal sub-graph repeatedly until a condition is met. -Returns an SSE event stream with loop progress and results. - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [SnippetLoopNodeRunPayload](#snippetloopnoderunpayload) | -| node_id | path | Node ID | Yes | string | -| snippet_id | path | Snippet ID | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Loop node run started successfully (SSE stream) | -| 404 | Snippet or draft workflow not found | - -### /snippets/{snippet_id}/workflows/draft/nodes/{node_id}/last-run - -#### GET -##### Summary - -Get the last run result for a specific node in snippet draft workflow - -##### Description - -Get last run result for a node in snippet draft workflow -Returns the most recent execution record for the given node, -including status, inputs, outputs, and timing information. - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| node_id | path | Node ID | Yes | string | -| snippet_id | path | Snippet ID | Yes | string | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Node last run retrieved successfully | [WorkflowRunNodeExecutionResponse](#workflowrunnodeexecutionresponse) | -| 404 | Snippet, draft workflow, or node last run not found | | - -### /snippets/{snippet_id}/workflows/draft/nodes/{node_id}/run - -#### POST -##### Summary - -Run a single node in snippet draft workflow - -##### Description - -Run a single node in snippet draft workflow (single-step debugging) -Executes a specific node with provided inputs for single-step debugging. -Returns the node execution result including status, outputs, and timing. - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [SnippetDraftNodeRunPayload](#snippetdraftnoderunpayload) | -| node_id | path | Node ID | Yes | string | -| snippet_id | path | Snippet ID | Yes | string | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Node run completed successfully | [WorkflowRunNodeExecutionResponse](#workflowrunnodeexecutionresponse) | -| 404 | Snippet or draft workflow not found | | - -### /snippets/{snippet_id}/workflows/draft/nodes/{node_id}/variables - -#### DELETE -##### Description - -Delete all variables for a specific node (snippet draft workflow) - -##### Parameters +### [DELETE] /rag/pipelines/{pipeline_id}/workflows/draft/nodes/{node_id}/variables +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | node_id | path | | Yes | string | -| snippet_id | path | | Yes | string | +| pipeline_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | | 204 | Node variables deleted successfully | -#### GET -##### Description +### [GET] /rag/pipelines/{pipeline_id}/workflows/draft/nodes/{node_id}/variables +#### Parameters -Get variables for a specific node (snippet draft workflow) +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| node_id | path | | Yes | string | +| pipeline_id | path | | Yes | string | -##### Parameters +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Node variables retrieved successfully | **application/json**: [WorkflowDraftVariableList](#workflowdraftvariablelist)
| + +### [GET] /rag/pipelines/{pipeline_id}/workflows/draft/pre-processing/parameters +**Get first step parameters of rag pipeline** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| node_id | query | | Yes | string | +| pipeline_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [RagPipelineStepParametersResponse](#ragpipelinestepparametersresponse)
| + +### [GET] /rag/pipelines/{pipeline_id}/workflows/draft/processing/parameters +**Get second step parameters of rag pipeline** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| node_id | query | | Yes | string | +| pipeline_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [RagPipelineStepParametersResponse](#ragpipelinestepparametersresponse)
| + +### [POST] /rag/pipelines/{pipeline_id}/workflows/draft/run +**Run draft workflow** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DraftWorkflowRunPayload](#draftworkflowrunpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [RagPipelineOpaqueResponse](#ragpipelineopaqueresponse)
| + +### [GET] /rag/pipelines/{pipeline_id}/workflows/draft/system-variables +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | System variables retrieved successfully | **application/json**: [WorkflowDraftVariableList](#workflowdraftvariablelist)
| + +### [DELETE] /rag/pipelines/{pipeline_id}/workflows/draft/variables +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | + +#### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Workflow variables deleted successfully | + +### [GET] /rag/pipelines/{pipeline_id}/workflows/draft/variables +**Get draft workflow** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| limit | query | | No | integer,
**Default:** 20 | +| page | query | | No | integer,
**Default:** 1 | +| pipeline_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow variables retrieved successfully | **application/json**: [WorkflowDraftVariableListWithoutValue](#workflowdraftvariablelistwithoutvalue)
| + +### [DELETE] /rag/pipelines/{pipeline_id}/workflows/draft/variables/{variable_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | +| variable_id | path | | Yes | string | + +#### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Variable deleted successfully | + +### [GET] /rag/pipelines/{pipeline_id}/workflows/draft/variables/{variable_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | +| variable_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Variable retrieved successfully | **application/json**: [WorkflowDraftVariable](#workflowdraftvariable)
| + +### [PATCH] /rag/pipelines/{pipeline_id}/workflows/draft/variables/{variable_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | +| variable_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [WorkflowDraftVariablePatchPayload](#workflowdraftvariablepatchpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Variable updated successfully | **application/json**: [WorkflowDraftVariable](#workflowdraftvariable)
| + +### [PUT] /rag/pipelines/{pipeline_id}/workflows/draft/variables/{variable_id}/reset +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | +| variable_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Variable reset successfully | **application/json**: [WorkflowDraftVariable](#workflowdraftvariable)
| +| 204 | Variable reset (no content) | | + +### [GET] /rag/pipelines/{pipeline_id}/workflows/publish +**Get published pipeline** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Published workflow retrieved successfully, or null if not exist | **application/json**: [WorkflowResponse](#workflowresponse)
| + +### [POST] /rag/pipelines/{pipeline_id}/workflows/publish +**Publish workflow** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [RagPipelineWorkflowPublishResponse](#ragpipelineworkflowpublishresponse)
| + +### [POST] /rag/pipelines/{pipeline_id}/workflows/published/datasource/nodes/{node_id}/preview +**Run datasource content preview** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| node_id | path | | Yes | string | +| pipeline_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [Parser](#parser)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [DataSourceContentPreviewResponse](#datasourcecontentpreviewresponse)
| + +### [POST] /rag/pipelines/{pipeline_id}/workflows/published/datasource/nodes/{node_id}/run +**Run rag pipeline datasource** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| node_id | path | | Yes | string | +| pipeline_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DatasourceNodeRunPayload](#datasourcenoderunpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [RagPipelineOpaqueResponse](#ragpipelineopaqueresponse)
| + +### [GET] /rag/pipelines/{pipeline_id}/workflows/published/pre-processing/parameters +**Get first step parameters of rag pipeline** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| node_id | query | | Yes | string | +| pipeline_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [RagPipelineStepParametersResponse](#ragpipelinestepparametersresponse)
| + +### [GET] /rag/pipelines/{pipeline_id}/workflows/published/processing/parameters +**Get second step parameters of rag pipeline** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| node_id | query | | Yes | string | +| pipeline_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [RagPipelineStepParametersResponse](#ragpipelinestepparametersresponse)
| + +### [POST] /rag/pipelines/{pipeline_id}/workflows/published/run +**Run published workflow** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [PublishedWorkflowRunPayload](#publishedworkflowrunpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [RagPipelineOpaqueResponse](#ragpipelineopaqueresponse)
| + +### [DELETE] /rag/pipelines/{pipeline_id}/workflows/{workflow_id} +**Delete a published workflow version that is not currently active on the pipeline** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | +| workflow_id | path | | Yes | string | + +#### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Workflow deleted successfully | + +### [PATCH] /rag/pipelines/{pipeline_id}/workflows/{workflow_id} +**Update workflow attributes** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | +| workflow_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [WorkflowUpdatePayload](#workflowupdatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow updated successfully | **application/json**: [WorkflowResponse](#workflowresponse)
| +| 400 | No valid fields to update | | +| 403 | Permission denied | | +| 404 | Workflow not found | | + +### [POST] /rag/pipelines/{pipeline_id}/workflows/{workflow_id}/restore +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | +| workflow_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [RagPipelineWorkflowSyncResponse](#ragpipelineworkflowsyncresponse)
| + +### [POST] /refresh-token +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| + +### [POST] /remote-files/upload +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [RemoteFileUploadPayload](#remotefileuploadpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | File uploaded successfully | **application/json**: [FileWithSignedUrl](#filewithsignedurl)
| + +### [GET] /remote-files/{url} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| url | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [RemoteFileInfo](#remotefileinfo)
| + +### [POST] /reset-password +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [EmailPayload](#emailpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultDataResponse](#simpleresultdataresponse)
| + +### [POST] /rule-code-generate +Generate code rules using LLM + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [RuleCodeGeneratePayload](#rulecodegeneratepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Code rules generated successfully | **application/json**: [GeneratorResponse](#generatorresponse)
| +| 400 | Invalid request parameters | | +| 402 | Provider quota exceeded | | + +### [POST] /rule-generate +Generate rule configuration using LLM + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [RuleGeneratePayload](#rulegeneratepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Rule configuration generated successfully | **application/json**: [GeneratorResponse](#generatorresponse)
| +| 400 | Invalid request parameters | | +| 402 | Provider quota exceeded | | + +### [POST] /rule-structured-output-generate +Generate structured output rules using LLM + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [RuleStructuredOutputPayload](#rulestructuredoutputpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Structured output generated successfully | **application/json**: [GeneratorResponse](#generatorresponse)
| +| 400 | Invalid request parameters | | +| 402 | Provider quota exceeded | | + +### [GET] /snippets/{snippet_id}/workflow-runs +**List workflow runs for snippet** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| last_id | query | | No | string | +| limit | query | | No | integer,
**Default:** 20 | +| snippet_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow runs retrieved successfully | **application/json**: [WorkflowRunPaginationResponse](#workflowrunpaginationresponse)
| + +### [POST] /snippets/{snippet_id}/workflow-runs/tasks/{task_id}/stop +**Stop a running snippet workflow task** + +Uses both the legacy stop flag mechanism and the graph engine +command channel for backward compatibility. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| snippet_id | path | | Yes | string | +| task_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Task stopped successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +| 404 | Snippet not found | | + +### [GET] /snippets/{snippet_id}/workflow-runs/{run_id} +**Get workflow run detail for snippet** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| run_id | path | | Yes | string | +| snippet_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow run detail retrieved successfully | **application/json**: [WorkflowRunDetailResponse](#workflowrundetailresponse)
| +| 404 | Workflow run not found | | + +### [GET] /snippets/{snippet_id}/workflow-runs/{run_id}/node-executions +**List node executions for a workflow run** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| run_id | path | | Yes | string | +| snippet_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Node executions retrieved successfully | **application/json**: [WorkflowRunNodeExecutionListResponse](#workflowrunnodeexecutionlistresponse)
| + +### [GET] /snippets/{snippet_id}/workflows +**Get all published workflow versions for snippet** + +Get all published workflows for a snippet + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| snippet_id | path | Snippet ID | Yes | string | +| limit | query | | No | integer,
**Default:** 10 | +| page | query | | No | integer,
**Default:** 1 | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Published workflows retrieved successfully | **application/json**: [WorkflowPaginationResponse](#workflowpaginationresponse)
| + +### [GET] /snippets/{snippet_id}/workflows/default-workflow-block-configs +**Get default block configurations for snippet workflow** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| snippet_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Default block configs retrieved successfully | **application/json**: [DefaultBlockConfigsResponse](#defaultblockconfigsresponse)
| + +### [GET] /snippets/{snippet_id}/workflows/draft +**Get draft workflow for snippet** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| snippet_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Draft workflow retrieved successfully | **application/json**: [SnippetWorkflowResponse](#snippetworkflowresponse)
| +| 404 | Snippet or draft workflow not found | | + +### [POST] /snippets/{snippet_id}/workflows/draft +**Sync draft workflow for snippet** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| snippet_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [SnippetDraftSyncPayload](#snippetdraftsyncpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Draft workflow synced successfully | **application/json**: [WorkflowRestoreResponse](#workflowrestoreresponse)
| +| 400 | Hash mismatch | | + +### [GET] /snippets/{snippet_id}/workflows/draft/config +**Get snippet draft workflow configuration limits** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| snippet_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Draft config retrieved successfully | **application/json**: [SnippetDraftConfigResponse](#snippetdraftconfigresponse)
| + +### [GET] /snippets/{snippet_id}/workflows/draft/conversation-variables +Conversation variables are not used in snippet workflows; returns an empty list for API parity + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| snippet_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Conversation variables retrieved successfully | **application/json**: [WorkflowDraftVariableList](#workflowdraftvariablelist)
| + +### [GET] /snippets/{snippet_id}/workflows/draft/environment-variables +Get environment variables from snippet draft workflow graph + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| snippet_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Environment variables retrieved successfully | **application/json**: [EnvironmentVariableListResponse](#environmentvariablelistresponse)
| +| 404 | Draft workflow not found | | + +### [POST] /snippets/{snippet_id}/workflows/draft/iteration/nodes/{node_id}/run +**Run a draft workflow iteration node for snippet** + +Run draft workflow iteration node for snippet +Iteration nodes execute their internal sub-graph multiple times over an input list. +Returns an SSE event stream with iteration progress and results. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| node_id | path | Node ID | Yes | string | +| snippet_id | path | Snippet ID | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [SnippetIterationNodeRunPayload](#snippetiterationnoderunpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Iteration node run started successfully (SSE stream) | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| +| 404 | Snippet or draft workflow not found | | + +### [POST] /snippets/{snippet_id}/workflows/draft/loop/nodes/{node_id}/run +**Run a draft workflow loop node for snippet** + +Run draft workflow loop node for snippet +Loop nodes execute their internal sub-graph repeatedly until a condition is met. +Returns an SSE event stream with loop progress and results. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| node_id | path | Node ID | Yes | string | +| snippet_id | path | Snippet ID | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [SnippetLoopNodeRunPayload](#snippetloopnoderunpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Loop node run started successfully (SSE stream) | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| +| 404 | Snippet or draft workflow not found | | + +### [GET] /snippets/{snippet_id}/workflows/draft/nodes/{node_id}/last-run +**Get the last run result for a specific node in snippet draft workflow** + +Get last run result for a node in snippet draft workflow +Returns the most recent execution record for the given node, +including status, inputs, outputs, and timing information. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| node_id | path | Node ID | Yes | string | +| snippet_id | path | Snippet ID | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Node last run retrieved successfully | **application/json**: [WorkflowRunNodeExecutionResponse](#workflowrunnodeexecutionresponse)
| +| 404 | Snippet, draft workflow, or node last run not found | | + +### [POST] /snippets/{snippet_id}/workflows/draft/nodes/{node_id}/run +**Run a single node in snippet draft workflow** + +Run a single node in snippet draft workflow (single-step debugging) +Executes a specific node with provided inputs for single-step debugging. +Returns the node execution result including status, outputs, and timing. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| node_id | path | Node ID | Yes | string | +| snippet_id | path | Snippet ID | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [SnippetDraftNodeRunPayload](#snippetdraftnoderunpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Node run completed successfully | **application/json**: [WorkflowRunNodeExecutionResponse](#workflowrunnodeexecutionresponse)
| +| 404 | Snippet or draft workflow not found | | + +### [DELETE] /snippets/{snippet_id}/workflows/draft/nodes/{node_id}/variables +Delete all variables for a specific node (snippet draft workflow) + +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | node_id | path | | Yes | string | | snippet_id | path | | Yes | string | -##### Responses +#### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Node variables deleted successfully | + +### [GET] /snippets/{snippet_id}/workflows/draft/nodes/{node_id}/variables +Get variables for a specific node (snippet draft workflow) + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| node_id | path | | Yes | string | +| snippet_id | path | | Yes | string | + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Node variables retrieved successfully | [WorkflowDraftVariableList](#workflowdraftvariablelist) | +| 200 | Node variables retrieved successfully | **application/json**: [WorkflowDraftVariableList](#workflowdraftvariablelist)
| -### /snippets/{snippet_id}/workflows/draft/run - -#### POST -##### Summary - -Run draft workflow for snippet - -##### Description +### [POST] /snippets/{snippet_id}/workflows/draft/run +**Run draft workflow for snippet** Executes the snippet's draft workflow with the provided inputs and returns an SSE event stream with execution progress and results. -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| snippet_id | path | | Yes | string | -| payload | body | | Yes | [SnippetDraftRunPayload](#snippetdraftrunpayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Draft workflow run started successfully (SSE stream) | -| 404 | Snippet or draft workflow not found | - -### /snippets/{snippet_id}/workflows/draft/system-variables - -#### GET -##### Description - -System variables are not used in snippet workflows; returns an empty list for API parity - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | snippet_id | path | | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [SnippetDraftRunPayload](#snippetdraftrunpayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | System variables retrieved successfully | [WorkflowDraftVariableList](#workflowdraftvariablelist) | +| 200 | Draft workflow run started successfully (SSE stream) | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| +| 404 | Snippet or draft workflow not found | | -### /snippets/{snippet_id}/workflows/draft/variables +### [GET] /snippets/{snippet_id}/workflows/draft/system-variables +System variables are not used in snippet workflows; returns an empty list for API parity -#### DELETE -##### Description - -Delete all draft workflow variables for the current user (snippet scope) - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | snippet_id | path | | Yes | string | -##### Responses +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | System variables retrieved successfully | **application/json**: [WorkflowDraftVariableList](#workflowdraftvariablelist)
| + +### [DELETE] /snippets/{snippet_id}/workflows/draft/variables +Delete all draft workflow variables for the current user (snippet scope) + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| snippet_id | path | | Yes | string | + +#### Responses | Code | Description | | ---- | ----------- | | 204 | Workflow variables deleted successfully | -#### GET -##### Description - +### [GET] /snippets/{snippet_id}/workflows/draft/variables List draft workflow variables without values (paginated, snippet scope) -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | +| limit | query | Items per page | No | integer,
**Default:** 20 | +| page | query | Page number | No | integer,
**Default:** 1 | | snippet_id | path | | Yes | string | -| payload | body | | Yes | [WorkflowDraftVariableListQuery](#workflowdraftvariablelistquery) | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow variables retrieved successfully | [WorkflowDraftVariableListWithoutValue](#workflowdraftvariablelistwithoutvalue) | - -### /snippets/{snippet_id}/workflows/draft/variables/{variable_id} - -#### DELETE -##### Description +| 200 | Workflow variables retrieved successfully | **application/json**: [WorkflowDraftVariableListWithoutValue](#workflowdraftvariablelistwithoutvalue)
| +### [DELETE] /snippets/{snippet_id}/workflows/draft/variables/{variable_id} Delete a draft workflow variable (snippet scope) -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | snippet_id | path | | Yes | string | | variable_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | | 204 | Variable deleted successfully | | 404 | Variable not found | -#### GET -##### Description - +### [GET] /snippets/{snippet_id}/workflows/draft/variables/{variable_id} Get a specific draft workflow variable (snippet scope) -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | snippet_id | path | | Yes | string | | variable_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Variable retrieved successfully | [WorkflowDraftVariable](#workflowdraftvariable) | +| 200 | Variable retrieved successfully | **application/json**: [WorkflowDraftVariable](#workflowdraftvariable)
| | 404 | Variable not found | | -#### PATCH -##### Description - +### [PATCH] /snippets/{snippet_id}/workflows/draft/variables/{variable_id} Update a draft workflow variable (snippet scope) -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | snippet_id | path | | Yes | string | | variable_id | path | | Yes | string | -| payload | body | | Yes | [WorkflowDraftVariableUpdatePayload](#workflowdraftvariableupdatepayload) | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [WorkflowDraftVariableUpdatePayload](#workflowdraftvariableupdatepayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Variable updated successfully | [WorkflowDraftVariable](#workflowdraftvariable) | +| 200 | Variable updated successfully | **application/json**: [WorkflowDraftVariable](#workflowdraftvariable)
| | 404 | Variable not found | | -### /snippets/{snippet_id}/workflows/draft/variables/{variable_id}/reset - -#### PUT -##### Description - +### [PUT] /snippets/{snippet_id}/workflows/draft/variables/{variable_id}/reset Reset a draft workflow variable to its default value (snippet scope) -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | snippet_id | path | | Yes | string | | variable_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Variable reset successfully | [WorkflowDraftVariable](#workflowdraftvariable) | +| 200 | Variable reset successfully | **application/json**: [WorkflowDraftVariable](#workflowdraftvariable)
| | 204 | Variable reset (no content) | | | 404 | Variable not found | | -### /snippets/{snippet_id}/workflows/publish +### [GET] /snippets/{snippet_id}/workflows/publish +**Get published workflow for snippet** -#### GET -##### Summary - -Get published workflow for snippet - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | snippet_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Published workflow retrieved successfully | [SnippetWorkflowResponse](#snippetworkflowresponse) | +| 200 | Published workflow retrieved successfully | **application/json**: [SnippetWorkflowResponse](#snippetworkflowresponse)
| | 404 | Snippet not found | | -#### POST -##### Summary +### [POST] /snippets/{snippet_id}/workflows/publish +**Publish snippet workflow** -Publish snippet workflow - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | snippet_id | path | | Yes | string | -| payload | body | | Yes | [PublishWorkflowPayload](#publishworkflowpayload) | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Workflow published successfully | -| 400 | No draft workflow found | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [PublishWorkflowPayload](#publishworkflowpayload)
| -### /snippets/{snippet_id}/workflows/{workflow_id}/restore +#### Responses -#### POST -##### Summary +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow published successfully | **application/json**: [WorkflowPublishResponse](#workflowpublishresponse)
| +| 400 | No draft workflow found | | -Restore a published snippet workflow version into the draft workflow +### [POST] /snippets/{snippet_id}/workflows/{workflow_id}/restore +**Restore a published snippet workflow version into the draft workflow** -##### Description - -Restore a published snippet workflow version into the draft workflow - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | snippet_id | path | Snippet ID | Yes | string | | workflow_id | path | Published workflow ID | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Workflow restored successfully | -| 400 | Source workflow must be published | -| 404 | Workflow not found | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow restored successfully | **application/json**: [WorkflowRestoreResponse](#workflowrestoreresponse)
| +| 400 | Source workflow must be published | | +| 404 | Workflow not found | | -### /spec/schema-definitions - -#### GET -##### Summary - -Get system JSON Schema definitions specification - -##### Description +### [GET] /spec/schema-definitions +**Get system JSON Schema definitions specification** Used for frontend component type mapping -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SchemaDefinitionsResponse](#schemadefinitionsresponse)
| -### /system-features - -#### GET -##### Summary - -Get system-wide feature configuration - -##### Description +### [GET] /system-features +**Get system-wide feature configuration** Get system-wide feature configuration NOTE: This endpoint is unauthenticated by design, as it provides system features @@ -8773,2772 +8414,2610 @@ Authentication would create circular dependency (can't login without dashboard l Only non-sensitive configuration data should be returned by this endpoint. -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SystemFeatureModel](#systemfeaturemodel) | +| 200 | Success | **application/json**: [SystemFeatureModel](#systemfeaturemodel)
| -### /tag-bindings +### [POST] /tag-bindings +#### Request Body -#### POST -##### Parameters +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [TagBindingPayload](#tagbindingpayload)
| -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [TagBindingPayload](#tagbindingpayload) | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SimpleResultResponse](#simpleresultresponse) | - -### /tag-bindings/remove - -#### POST -##### Description +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +### [POST] /tag-bindings/remove Remove one or more tag bindings from a target. -##### Parameters +#### Request Body -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [TagBindingRemovePayload](#tagbindingremovepayload) | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [TagBindingRemovePayload](#tagbindingremovepayload)
| -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SimpleResultResponse](#simpleresultresponse) | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| -### /tags - -#### GET -##### Parameters +### [GET] /tags +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| keyword | query | Search keyword for tag name. | No | string | -| type | query | Tag type filter. Can be "knowledge", "app", or "snippet". | No | string | +| keyword | query | Search keyword | No | string | +| type | query | Tag type filter | No | string,
**Available values:** "", "app", "knowledge", "snippet" | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [ [TagResponse](#tagresponse) ] | +| 200 | Success | **application/json**: [ [TagResponse](#tagresponse) ]
| -#### POST -##### Parameters +### [POST] /tags +#### Request Body -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [TagBasePayload](#tagbasepayload) | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [TagBasePayload](#tagbasepayload)
| -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [TagResponse](#tagresponse) | +| 200 | Success | **application/json**: [TagResponse](#tagresponse)
| -### /tags/{tag_id} - -#### DELETE -##### Parameters +### [DELETE] /tags/{tag_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | tag_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | | 204 | Tag deleted successfully | -#### PATCH -##### Parameters +### [PATCH] /tags/{tag_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | tag_id | path | | Yes | string | -| payload | body | | Yes | [TagUpdateRequestPayload](#tagupdaterequestpayload) | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [TagUpdateRequestPayload](#tagupdaterequestpayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [TagResponse](#tagresponse) | - -### /test/retrieval - -#### POST -##### Description +| 200 | Success | **application/json**: [TagResponse](#tagresponse)
| +### [POST] /test/retrieval Bedrock retrieval test (internal use only) -##### Parameters +#### Request Body -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [BedrockRetrievalPayload](#bedrockretrievalpayload) | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [BedrockRetrievalPayload](#bedrockretrievalpayload)
| -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Bedrock retrieval test completed | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Bedrock retrieval test completed | **application/json**: [ExternalRetrievalTestResponse](#externalretrievaltestresponse)
| -### /trial-apps/{app_id} +### [GET] /trial-apps/{app_id} +**Get app detail** -#### GET -##### Summary - -Get app detail - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [TrialAppDetailWithSite](#trialappdetailwithsite)
| -### /trial-apps/{app_id}/audio-to-text - -#### POST -##### Parameters +### [POST] /trial-apps/{app_id}/audio-to-text +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [AudioTranscriptResponse](#audiotranscriptresponse)
| -### /trial-apps/{app_id}/chat-messages - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | -| payload | body | | Yes | [ChatRequest](#chatrequest) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /trial-apps/{app_id}/completion-messages - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | -| payload | body | | Yes | [CompletionRequest](#completionrequest) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /trial-apps/{app_id}/datasets - -#### GET -##### Parameters +### [POST] /trial-apps/{app_id}/chat-messages +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | | Yes | string | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ChatRequest](#chatrequest)
| -### /trial-apps/{app_id}/messages/{message_id}/suggested-questions +#### Responses -#### GET -##### Parameters +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| + +### [POST] /trial-apps/{app_id}/completion-messages +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [CompletionRequest](#completionrequest)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| + +### [GET] /trial-apps/{app_id}/datasets +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| ids | query | Dataset IDs | No | [ string ] | +| limit | query | Number of items per page | No | integer,
**Default:** 20 | +| page | query | Page number | No | integer,
**Default:** 1 | +| app_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [TrialDatasetList](#trialdatasetlist)
| + +### [GET] /trial-apps/{app_id}/messages/{message_id}/suggested-questions +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | | Yes | string | | message_id | path | | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SuggestedQuestionsResponse](#suggestedquestionsresponse)
| -### /trial-apps/{app_id}/parameters +### [GET] /trial-apps/{app_id}/parameters +**Retrieve app parameters** -#### GET -##### Summary - -Retrieve app parameters - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [Parameters](#parameters)
| -### /trial-apps/{app_id}/site - -#### GET -##### Summary - -Retrieve app site info - -##### Description +### [GET] /trial-apps/{app_id}/site +**Retrieve app site info** Returns the site configuration for the application including theme, icons, and text. -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [Site](#site)
| -### /trial-apps/{app_id}/text-to-audio - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | -| payload | body | | Yes | [TextToSpeechRequest](#texttospeechrequest) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /trial-apps/{app_id}/workflows - -#### GET -##### Summary - -Get workflow detail - -##### Parameters +### [POST] /trial-apps/{app_id}/text-to-audio +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | | Yes | string | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [TextToSpeechRequest](#texttospeechrequest)
| -### /trial-apps/{app_id}/workflows/run +#### Responses -#### POST -##### Summary +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [AudioBinaryResponse](#audiobinaryresponse)
| -Run workflow +### [GET] /trial-apps/{app_id}/workflows +**Get workflow detail** -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | | Yes | string | -| payload | body | | Yes | [WorkflowRunRequest](#workflowrunrequest) | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [TrialWorkflow](#trialworkflow)
| -### /trial-apps/{app_id}/workflows/tasks/{task_id}/stop +### [POST] /trial-apps/{app_id}/workflows/run +**Run workflow** -#### POST -##### Summary +#### Parameters -Stop workflow task +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | -##### Parameters +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [WorkflowRunRequest](#workflowrunrequest)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| + +### [POST] /trial-apps/{app_id}/workflows/tasks/{task_id}/stop +**Stop workflow task** + +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | | Yes | string | | task_id | path | | Yes | string | -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /trial-models - -#### GET -##### Summary - -Get hosted trial model provider configuration for model-provider pages - -##### Description - -Get hosted trial model provider configuration - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [TrialModelsResponse](#trialmodelsresponse) | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| -### /website/crawl +### [GET] /trial-models +**Get hosted trial model provider configuration for model-provider pages** -#### POST -##### Description +Get hosted trial model provider configuration +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [TrialModelsResponse](#trialmodelsresponse)
| + +### [POST] /website/crawl Crawl website content -##### Parameters +#### Request Body -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [WebsiteCrawlPayload](#websitecrawlpayload) | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [WebsiteCrawlPayload](#websitecrawlpayload)
| -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Website crawl initiated successfully | -| 400 | Invalid crawl parameters | - -### /website/crawl/status/{job_id} - -#### GET -##### Description +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Website crawl initiated successfully | **application/json**: [WebsiteCrawlResponse](#websitecrawlresponse)
| +| 400 | Invalid crawl parameters | | +### [GET] /website/crawl/status/{job_id} Get website crawl status -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [WebsiteCrawlStatusQuery](#websitecrawlstatusquery) | | job_id | path | Crawl job ID | Yes | string | -| provider | query | Crawl provider (firecrawl/watercrawl/jinareader) | No | string | +| provider | query | Crawl provider (firecrawl/watercrawl/jinareader) | Yes | string,
**Available values:** "firecrawl", "jinareader", "watercrawl" | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Crawl status retrieved successfully | -| 400 | Invalid provider | -| 404 | Crawl job not found | - -### /workflow-generate - -#### POST -##### Description +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Crawl status retrieved successfully | **application/json**: [WebsiteCrawlResponse](#websitecrawlresponse)
| +| 400 | Invalid provider | | +| 404 | Crawl job not found | | +### [POST] /workflow-generate Generate a Dify workflow graph from natural language -##### Parameters +#### Request Body -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [WorkflowGeneratePayload](#workflowgeneratepayload) | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [WorkflowGeneratePayload](#workflowgeneratepayload)
| -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Workflow graph generated successfully | -| 400 | Invalid request parameters | -| 402 | Provider quota exceeded | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow graph generated successfully | **application/json**: [GeneratorResponse](#generatorresponse)
| +| 400 | Invalid request parameters | | +| 402 | Provider quota exceeded | | -### /workflow/{workflow_run_id}/events - -#### GET -##### Summary - -Get workflow execution events stream after resume - -##### Description +### [GET] /workflow/{workflow_run_id}/events +**Get workflow execution events stream after resume** GET /console/api/workflow//events Returns Server-Sent Events stream. -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | workflow_run_id | path | | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | SSE event stream | **application/json**: [EventStreamResponse](#eventstreamresponse)
| -### /workflow/{workflow_run_id}/pause-details - -#### GET -##### Summary - -Get workflow pause details - -##### Description +### [GET] /workflow/{workflow_run_id}/pause-details +**Get workflow pause details** Get workflow pause details GET /console/api/workflow//pause-details Returns information about why and where the workflow is paused. -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | workflow_run_id | path | Workflow run ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow pause details retrieved successfully | [WorkflowPauseDetailsResponse](#workflowpausedetailsresponse) | +| 200 | Workflow pause details retrieved successfully | **application/json**: [WorkflowPauseDetailsResponse](#workflowpausedetailsresponse)
| | 404 | Workflow run not found | | -### /workspaces - -#### GET -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current - -#### POST -##### Responses +### [GET] /workspaces +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [TenantInfoResponse](#tenantinforesponse) | +| 200 | Success | **application/json**: [TenantListResponse](#tenantlistresponse)
| -### /workspaces/current/agent-provider/{provider_name} +### [POST] /workspaces/current +#### Responses -#### GET -##### Description +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [TenantInfoResponse](#tenantinforesponse)
| +### [GET] /workspaces/current/agent-provider/{provider_name} Get specific agent provider details -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | provider_name | path | Agent provider name | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | object | - -### /workspaces/current/agent-providers - -#### GET -##### Description +| 200 | Success | **application/json**: [AgentProviderResponse](#agentproviderresponse)
| +### [GET] /workspaces/current/agent-providers Get list of available agent providers -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [ object ] | +| 200 | Success | **application/json**: [AgentProviderListResponse](#agentproviderlistresponse)
| -### /workspaces/current/customized-snippets +### [GET] /workspaces/current/customized-snippets +**List customized snippets with pagination and search** -#### GET -##### Summary - -List customized snippets with pagination and search - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [SnippetListQuery](#snippetlistquery) | +| creators | query | Filter by creator account IDs | No | [ string ] | +| is_published | query | Filter by published status | No | boolean | +| keyword | query | | No | string | +| limit | query | | No | integer,
**Default:** 20 | +| page | query | | No | integer,
**Default:** 1 | +| tag_ids | query | Filter by tag IDs | No | [ string ] | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Snippets retrieved successfully | [SnippetPagination](#snippetpagination) | +| 200 | Snippets retrieved successfully | **application/json**: [SnippetPagination](#snippetpagination)
| -#### POST -##### Summary +### [POST] /workspaces/current/customized-snippets +**Create a new customized snippet** -Create a new customized snippet +#### Request Body -##### Parameters +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [CreateSnippetPayload](#createsnippetpayload)
| -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [CreateSnippetPayload](#createsnippetpayload) | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 201 | Snippet created successfully | [Snippet](#snippet) | +| 201 | Snippet created successfully | **application/json**: [Snippet](#snippet)
| | 400 | Invalid request | | -### /workspaces/current/customized-snippets/imports +### [POST] /workspaces/current/customized-snippets/imports +**Import snippet from DSL** -#### POST -##### Summary +#### Request Body -Import snippet from DSL +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [SnippetImportPayload](#snippetimportpayload)
| -##### Description +#### Responses -Import snippet from DSL +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Snippet imported successfully | **application/json**: [SnippetImportResponse](#snippetimportresponse)
| +| 202 | Import pending confirmation | **application/json**: [SnippetImportResponse](#snippetimportresponse)
| +| 400 | Import failed | | -##### Parameters +### [POST] /workspaces/current/customized-snippets/imports/{import_id}/confirm +**Confirm a pending snippet import** -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [SnippetImportPayload](#snippetimportpayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Snippet imported successfully | -| 202 | Import pending confirmation | -| 400 | Import failed | - -### /workspaces/current/customized-snippets/imports/{import_id}/confirm - -#### POST -##### Summary - -Confirm a pending snippet import - -##### Description - -Confirm a pending snippet import - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | import_id | path | Import ID to confirm | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Import confirmed successfully | -| 400 | Import failed | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Import confirmed successfully | **application/json**: [SnippetImportResponse](#snippetimportresponse)
| +| 400 | Import failed | | -### /workspaces/current/customized-snippets/{snippet_id} +### [DELETE] /workspaces/current/customized-snippets/{snippet_id} +**Delete customized snippet** -#### DELETE -##### Summary - -Delete customized snippet - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | snippet_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | | 204 | Snippet deleted successfully | | 404 | Snippet not found | -#### GET -##### Summary +### [GET] /workspaces/current/customized-snippets/{snippet_id} +**Get customized snippet details** -Get customized snippet details - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | snippet_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Snippet retrieved successfully | [Snippet](#snippet) | +| 200 | Snippet retrieved successfully | **application/json**: [Snippet](#snippet)
| | 404 | Snippet not found | | -#### PATCH -##### Summary +### [PATCH] /workspaces/current/customized-snippets/{snippet_id} +**Update customized snippet** -Update customized snippet - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | snippet_id | path | | Yes | string | -| payload | body | | Yes | [UpdateSnippetPayload](#updatesnippetpayload) | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [UpdateSnippetPayload](#updatesnippetpayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Snippet updated successfully | [Snippet](#snippet) | +| 200 | Snippet updated successfully | **application/json**: [Snippet](#snippet)
| | 400 | Invalid request | | | 404 | Snippet not found | | -### /workspaces/current/customized-snippets/{snippet_id}/check-dependencies +### [GET] /workspaces/current/customized-snippets/{snippet_id}/check-dependencies +**Check dependencies for a snippet** -#### GET -##### Summary - -Check dependencies for a snippet - -##### Description - -Check dependencies for a snippet - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | snippet_id | path | Snippet ID | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Dependencies checked successfully | -| 404 | Snippet not found | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Dependencies checked successfully | **application/json**: [SnippetDependencyCheckResponse](#snippetdependencycheckresponse)
| +| 404 | Snippet not found | | -### /workspaces/current/customized-snippets/{snippet_id}/export - -#### GET -##### Summary - -Export snippet as DSL - -##### Description +### [GET] /workspaces/current/customized-snippets/{snippet_id}/export +**Export snippet as DSL** Export snippet configuration as DSL -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | snippet_id | path | Snippet ID to export | Yes | string | +| include_secret | query | Whether to include secret variables | No | string,
**Default:** false | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Snippet exported successfully | -| 404 | Snippet not found | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Snippet exported successfully | **application/json**: [TextFileResponse](#textfileresponse)
| +| 404 | Snippet not found | | -### /workspaces/current/customized-snippets/{snippet_id}/use-count/increment - -#### POST -##### Summary - -Increment snippet use count when it is inserted into a workflow - -##### Description +### [POST] /workspaces/current/customized-snippets/{snippet_id}/use-count/increment +**Increment snippet use count when it is inserted into a workflow** Increment snippet use count by 1 -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | snippet_id | path | Snippet ID | Yes | string | -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Use count incremented successfully | -| 404 | Snippet not found | - -### /workspaces/current/dataset-operators - -#### GET -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [AccountWithRoleList](#accountwithrolelist) | +| 200 | Use count incremented successfully | **application/json**: [SnippetUseCountResponse](#snippetusecountresponse)
| +| 404 | Snippet not found | | -### /workspaces/current/default-model - -#### GET -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ParserGetDefault](#parsergetdefault) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ParserPostDefault](#parserpostdefault) | - -##### Responses +### [GET] /workspaces/current/dataset-operators +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SimpleResultResponse](#simpleresultresponse) | +| 200 | Success | **application/json**: [AccountWithRoleList](#accountwithrolelist)
| -### /workspaces/current/endpoints +### [GET] /workspaces/current/default-model +#### Parameters -#### POST -##### Description +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| model_type | query | Enum class for model type. | Yes | string,
**Available values:** "llm", "moderation", "rerank", "speech2text", "text-embedding", "tts" | +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [DefaultModelDataResponse](#defaultmodeldataresponse)
| + +### [POST] /workspaces/current/default-model +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ParserPostDefault](#parserpostdefault)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| + +### [POST] /workspaces/current/endpoints Create a new plugin endpoint -##### Parameters +#### Request Body -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [EndpointCreatePayload](#endpointcreatepayload) | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [EndpointCreatePayload](#endpointcreatepayload)
| -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Endpoint created successfully | [EndpointCreateResponse](#endpointcreateresponse) | +| 200 | Endpoint created successfully | **application/json**: [EndpointCreateResponse](#endpointcreateresponse)
| | 403 | Admin privileges required | | -### /workspaces/current/endpoints/create +### ~~[POST] /workspaces/current/endpoints/create~~ -#### POST ***DEPRECATED*** -##### Description Deprecated legacy alias for creating a plugin endpoint. Use POST /workspaces/current/endpoints instead. -##### Parameters +#### Request Body -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [EndpointCreatePayload](#endpointcreatepayload) | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [EndpointCreatePayload](#endpointcreatepayload)
| -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Endpoint created successfully | [EndpointCreateResponse](#endpointcreateresponse) | +| 200 | Endpoint created successfully | **application/json**: [EndpointCreateResponse](#endpointcreateresponse)
| | 403 | Admin privileges required | | -### /workspaces/current/endpoints/delete +### ~~[POST] /workspaces/current/endpoints/delete~~ -#### POST ***DEPRECATED*** -##### Description Deprecated legacy alias for deleting a plugin endpoint. Use DELETE /workspaces/current/endpoints/{id} instead. -##### Parameters +#### Request Body -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [EndpointIdPayload](#endpointidpayload) | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [EndpointIdPayload](#endpointidpayload)
| -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Endpoint deleted successfully | [EndpointDeleteResponse](#endpointdeleteresponse) | +| 200 | Endpoint deleted successfully | **application/json**: [EndpointDeleteResponse](#endpointdeleteresponse)
| | 403 | Admin privileges required | | -### /workspaces/current/endpoints/disable - -#### POST -##### Description - +### [POST] /workspaces/current/endpoints/disable Disable a plugin endpoint -##### Parameters +#### Request Body -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [EndpointIdPayload](#endpointidpayload) | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [EndpointIdPayload](#endpointidpayload)
| -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Endpoint disabled successfully | [EndpointDisableResponse](#endpointdisableresponse) | +| 200 | Endpoint disabled successfully | **application/json**: [EndpointDisableResponse](#endpointdisableresponse)
| | 403 | Admin privileges required | | -### /workspaces/current/endpoints/enable - -#### POST -##### Description - +### [POST] /workspaces/current/endpoints/enable Enable a plugin endpoint -##### Parameters +#### Request Body -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [EndpointIdPayload](#endpointidpayload) | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [EndpointIdPayload](#endpointidpayload)
| -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Endpoint enabled successfully | [EndpointEnableResponse](#endpointenableresponse) | +| 200 | Endpoint enabled successfully | **application/json**: [EndpointEnableResponse](#endpointenableresponse)
| | 403 | Admin privileges required | | -### /workspaces/current/endpoints/list - -#### GET -##### Description - +### [GET] /workspaces/current/endpoints/list List plugin endpoints with pagination -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [EndpointListQuery](#endpointlistquery) | +| page | query | | Yes | integer | +| page_size | query | | Yes | integer | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [EndpointListResponse](#endpointlistresponse) | - -### /workspaces/current/endpoints/list/plugin - -#### GET -##### Description +| 200 | Success | **application/json**: [EndpointListResponse](#endpointlistresponse)
| +### [GET] /workspaces/current/endpoints/list/plugin List endpoints for a specific plugin -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [EndpointListForPluginQuery](#endpointlistforpluginquery) | +| page | query | | Yes | integer | +| page_size | query | | Yes | integer | +| plugin_id | query | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [PluginEndpointListResponse](#pluginendpointlistresponse) | +| 200 | Success | **application/json**: [PluginEndpointListResponse](#pluginendpointlistresponse)
| -### /workspaces/current/endpoints/update +### ~~[POST] /workspaces/current/endpoints/update~~ -#### POST ***DEPRECATED*** -##### Description Deprecated legacy alias for updating a plugin endpoint. Use PATCH /workspaces/current/endpoints/{id} instead. -##### Parameters +#### Request Body -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [LegacyEndpointUpdatePayload](#legacyendpointupdatepayload) | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [LegacyEndpointUpdatePayload](#legacyendpointupdatepayload)
| -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Endpoint updated successfully | [EndpointUpdateResponse](#endpointupdateresponse) | +| 200 | Endpoint updated successfully | **application/json**: [EndpointUpdateResponse](#endpointupdateresponse)
| | 403 | Admin privileges required | | -### /workspaces/current/endpoints/{id} - -#### DELETE -##### Description - +### [DELETE] /workspaces/current/endpoints/{id} Delete a plugin endpoint -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | id | path | Endpoint ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Endpoint deleted successfully | [EndpointDeleteResponse](#endpointdeleteresponse) | +| 200 | Endpoint deleted successfully | **application/json**: [EndpointDeleteResponse](#endpointdeleteresponse)
| | 403 | Admin privileges required | | -#### PATCH -##### Description - +### [PATCH] /workspaces/current/endpoints/{id} Update a plugin endpoint -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [EndpointUpdatePayload](#endpointupdatepayload) | | id | path | Endpoint ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [EndpointUpdatePayload](#endpointupdatepayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Endpoint updated successfully | [EndpointUpdateResponse](#endpointupdateresponse) | +| 200 | Endpoint updated successfully | **application/json**: [EndpointUpdateResponse](#endpointupdateresponse)
| | 403 | Admin privileges required | | -### /workspaces/current/members - -#### GET -##### Responses +### [GET] /workspaces/current/members +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [AccountWithRoleList](#accountwithrolelist) | +| 200 | Success | **application/json**: [AccountWithRoleList](#accountwithrolelist)
| -### /workspaces/current/members/invite-email +### [POST] /workspaces/current/members/invite-email +#### Request Body -#### POST -##### Parameters +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [MemberInvitePayload](#memberinvitepayload)
| -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [MemberInvitePayload](#memberinvitepayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/members/owner-transfer-check - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [OwnerTransferCheckPayload](#ownertransfercheckpayload) | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [VerificationTokenResponse](#verificationtokenresponse) | +| 201 | Success | **application/json**: [MemberInviteResponse](#memberinviteresponse)
| -### /workspaces/current/members/send-owner-transfer-confirm-email +### [POST] /workspaces/current/members/owner-transfer-check +#### Request Body -#### POST -##### Parameters +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [OwnerTransferCheckPayload](#ownertransfercheckpayload)
| -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [OwnerTransferEmailPayload](#ownertransferemailpayload) | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SimpleResultDataResponse](#simpleresultdataresponse) | +| 200 | Success | **application/json**: [VerificationTokenResponse](#verificationtokenresponse)
| -### /workspaces/current/members/{member_id} +### [POST] /workspaces/current/members/send-owner-transfer-confirm-email +#### Request Body -#### DELETE -##### Parameters +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [OwnerTransferEmailPayload](#ownertransferemailpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultDataResponse](#simpleresultdataresponse)
| + +### [DELETE] /workspaces/current/members/{member_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | member_id | path | | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [MemberActionTenantResponse](#memberactiontenantresponse)
| -### /workspaces/current/members/{member_id}/owner-transfer - -#### POST -##### Parameters +### [POST] /workspaces/current/members/{member_id}/owner-transfer +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | member_id | path | | Yes | string | -| payload | body | | Yes | [OwnerTransferPayload](#ownertransferpayload) | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [OwnerTransferPayload](#ownertransferpayload)
| -### /workspaces/current/members/{member_id}/update-role +#### Responses -#### PUT -##### Parameters +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| + +### [PUT] /workspaces/current/members/{member_id}/update-role +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | member_id | path | | Yes | string | -| payload | body | | Yes | [MemberRoleUpdatePayload](#memberroleupdatepayload) | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [MemberRoleUpdatePayload](#memberroleupdatepayload)
| -### /workspaces/current/model-providers +#### Responses -#### GET -##### Parameters +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| + +### [GET] /workspaces/current/model-providers +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ParserModelList](#parsermodellist) | +| model_type | query | Enum class for model type. | No | string,
**Available values:** "llm", "moderation", "rerank", "speech2text", "text-embedding", "tts" | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ModelProviderListResponse](#modelproviderlistresponse)
| -### /workspaces/current/model-providers/{provider}/checkout-url - -#### GET -##### Parameters +### [GET] /workspaces/current/model-providers/{provider}/checkout-url +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | provider | path | | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ModelProviderPaymentCheckoutUrlResponse](#modelproviderpaymentcheckouturlresponse)
| -### /workspaces/current/model-providers/{provider}/credentials - -#### DELETE -##### Parameters +### [DELETE] /workspaces/current/model-providers/{provider}/credentials +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | provider | path | | Yes | string | -| payload | body | | Yes | [ParserCredentialDelete](#parsercredentialdelete) | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ParserCredentialDelete](#parsercredentialdelete)
| + +#### Responses | Code | Description | | ---- | ----------- | | 204 | Credential deleted successfully | -#### GET -##### Parameters +### [GET] /workspaces/current/model-providers/{provider}/credentials +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | +| credential_id | query | | No | string | | provider | path | | Yes | string | -| payload | body | | Yes | [ParserCredentialId](#parsercredentialid) | -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider | path | | Yes | string | -| payload | body | | Yes | [ParserCredentialCreate](#parsercredentialcreate) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -#### PUT -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider | path | | Yes | string | -| payload | body | | Yes | [ParserCredentialUpdate](#parsercredentialupdate) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/model-providers/{provider}/credentials/switch - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider | path | | Yes | string | -| payload | body | | Yes | [ParserCredentialSwitch](#parsercredentialswitch) | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SimpleResultResponse](#simpleresultresponse) | +| 200 | Success | **application/json**: [ProviderCredentialResponse](#providercredentialresponse)
| -### /workspaces/current/model-providers/{provider}/credentials/validate - -#### POST -##### Parameters +### [POST] /workspaces/current/model-providers/{provider}/credentials +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | provider | path | | Yes | string | -| payload | body | | Yes | [ParserCredentialValidate](#parsercredentialvalidate) | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ParserCredentialCreate](#parsercredentialcreate)
| -### /workspaces/current/model-providers/{provider}/models +#### Responses -#### DELETE -##### Parameters +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Credential created successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| + +### [PUT] /workspaces/current/model-providers/{provider}/credentials +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | provider | path | | Yes | string | -| payload | body | | Yes | [ParserDeleteModels](#parserdeletemodels) | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ParserCredentialUpdate](#parsercredentialupdate)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Credential updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| + +### [POST] /workspaces/current/model-providers/{provider}/credentials/switch +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ParserCredentialSwitch](#parsercredentialswitch)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| + +### [POST] /workspaces/current/model-providers/{provider}/credentials/validate +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ParserCredentialValidate](#parsercredentialvalidate)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Credential validation result | **application/json**: [ProviderCredentialValidateResponse](#providercredentialvalidateresponse)
| + +### [DELETE] /workspaces/current/model-providers/{provider}/models +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ParserDeleteModels](#parserdeletemodels)
| + +#### Responses | Code | Description | | ---- | ----------- | | 204 | Model deleted successfully | -#### GET -##### Parameters +### [GET] /workspaces/current/model-providers/{provider}/models +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | provider | path | | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ModelWithProviderListResponse](#modelwithproviderlistresponse)
| -#### POST -##### Parameters +### [POST] /workspaces/current/model-providers/{provider}/models +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | provider | path | | Yes | string | -| payload | body | | Yes | [ParserPostModels](#parserpostmodels) | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ParserPostModels](#parserpostmodels)
| -### /workspaces/current/model-providers/{provider}/models/credentials +#### Responses -#### DELETE -##### Parameters +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| + +### [DELETE] /workspaces/current/model-providers/{provider}/models/credentials +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | provider | path | | Yes | string | -| payload | body | | Yes | [ParserDeleteCredential](#parserdeletecredential) | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ParserDeleteCredential](#parserdeletecredential)
| + +#### Responses | Code | Description | | ---- | ----------- | | 204 | Credential deleted successfully | -#### GET -##### Parameters +### [GET] /workspaces/current/model-providers/{provider}/models/credentials +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | +| config_from | query | | No | string | +| credential_id | query | | No | string | +| model | query | | Yes | string | +| model_type | query | Enum class for model type. | Yes | string,
**Available values:** "llm", "moderation", "rerank", "speech2text", "text-embedding", "tts" | | provider | path | | Yes | string | -| payload | body | | Yes | [ParserGetCredentials](#parsergetcredentials) | -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider | path | | Yes | string | -| payload | body | | Yes | [ParserCreateCredential](#parsercreatecredential) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -#### PUT -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider | path | | Yes | string | -| payload | body | | Yes | [ParserUpdateCredential](#parserupdatecredential) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/model-providers/{provider}/models/credentials/switch - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider | path | | Yes | string | -| payload | body | | Yes | [ParserSwitch](#parserswitch) | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SimpleResultResponse](#simpleresultresponse) | +| 200 | Success | **application/json**: [ModelCredentialResponse](#modelcredentialresponse)
| -### /workspaces/current/model-providers/{provider}/models/credentials/validate - -#### POST -##### Parameters +### [POST] /workspaces/current/model-providers/{provider}/models/credentials +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | provider | path | | Yes | string | -| payload | body | | Yes | [ParserValidate](#parservalidate) | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ParserCreateCredential](#parsercreatecredential)
| -### /workspaces/current/model-providers/{provider}/models/disable - -#### PATCH -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider | path | | Yes | string | -| payload | body | | Yes | [ParserDeleteModels](#parserdeletemodels) | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SimpleResultResponse](#simpleresultresponse) | +| 201 | Credential created successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| -### /workspaces/current/model-providers/{provider}/models/enable - -#### PATCH -##### Parameters +### [PUT] /workspaces/current/model-providers/{provider}/models/credentials +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | provider | path | | Yes | string | -| payload | body | | Yes | [ParserDeleteModels](#parserdeletemodels) | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ParserUpdateCredential](#parserupdatecredential)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SimpleResultResponse](#simpleresultresponse) | +| 200 | Credential updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| -### /workspaces/current/model-providers/{provider}/models/load-balancing-configs/credentials-validate - -#### POST -##### Parameters +### [POST] /workspaces/current/model-providers/{provider}/models/credentials/switch +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | provider | path | | Yes | string | -| payload | body | | Yes | [LoadBalancingCredentialPayload](#loadbalancingcredentialpayload) | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ParserSwitch](#parserswitch)
| -### /workspaces/current/model-providers/{provider}/models/load-balancing-configs/{config_id}/credentials-validate +#### Responses -#### POST -##### Parameters +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| + +### [POST] /workspaces/current/model-providers/{provider}/models/credentials/validate +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ParserValidate](#parservalidate)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Credential validation result | **application/json**: [ModelCredentialValidateResponse](#modelcredentialvalidateresponse)
| + +### [PATCH] /workspaces/current/model-providers/{provider}/models/disable +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ParserDeleteModels](#parserdeletemodels)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| + +### [PATCH] /workspaces/current/model-providers/{provider}/models/enable +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ParserDeleteModels](#parserdeletemodels)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| + +### [POST] /workspaces/current/model-providers/{provider}/models/load-balancing-configs/credentials-validate +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [LoadBalancingCredentialPayload](#loadbalancingcredentialpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Credential validation result | **application/json**: [LoadBalancingCredentialValidateResponse](#loadbalancingcredentialvalidateresponse)
| + +### [POST] /workspaces/current/model-providers/{provider}/models/load-balancing-configs/{config_id}/credentials-validate +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | config_id | path | | Yes | string | | provider | path | | Yes | string | -| payload | body | | Yes | [LoadBalancingCredentialPayload](#loadbalancingcredentialpayload) | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [LoadBalancingCredentialPayload](#loadbalancingcredentialpayload)
| -### /workspaces/current/model-providers/{provider}/models/parameter-rules - -#### GET -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider | path | | Yes | string | -| payload | body | | Yes | [ParserParameter](#parserparameter) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/model-providers/{provider}/preferred-provider-type - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider | path | | Yes | string | -| payload | body | | Yes | [ParserPreferredProviderType](#parserpreferredprovidertype) | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SimpleResultResponse](#simpleresultresponse) | +| 200 | Credential validation result | **application/json**: [LoadBalancingCredentialValidateResponse](#loadbalancingcredentialvalidateresponse)
| -### /workspaces/current/models/model-types/{model_type} +### [GET] /workspaces/current/model-providers/{provider}/models/parameter-rules +#### Parameters -#### GET -##### Parameters +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| model | query | | Yes | string | +| provider | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ModelParameterRulesResponse](#modelparameterrulesresponse)
| + +### [POST] /workspaces/current/model-providers/{provider}/preferred-provider-type +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ParserPreferredProviderType](#parserpreferredprovidertype)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| + +### [GET] /workspaces/current/models/model-types/{model_type} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | model_type | path | | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ProviderWithModelsDataResponse](#providerwithmodelsdataresponse)
| -### /workspaces/current/permission - -#### GET -##### Summary - -Get workspace permission settings - -##### Description +### [GET] /workspaces/current/permission +**Get workspace permission settings** Returns permission flags that control workspace features like member invitations and owner transfer. -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [WorkspacePermissionResponse](#workspacepermissionresponse) | +| 200 | Success | **application/json**: [WorkspacePermissionResponse](#workspacepermissionresponse)
| -### /workspaces/current/plugin/asset - -#### GET -##### Parameters +### [GET] /workspaces/current/plugin/asset +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ParserAsset](#parserasset) | +| file_name | query | | Yes | string | +| plugin_unique_identifier | query | | Yes | string | -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/plugin/debugging-key - -#### GET -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [PluginDebuggingKeyResponse](#plugindebuggingkeyresponse) | +| 200 | Success | **application/json**: [BinaryFileResponse](#binaryfileresponse)
| -### /workspaces/current/plugin/fetch-manifest +### [POST] /workspaces/current/plugin/auto-upgrade/change +#### Request Body -#### GET -##### Parameters +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ParserAutoUpgradeChange](#parserautoupgradechange)
| -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ParserPluginIdentifierQuery](#parserpluginidentifierquery) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/plugin/icon - -#### GET -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ParserIcon](#parsericon) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/plugin/install/github - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ParserGithubInstall](#parsergithubinstall) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/plugin/install/marketplace - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ParserPluginIdentifiers](#parserpluginidentifiers) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/plugin/install/pkg - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ParserPluginIdentifiers](#parserpluginidentifiers) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/plugin/list - -#### GET -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ParserList](#parserlist) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/plugin/list/installations/ids - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ParserLatest](#parserlatest) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/plugin/list/latest-versions - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ParserLatest](#parserlatest) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/plugin/marketplace/pkg - -#### GET -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ParserPluginIdentifierQuery](#parserpluginidentifierquery) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/plugin/parameters/dynamic-options - -#### GET -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ParserDynamicOptions](#parserdynamicoptions) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/plugin/parameters/dynamic-options-with-credentials - -#### POST -##### Summary - -Fetch dynamic options using credentials directly (for edit mode) - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ParserDynamicOptionsWithCredentials](#parserdynamicoptionswithcredentials) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/plugin/permission/change - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ParserPermissionChange](#parserpermissionchange) | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SuccessResponse](#successresponse) | +| 200 | Success | **application/json**: [PluginAutoUpgradeChangeResponse](#pluginautoupgradechangeresponse)
| -### /workspaces/current/plugin/permission/fetch +### [POST] /workspaces/current/plugin/auto-upgrade/exclude +#### Request Body -#### GET -##### Responses +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ParserExcludePlugin](#parserexcludeplugin)
| -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/plugin/preferences/autoupgrade/exclude - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ParserExcludePlugin](#parserexcludeplugin) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/plugin/preferences/change - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ParserPreferencesChange](#parserpreferenceschange) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/plugin/preferences/fetch - -#### GET -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/plugin/readme - -#### GET -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ParserReadme](#parserreadme) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/plugin/tasks - -#### GET -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ParserTasks](#parsertasks) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/plugin/tasks/delete_all - -#### POST -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SuccessResponse](#successresponse) | +| 200 | Success | **application/json**: [SuccessResponse](#successresponse)
| -### /workspaces/current/plugin/tasks/{task_id} +### [GET] /workspaces/current/plugin/auto-upgrade/fetch +#### Parameters -#### GET -##### Parameters +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| category | query | | Yes | string,
**Available values:** "agent-strategy", "datasource", "extension", "model", "tool", "trigger" | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [PluginAutoUpgradeFetchResponse](#pluginautoupgradefetchresponse)
| + +### [GET] /workspaces/current/plugin/debugging-key +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [PluginDebuggingKeyResponse](#plugindebuggingkeyresponse)
| + +### [GET] /workspaces/current/plugin/fetch-manifest +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| plugin_unique_identifier | query | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [PluginManifestResponse](#pluginmanifestresponse)
| + +### [GET] /workspaces/current/plugin/icon +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| filename | query | | Yes | string | +| tenant_id | query | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [BinaryFileResponse](#binaryfileresponse)
| + +### [POST] /workspaces/current/plugin/install/github +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ParserGithubInstall](#parsergithubinstall)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [PluginDaemonOperationResponse](#plugindaemonoperationresponse)
| + +### [POST] /workspaces/current/plugin/install/marketplace +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ParserPluginIdentifiers](#parserpluginidentifiers)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [PluginDaemonOperationResponse](#plugindaemonoperationresponse)
| + +### [POST] /workspaces/current/plugin/install/pkg +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ParserPluginIdentifiers](#parserpluginidentifiers)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [PluginDaemonOperationResponse](#plugindaemonoperationresponse)
| + +### [GET] /workspaces/current/plugin/list +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| page | query | Page number | No | integer,
**Default:** 1 | +| page_size | query | Page size (1-256) | No | integer,
**Default:** 256 | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [PluginListResponse](#pluginlistresponse)
| + +### [POST] /workspaces/current/plugin/list/installations/ids +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ParserLatest](#parserlatest)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [PluginInstallationsResponse](#plugininstallationsresponse)
| + +### [POST] /workspaces/current/plugin/list/latest-versions +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ParserLatest](#parserlatest)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [PluginVersionsResponse](#pluginversionsresponse)
| + +### [GET] /workspaces/current/plugin/marketplace/pkg +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| plugin_unique_identifier | query | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [PluginManifestResponse](#pluginmanifestresponse)
| + +### [GET] /workspaces/current/plugin/parameters/dynamic-options +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| action | query | | Yes | string | +| credential_id | query | | No | string | +| parameter | query | | Yes | string | +| plugin_id | query | | Yes | string | +| provider | query | | Yes | string | +| provider_type | query | | Yes | string,
**Available values:** "tool", "trigger" | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [PluginDynamicOptionsResponse](#plugindynamicoptionsresponse)
| + +### [POST] /workspaces/current/plugin/parameters/dynamic-options-with-credentials +**Fetch dynamic options using credentials directly (for edit mode)** + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ParserDynamicOptionsWithCredentials](#parserdynamicoptionswithcredentials)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [PluginDynamicOptionsResponse](#plugindynamicoptionsresponse)
| + +### [POST] /workspaces/current/plugin/permission/change +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ParserPermissionChange](#parserpermissionchange)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SuccessResponse](#successresponse)
| + +### [GET] /workspaces/current/plugin/permission/fetch +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [PluginPermissionResponse](#pluginpermissionresponse)
| + +### [GET] /workspaces/current/plugin/readme +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| language | query | | No | string,
**Default:** en-US | +| plugin_unique_identifier | query | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [PluginReadmeResponse](#pluginreadmeresponse)
| + +### [GET] /workspaces/current/plugin/tasks +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| page | query | Page number | No | integer,
**Default:** 1 | +| page_size | query | Page size (1-256) | No | integer,
**Default:** 256 | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [PluginTasksResponse](#plugintasksresponse)
| + +### [POST] /workspaces/current/plugin/tasks/delete_all +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SuccessResponse](#successresponse)
| + +### [GET] /workspaces/current/plugin/tasks/{task_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | task_id | path | | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [PluginTaskResponse](#plugintaskresponse)
| -### /workspaces/current/plugin/tasks/{task_id}/delete - -#### POST -##### Parameters +### [POST] /workspaces/current/plugin/tasks/{task_id}/delete +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | task_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SuccessResponse](#successresponse) | +| 200 | Success | **application/json**: [SuccessResponse](#successresponse)
| -### /workspaces/current/plugin/tasks/{task_id}/delete/{identifier} - -#### POST -##### Parameters +### [POST] /workspaces/current/plugin/tasks/{task_id}/delete/{identifier} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | identifier | path | | Yes | string | | task_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SuccessResponse](#successresponse) | +| 200 | Success | **application/json**: [SuccessResponse](#successresponse)
| -### /workspaces/current/plugin/uninstall +### [POST] /workspaces/current/plugin/uninstall +#### Request Body -#### POST -##### Parameters +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ParserUninstall](#parseruninstall)
| -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ParserUninstall](#parseruninstall) | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SuccessResponse](#successresponse) | +| 200 | Success | **application/json**: [SuccessResponse](#successresponse)
| -### /workspaces/current/plugin/upgrade/github +### [POST] /workspaces/current/plugin/upgrade/github +#### Request Body -#### POST -##### Parameters +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ParserGithubUpgrade](#parsergithubupgrade)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [PluginDaemonOperationResponse](#plugindaemonoperationresponse)
| + +### [POST] /workspaces/current/plugin/upgrade/marketplace +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ParserMarketplaceUpgrade](#parsermarketplaceupgrade)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [PluginDaemonOperationResponse](#plugindaemonoperationresponse)
| + +### [POST] /workspaces/current/plugin/upload/bundle +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [PluginDaemonOperationResponse](#plugindaemonoperationresponse)
| + +### [POST] /workspaces/current/plugin/upload/github +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ParserGithubUpload](#parsergithubupload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [PluginDaemonOperationResponse](#plugindaemonoperationresponse)
| + +### [POST] /workspaces/current/plugin/upload/pkg +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [PluginDaemonOperationResponse](#plugindaemonoperationresponse)
| + +### [GET] /workspaces/current/plugin/{category}/list +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ParserGithubUpgrade](#parsergithubupgrade) | +| page | query | Page number | No | integer,
**Default:** 1 | +| page_size | query | Page size (1-256) | No | integer,
**Default:** 256 | +| category | path | | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [PluginCategoryListResponse](#plugincategorylistresponse)
| -### /workspaces/current/plugin/upgrade/marketplace +### [GET] /workspaces/current/tool-labels +#### Responses -#### POST -##### Parameters +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| + +### [POST] /workspaces/current/tool-provider/api/add +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ApiToolProviderAddPayload](#apitoolprovideraddpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| + +### [POST] /workspaces/current/tool-provider/api/delete +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ApiToolProviderDeletePayload](#apitoolproviderdeletepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| + +### [GET] /workspaces/current/tool-provider/api/get +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ParserMarketplaceUpgrade](#parsermarketplaceupgrade) | +| provider | query | | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| -### /workspaces/current/plugin/upload/bundle - -#### POST -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/plugin/upload/github - -#### POST -##### Parameters +### [GET] /workspaces/current/tool-provider/api/remote +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ParserGithubUpload](#parsergithubupload) | +| url | query | | Yes | string (uri) | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| -### /workspaces/current/plugin/upload/pkg +### [POST] /workspaces/current/tool-provider/api/schema +#### Request Body -#### POST -##### Responses +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ApiToolSchemaPayload](#apitoolschemapayload)
| -| Code | Description | -| ---- | ----------- | -| 200 | Success | +#### Responses -### /workspaces/current/tool-labels +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| -#### GET -##### Responses +### [POST] /workspaces/current/tool-provider/api/test/pre +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ApiToolTestPayload](#apitooltestpayload)
| -### /workspaces/current/tool-provider/api/add +#### Responses -#### POST -##### Parameters +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| + +### [GET] /workspaces/current/tool-provider/api/tools +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ApiToolProviderAddPayload](#apitoolprovideraddpayload) | +| provider | query | | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| -### /workspaces/current/tool-provider/api/delete +### [POST] /workspaces/current/tool-provider/api/update +#### Request Body -#### POST -##### Parameters +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ApiToolProviderUpdatePayload](#apitoolproviderupdatepayload)
| -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ApiToolProviderDeletePayload](#apitoolproviderdeletepayload) | +#### Responses -##### Responses +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/tool-provider/api/get - -#### GET -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/tool-provider/api/remote - -#### GET -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/tool-provider/api/schema - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ApiToolSchemaPayload](#apitoolschemapayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/tool-provider/api/test/pre - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ApiToolTestPayload](#apitooltestpayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/tool-provider/api/tools - -#### GET -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/tool-provider/api/update - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ApiToolProviderUpdatePayload](#apitoolproviderupdatepayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/tool-provider/builtin/{provider}/add - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider | path | | Yes | string | -| payload | body | | Yes | [BuiltinToolAddPayload](#builtintooladdpayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/tool-provider/builtin/{provider}/credential/info - -#### GET -##### Parameters +### [POST] /workspaces/current/tool-provider/builtin/{provider}/add +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | provider | path | | Yes | string | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [BuiltinToolAddPayload](#builtintooladdpayload)
| -### /workspaces/current/tool-provider/builtin/{provider}/credential/schema/{credential_type} +#### Responses -#### GET -##### Parameters +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| + +### [GET] /workspaces/current/tool-provider/builtin/{provider}/credential/info +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| include_credential_ids | query | Credential IDs to include even if visibility would hide them | No | [ string ] | +| provider | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| + +### [GET] /workspaces/current/tool-provider/builtin/{provider}/credential/schema/{credential_type} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | credential_type | path | | Yes | string | | provider | path | | Yes | string | -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/tool-provider/builtin/{provider}/credentials - -#### GET -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider | path | | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/tool-provider/builtin/{provider}/default-credential - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider | path | | Yes | string | -| payload | body | | Yes | [BuiltinProviderDefaultCredentialPayload](#builtinproviderdefaultcredentialpayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/tool-provider/builtin/{provider}/delete - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider | path | | Yes | string | -| payload | body | | Yes | [BuiltinToolCredentialDeletePayload](#builtintoolcredentialdeletepayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/tool-provider/builtin/{provider}/icon - -#### GET -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider | path | | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/tool-provider/builtin/{provider}/info - -#### GET -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider | path | | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/tool-provider/builtin/{provider}/oauth/client-schema - -#### GET -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider | path | | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/tool-provider/builtin/{provider}/oauth/custom-client - -#### DELETE -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider | path | | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -#### GET -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider | path | | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider | path | | Yes | string | -| payload | body | | Yes | [ToolOAuthCustomClientPayload](#tooloauthcustomclientpayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/tool-provider/builtin/{provider}/tools - -#### GET -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider | path | | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/tool-provider/builtin/{provider}/update - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider | path | | Yes | string | -| payload | body | | Yes | [BuiltinToolUpdatePayload](#builtintoolupdatepayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/tool-provider/mcp - -#### DELETE -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [MCPProviderDeletePayload](#mcpproviderdeletepayload) | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SimpleResultResponse](#simpleresultresponse) | +| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| -#### POST -##### Parameters +### [GET] /workspaces/current/tool-provider/builtin/{provider}/credentials +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [MCPProviderCreatePayload](#mcpprovidercreatepayload) | +| include_credential_ids | query | Credential IDs to include even if visibility would hide them | No | [ string ] | +| provider | path | | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| -#### PUT -##### Parameters +### [POST] /workspaces/current/tool-provider/builtin/{provider}/default-credential +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [MCPProviderUpdatePayload](#mcpproviderupdatepayload) | +| provider | path | | Yes | string | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [BuiltinProviderDefaultCredentialPayload](#builtinproviderdefaultcredentialpayload)
| -### /workspaces/current/tool-provider/mcp/auth +#### Responses -#### POST -##### Parameters +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| + +### [POST] /workspaces/current/tool-provider/builtin/{provider}/delete +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [MCPAuthPayload](#mcpauthpayload) | +| provider | path | | Yes | string | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [BuiltinToolCredentialDeletePayload](#builtintoolcredentialdeletepayload)
| -### /workspaces/current/tool-provider/mcp/tools/{provider_id} +#### Responses -#### GET -##### Parameters +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| + +### [GET] /workspaces/current/tool-provider/builtin/{provider}/icon +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [BinaryFileResponse](#binaryfileresponse)
| + +### [GET] /workspaces/current/tool-provider/builtin/{provider}/info +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| + +### [GET] /workspaces/current/tool-provider/builtin/{provider}/oauth/client-schema +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ToolOAuthClientSchemaResponse](#tooloauthclientschemaresponse)
| + +### [DELETE] /workspaces/current/tool-provider/builtin/{provider}/oauth/custom-client +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| + +### [GET] /workspaces/current/tool-provider/builtin/{provider}/oauth/custom-client +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ToolOAuthCustomClientResponse](#tooloauthcustomclientresponse)
| + +### [POST] /workspaces/current/tool-provider/builtin/{provider}/oauth/custom-client +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ToolOAuthCustomClientPayload](#tooloauthcustomclientpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| + +### [GET] /workspaces/current/tool-provider/builtin/{provider}/tools +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| + +### [POST] /workspaces/current/tool-provider/builtin/{provider}/update +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [BuiltinToolUpdatePayload](#builtintoolupdatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| + +### [DELETE] /workspaces/current/tool-provider/mcp +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [MCPProviderDeletePayload](#mcpproviderdeletepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| + +### [POST] /workspaces/current/tool-provider/mcp +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [MCPProviderCreatePayload](#mcpprovidercreatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| + +### [PUT] /workspaces/current/tool-provider/mcp +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [MCPProviderUpdatePayload](#mcpproviderupdatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| + +### [POST] /workspaces/current/tool-provider/mcp/auth +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [MCPAuthPayload](#mcpauthpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| + +### [GET] /workspaces/current/tool-provider/mcp/tools/{provider_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | provider_id | path | | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| -### /workspaces/current/tool-provider/mcp/update/{provider_id} - -#### GET -##### Parameters +### [GET] /workspaces/current/tool-provider/mcp/update/{provider_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | provider_id | path | | Yes | string | -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/tool-provider/workflow/create - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [WorkflowToolCreatePayload](#workflowtoolcreatepayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/tool-provider/workflow/delete - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [WorkflowToolDeletePayload](#workflowtooldeletepayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/tool-provider/workflow/get - -#### GET -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/tool-provider/workflow/tools - -#### GET -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/tool-provider/workflow/update - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [WorkflowToolUpdatePayload](#workflowtoolupdatepayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/tool-providers - -#### GET -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/tools/api - -#### GET -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/tools/builtin - -#### GET -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/tools/mcp - -#### GET -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/tools/workflow - -#### GET -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/trigger-provider/{provider}/icon - -#### GET -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider | path | | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/trigger-provider/{provider}/info - -#### GET -##### Summary - -Get info for a trigger provider - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider | path | | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/trigger-provider/{provider}/oauth/client - -#### DELETE -##### Summary - -Remove custom OAuth client configuration - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider | path | | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -#### GET -##### Summary - -Get OAuth client configuration for a provider - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider | path | | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -#### POST -##### Summary - -Configure custom OAuth client for a provider - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider | path | | Yes | string | -| payload | body | | Yes | [TriggerOAuthClientPayload](#triggeroauthclientpayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/trigger-provider/{provider}/subscriptions/builder/build/{subscription_builder_id} - -#### POST -##### Summary - -Build a subscription instance for a trigger provider - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider | path | | Yes | string | -| subscription_builder_id | path | | Yes | string | -| payload | body | | Yes | [TriggerSubscriptionBuilderUpdatePayload](#triggersubscriptionbuilderupdatepayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/trigger-provider/{provider}/subscriptions/builder/create - -#### POST -##### Summary - -Add a new subscription instance for a trigger provider - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider | path | | Yes | string | -| payload | body | | Yes | [TriggerSubscriptionBuilderCreatePayload](#triggersubscriptionbuildercreatepayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/trigger-provider/{provider}/subscriptions/builder/logs/{subscription_builder_id} - -#### GET -##### Summary - -Get the request logs for a subscription instance for a trigger provider - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider | path | | Yes | string | -| subscription_builder_id | path | | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/trigger-provider/{provider}/subscriptions/builder/update/{subscription_builder_id} - -#### POST -##### Summary - -Update a subscription instance for a trigger provider - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider | path | | Yes | string | -| subscription_builder_id | path | | Yes | string | -| payload | body | | Yes | [TriggerSubscriptionBuilderUpdatePayload](#triggersubscriptionbuilderupdatepayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/trigger-provider/{provider}/subscriptions/builder/verify-and-update/{subscription_builder_id} - -#### POST -##### Summary - -Verify and update a subscription instance for a trigger provider - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider | path | | Yes | string | -| subscription_builder_id | path | | Yes | string | -| payload | body | | Yes | [TriggerSubscriptionBuilderVerifyPayload](#triggersubscriptionbuilderverifypayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/trigger-provider/{provider}/subscriptions/builder/{subscription_builder_id} - -#### GET -##### Summary - -Get a subscription instance for a trigger provider - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider | path | | Yes | string | -| subscription_builder_id | path | | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/trigger-provider/{provider}/subscriptions/list - -#### GET -##### Summary - -List all trigger subscriptions for the current tenant's provider - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider | path | | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/trigger-provider/{provider}/subscriptions/oauth/authorize - -#### GET -##### Summary - -Initiate OAuth authorization flow for a trigger provider - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider | path | | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/trigger-provider/{provider}/subscriptions/verify/{subscription_id} - -#### POST -##### Summary - -Verify credentials for an existing subscription (edit mode only) - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| provider | path | | Yes | string | -| subscription_id | path | | Yes | string | -| payload | body | | Yes | [TriggerSubscriptionBuilderVerifyPayload](#triggersubscriptionbuilderverifypayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/current/trigger-provider/{subscription_id}/subscriptions/delete - -#### POST -##### Summary - -Delete a subscription instance - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| subscription_id | path | | Yes | string | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [SimpleResultResponse](#simpleresultresponse) | +| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| -### /workspaces/current/trigger-provider/{subscription_id}/subscriptions/update +### [POST] /workspaces/current/tool-provider/workflow/create +#### Request Body -#### POST -##### Summary +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [WorkflowToolCreatePayload](#workflowtoolcreatepayload)
| -Update a subscription instance +#### Responses -##### Parameters +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| + +### [POST] /workspaces/current/tool-provider/workflow/delete +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [WorkflowToolDeletePayload](#workflowtooldeletepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| + +### [GET] /workspaces/current/tool-provider/workflow/get +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| workflow_app_id | query | | No | string | +| workflow_tool_id | query | | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| + +### [GET] /workspaces/current/tool-provider/workflow/tools +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| workflow_tool_id | query | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| + +### [POST] /workspaces/current/tool-provider/workflow/update +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [WorkflowToolUpdatePayload](#workflowtoolupdatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| + +### [GET] /workspaces/current/tool-providers +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| type | query | | No | string,
**Available values:** "api", "builtin", "mcp", "model", "workflow" | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| + +### [GET] /workspaces/current/tools/api +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| + +### [GET] /workspaces/current/tools/builtin +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| + +### [GET] /workspaces/current/tools/mcp +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| + +### [GET] /workspaces/current/tools/workflow +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ToolProviderOpaqueResponse](#toolprovideropaqueresponse)
| + +### [GET] /workspaces/current/trigger-provider/{provider}/icon +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [BinaryFileResponse](#binaryfileresponse)
| + +### [GET] /workspaces/current/trigger-provider/{provider}/info +**Get info for a trigger provider** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [TriggerProviderOpaqueResponse](#triggerprovideropaqueresponse)
| + +### [DELETE] /workspaces/current/trigger-provider/{provider}/oauth/client +**Remove custom OAuth client configuration** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| + +### [GET] /workspaces/current/trigger-provider/{provider}/oauth/client +**Get OAuth client configuration for a provider** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [TriggerOAuthClientResponse](#triggeroauthclientresponse)
| + +### [POST] /workspaces/current/trigger-provider/{provider}/oauth/client +**Configure custom OAuth client for a provider** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [TriggerOAuthClientPayload](#triggeroauthclientpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| + +### [POST] /workspaces/current/trigger-provider/{provider}/subscriptions/builder/build/{subscription_builder_id} +**Build a subscription instance for a trigger provider** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| subscription_builder_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [TriggerSubscriptionBuilderUpdatePayload](#triggersubscriptionbuilderupdatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [TriggerProviderOpaqueResponse](#triggerprovideropaqueresponse)
| + +### [POST] /workspaces/current/trigger-provider/{provider}/subscriptions/builder/create +**Add a new subscription instance for a trigger provider** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [TriggerSubscriptionBuilderCreatePayload](#triggersubscriptionbuildercreatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [TriggerProviderOpaqueResponse](#triggerprovideropaqueresponse)
| + +### [GET] /workspaces/current/trigger-provider/{provider}/subscriptions/builder/logs/{subscription_builder_id} +**Get the request logs for a subscription instance for a trigger provider** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| subscription_builder_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [TriggerProviderOpaqueResponse](#triggerprovideropaqueresponse)
| + +### [POST] /workspaces/current/trigger-provider/{provider}/subscriptions/builder/update/{subscription_builder_id} +**Update a subscription instance for a trigger provider** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| subscription_builder_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [TriggerSubscriptionBuilderUpdatePayload](#triggersubscriptionbuilderupdatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [TriggerProviderOpaqueResponse](#triggerprovideropaqueresponse)
| + +### [POST] /workspaces/current/trigger-provider/{provider}/subscriptions/builder/verify-and-update/{subscription_builder_id} +**Verify and update a subscription instance for a trigger provider** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| subscription_builder_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [TriggerSubscriptionBuilderVerifyPayload](#triggersubscriptionbuilderverifypayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [TriggerProviderOpaqueResponse](#triggerprovideropaqueresponse)
| + +### [GET] /workspaces/current/trigger-provider/{provider}/subscriptions/builder/{subscription_builder_id} +**Get a subscription instance for a trigger provider** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| subscription_builder_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [TriggerProviderOpaqueResponse](#triggerprovideropaqueresponse)
| + +### [GET] /workspaces/current/trigger-provider/{provider}/subscriptions/list +**List all trigger subscriptions for the current tenant's provider** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [TriggerProviderOpaqueResponse](#triggerprovideropaqueresponse)
| + +### [GET] /workspaces/current/trigger-provider/{provider}/subscriptions/oauth/authorize +**Initiate OAuth authorization flow for a trigger provider** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Authorization URL retrieved successfully | **application/json**: [TriggerOAuthAuthorizeResponse](#triggeroauthauthorizeresponse)
| + +### [POST] /workspaces/current/trigger-provider/{provider}/subscriptions/verify/{subscription_id} +**Verify credentials for an existing subscription (edit mode only)** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| subscription_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [TriggerSubscriptionBuilderVerifyPayload](#triggersubscriptionbuilderverifypayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [TriggerProviderOpaqueResponse](#triggerprovideropaqueresponse)
| + +### [POST] /workspaces/current/trigger-provider/{subscription_id}/subscriptions/delete +**Delete a subscription instance** + +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | subscription_id | path | | Yes | string | -| payload | body | | Yes | [TriggerSubscriptionBuilderUpdatePayload](#triggersubscriptionbuilderupdatepayload) | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| -### /workspaces/current/triggers +### [POST] /workspaces/current/trigger-provider/{subscription_id}/subscriptions/update +**Update a subscription instance** -#### GET -##### Summary - -List all trigger providers for the current tenant - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /workspaces/custom-config - -#### POST -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [WorkspaceCustomConfigPayload](#workspacecustomconfigpayload) | +| subscription_id | path | | Yes | string | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [TriggerSubscriptionBuilderUpdatePayload](#triggersubscriptionbuilderupdatepayload)
| -### /workspaces/custom-config/webapp-logo/upload +#### Responses -#### POST -##### Responses +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [TriggerProviderOpaqueResponse](#triggerprovideropaqueresponse)
| -| Code | Description | -| ---- | ----------- | -| 200 | Success | +### [GET] /workspaces/current/triggers +**List all trigger providers for the current tenant** -### /workspaces/info +#### Responses -#### POST -##### Parameters +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [TriggerProviderOpaqueResponse](#triggerprovideropaqueresponse)
| -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [WorkspaceInfoPayload](#workspaceinfopayload) | +### [POST] /workspaces/custom-config +#### Request Body -##### Responses +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [WorkspaceCustomConfigPayload](#workspacecustomconfigpayload)
| -| Code | Description | -| ---- | ----------- | -| 200 | Success | +#### Responses -### /workspaces/switch +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [WorkspaceMutationResponse](#workspacemutationresponse)
| -#### POST -##### Parameters +### [POST] /workspaces/custom-config/webapp-logo/upload +#### Responses -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [SwitchWorkspacePayload](#switchworkspacepayload) | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Logo uploaded | **application/json**: [WorkspaceLogoUploadResponse](#workspacelogouploadresponse)
| -##### Responses +### [POST] /workspaces/info +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [WorkspaceInfoPayload](#workspaceinfopayload)
| -### /workspaces/{tenant_id}/model-providers/{provider}/{icon_type}/{lang} +#### Responses -#### GET -##### Parameters +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [WorkspaceMutationResponse](#workspacemutationresponse)
| + +### [POST] /workspaces/switch +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [SwitchWorkspacePayload](#switchworkspacepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SwitchWorkspaceResponse](#switchworkspaceresponse)
| + +### [GET] /workspaces/{tenant_id}/model-providers/{provider}/{icon_type}/{lang} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | @@ -11547,31 +11026,47 @@ List all trigger providers for the current tenant | provider | path | | Yes | string | | tenant_id | path | | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [BinaryFileResponse](#binaryfileresponse)
| --- ## default Default namespace -### /explore/banners +### [GET] /explore/banners +**Get banner list** -#### GET -##### Summary +#### Parameters -Get banner list +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| language | query | Banner language | No | string,
**Default:** en-US | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [BannerListResponse](#bannerlistresponse)
| --- -### Models +### Schemas + +#### AIModelEntityResponse + +| 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 | [PriceConfigResponse](#priceconfigresponse) | | No | #### APIBasedExtensionListResponse @@ -11673,7 +11168,7 @@ Get banner list | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| interface_theme | string | *Enum:* `"dark"`, `"light"` | Yes | +| interface_theme | string,
**Available values:** "dark", "light" | *Enum:* `"dark"`, `"light"` | Yes | #### AccountNamePayload @@ -11796,10 +11291,17 @@ Get banner list | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | app_mode | string | Application mode | Yes | -| has_context | string | Whether has context | No | +| has_context | string,
**Default:** true | Whether has context | No | | model_mode | string | Model mode | Yes | | model_name | string | Model name | Yes | +#### AdvancedPromptTemplateResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| chat_prompt_config | object | | No | +| completion_prompt_config | object | | No | + #### AgentAppComposerResponse | Name | Type | Description | Required | @@ -11811,6 +11313,17 @@ Get banner list | validation | [ComposerValidationFindingsResponse](#composervalidationfindingsresponse) | | No | | variant | string | | Yes | +#### AgentAppCreatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | Agent description (max 400 chars) | No | +| icon | string | Icon | No | +| icon_background | string | Icon background color | No | +| icon_type | [IconType](#icontype) | Icon type | No | +| name | string | Agent name | Yes | +| role | string | Agent role | No | + #### AgentAppFeaturesPayload Presentation features configurable on an Agent App. @@ -11828,6 +11341,86 @@ default (the config form sends the full desired feature state on save). | suggested_questions_after_answer | [AgentSuggestedQuestionsAfterAnswerFeatureConfig](#agentsuggestedquestionsafteranswerfeatureconfig) | Follow-up suggestions config, e.g. {'enabled': true} | No | | text_to_speech | [AgentTextToSpeechFeatureConfig](#agenttexttospeechfeatureconfig) | Text-to-speech config | No | +#### AgentAppPagination + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [AgentAppPartial](#agentapppartial) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | +| page | integer | | Yes | +| total | integer | | Yes | + +#### AgentAppPartial + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| access_mode | string | | No | +| active_config_is_published | boolean | | No | +| app_id | string | | No | +| author_name | string | | No | +| bound_agent_id | string | | No | +| create_user_name | string | | No | +| created_at | integer | | No | +| created_by | string | | No | +| description | string | | No | +| has_draft_trigger | boolean | | No | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | string | | No | +| icon_url | string | | Yes | +| id | string | | Yes | +| is_starred | boolean | | No | +| max_active_requests | integer | | No | +| mode | string | | Yes | +| model_config | [ModelConfigPartial](#modelconfigpartial) | | No | +| name | string | | Yes | +| published_reference_count | integer | | No | +| published_references | [ [AgentAppPublishedReferenceResponse](#agentapppublishedreferenceresponse) ] | | No | +| role | string | | No | +| tags | [ [Tag](#tag) ] | | No | +| updated_at | integer | | No | +| updated_by | string | | No | +| use_icon_as_answer_icon | boolean | | No | +| workflow | [WorkflowPartial](#workflowpartial) | | No | + +#### AgentAppPublishedReferenceResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_icon | string | | No | +| app_icon_background | string | | No | +| app_icon_type | string | | No | +| app_id | string | | Yes | +| app_name | string | | Yes | + +#### AgentAppUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | App description (max 400 chars) | No | +| icon | string | Icon | No | +| icon_background | string | Icon background color | No | +| icon_type | [IconType](#icontype) | Icon type | No | +| max_active_requests | integer | Maximum active requests | No | +| name | string | App name | Yes | +| role | string | Agent role | No | +| use_icon_as_answer_icon | boolean | Use icon as answer icon | No | + +#### AgentAverageResponseTimeStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| date | string | | Yes | +| latency | number | | Yes | + +#### AgentAverageSessionInteractionStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| date | string | | Yes | +| interactions | number | | Yes | + #### AgentCliToolAuthorizationStatus Authorization state for Agent-scoped CLI tools. @@ -11852,7 +11445,10 @@ composer/publish validators and skipped by runtime request builders. | dangerous_acknowledged | boolean | | No | | dangerous_command | boolean | | No | | description | string | | No | -| enabled | boolean | | No | +| enabled | boolean,
**Default:** true | | No | +| env | [AgentCliToolEnvConfig](#agentclitoolenvconfig) | | No | +| id | string | | No | +| inferred_from | string | | No | | install | string | | No | | install_command | string | | No | | install_commands | [ string ] | | No | @@ -11867,6 +11463,13 @@ composer/publish validators and skipped by runtime request builders. | setup_command | string | | No | | tool_name | string | | No | +#### AgentCliToolEnvConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| secret_refs | [ [AgentSecretRefConfig](#agentsecretrefconfig) ] | | No | +| variables | [ [AgentEnvVariableConfig](#agentenvvariableconfig) ] | | No | + #### AgentCliToolRiskLevel Risk marker for CLI tool bootstrap commands. @@ -11912,19 +11515,22 @@ Risk marker for CLI tool bootstrap commands. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | description | string | | No | +| granularity | string | | No | | id | string | | No | | name | string | | No | | plugin_id | string | | No | | provider | string | | No | | provider_id | string | | No | +| tools_count | integer | | No | #### AgentComposerFileCandidateResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | +| drive_key | string | | No | | file_id | string | | No | | id | string | | No | -| kind | string | | No | +| kind | string,
**Default:** file | | No | | name | string | | No | | reference | string | | No | | remote_url | string | | No | @@ -11964,10 +11570,15 @@ Risk marker for CLI tool bootstrap commands. | ---- | ---- | ----------- | -------- | | description | string | | No | | file_id | string | | No | +| full_archive_file_id | string | | No | +| full_archive_key | string | | No | | id | string | | No | -| kind | string | | No | +| kind | string,
**Default:** skill | | No | +| manifest_files | [ string ] | | No | | name | string | | No | | path | string | | No | +| skill_md_file_id | string | | No | +| skill_md_key | string | | No | #### AgentComposerSoulCandidatesResponse @@ -12050,6 +11661,97 @@ Audit operation recorded for Agent Soul version/revision changes. | version | integer | | Yes | | version_note | string | | No | +#### AgentDailyConversationStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conversation_count | integer | | Yes | +| date | string | | Yes | + +#### AgentDailyEndUserStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| date | string | | Yes | +| terminal_count | integer | | Yes | + +#### AgentDailyMessageStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| date | string | | Yes | +| message_count | integer | | Yes | + +#### AgentDriveDeleteFileByAgentQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| key | string | Drive key, e.g. files/sample.pdf | Yes | + +#### AgentDriveDeleteResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| config_version_id | string | | No | +| removed_keys | [ string ] | | No | +| result | string | | Yes | + +#### AgentDriveDownloadResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| url | string | | Yes | + +#### AgentDriveFileCommitResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| config_version_id | string | | No | +| file | [AgentDriveFileResponse](#agentdrivefileresponse) | | Yes | + +#### AgentDriveFilePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| upload_file_id | string | UploadFile UUID from POST /console/api/files/upload | Yes | + +#### AgentDriveFileResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| drive_key | string | | Yes | +| file_id | string | | Yes | +| mime_type | string | | No | +| name | string | | Yes | +| size | integer | | No | + +#### AgentDriveItemResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | No | +| file_kind | string | | Yes | +| hash | string | | No | +| key | string | | Yes | +| mime_type | string | | No | +| size | integer | | No | + +#### AgentDriveListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| items | [ [AgentDriveItemResponse](#agentdriveitemresponse) ] | | No | + +#### AgentDrivePreviewResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| binary | boolean | | Yes | +| key | string | | Yes | +| size | integer | | No | +| text | string | | No | +| truncated | boolean | | Yes | + #### AgentEnvVariableConfig | Name | Type | Description | Required | @@ -12073,6 +11775,7 @@ Audit operation recorded for Agent Soul version/revision changes. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | +| drive_key | string | | No | | file_id | string | | No | | id | string | | No | | name | string | | No | @@ -12103,7 +11806,7 @@ Audit operation recorded for Agent Soul version/revision changes. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | description | string | | No | -| enabled | boolean | | No | +| enabled | boolean,
**Default:** true | | No | | name | string | | No | #### AgentIconType @@ -12124,6 +11827,7 @@ Supported icon storage formats for Agent roster entries. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | +| active_config_is_published | boolean | | No | | active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | No | | active_config_snapshot_id | string | | No | | agent_kind | [AgentKind](#agentkind) | | Yes | @@ -12141,6 +11845,10 @@ Supported icon storage formats for Agent roster entries. | in_current_workflow_count | integer | | No | | is_in_current_workflow | boolean | | No | | name | string | | Yes | +| published_node_reference_count | integer | | No | +| published_reference_count | integer | | No | +| published_references | [ [AgentPublishedReferenceResponse](#agentpublishedreferenceresponse) ] | | No | +| role | string | | No | | scope | [AgentScope](#agentscope) | | Yes | | source | [AgentSource](#agentsource) | | Yes | | status | [AgentStatus](#agentstatus) | | Yes | @@ -12155,8 +11863,8 @@ Supported icon storage formats for Agent roster entries. | ---- | ---- | ----------- | -------- | | app_id | string | Workflow app id for in-current-workflow markers | No | | keyword | string | | No | -| limit | integer | | No | -| page | integer | | No | +| limit | integer,
**Default:** 20 | | No | +| page | integer,
**Default:** 1 | | No | #### AgentInviteOptionsResponse @@ -12168,6 +11876,17 @@ Supported icon storage formats for Agent roster entries. | page | integer | | Yes | | total | integer | | Yes | +#### AgentIterationLogResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | string | | Yes | +| files | [ ] | | No | +| thought | string | | No | +| tokens | integer | | Yes | +| tool_calls | [ [AgentToolCallResponse](#agenttoolcallresponse) ] | | Yes | +| tool_raw | object | | Yes | + #### AgentKind Agent implementation family. @@ -12202,6 +11921,53 @@ the current roster/workflow APIs scoped to Dify Agent. | ---- | ---- | ----------- | -------- | | AgentKnowledgeQueryMode | string | | | +#### AgentLogItemResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| answer | string | | Yes | +| answer_tokens | integer | | Yes | +| conversation_id | string | | Yes | +| conversation_name | string | | No | +| created_at | integer | | No | +| currency | string | | Yes | +| error | string | | No | +| from_account_id | string | | No | +| from_end_user_id | string | | No | +| from_source | string | | No | +| id | string | | Yes | +| latency | number | | Yes | +| message_id | string | | Yes | +| message_tokens | integer | | Yes | +| query | string | | Yes | +| source | string | | No | +| status | string | | Yes | +| total_price | string | | Yes | +| total_tokens | integer | | Yes | +| updated_at | integer | | No | + +#### AgentLogListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [AgentLogItemResponse](#agentlogitemresponse) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | +| page | integer | | Yes | +| total | integer | | Yes | + +#### AgentLogMetaResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| agent_mode | string | | Yes | +| elapsed_time | number | | No | +| executor | string | | Yes | +| iterations | integer | | Yes | +| start_time | string | | Yes | +| status | string | | Yes | +| total_tokens | integer | | Yes | + #### AgentLogQuery | Name | Type | Description | Required | @@ -12209,6 +11975,26 @@ the current roster/workflow APIs scoped to Dify Agent. | conversation_id | string | Conversation UUID | Yes | | message_id | string | Message UUID | Yes | +#### AgentLogResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| files | [ ] | | No | +| iterations | [ [AgentIterationLogResponse](#agentiterationlogresponse) ] | | Yes | +| meta | [AgentLogMetaResponse](#agentlogmetaresponse) | | Yes | + +#### AgentLogsQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| end | string | End date (YYYY-MM-DD HH:MM) | No | +| keyword | string | Search query, answer, or conversation name | No | +| limit | integer,
**Default:** 20 | Page size | No | +| page | integer,
**Default:** 1 | Page number | No | +| source | string | Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger | No | +| start | string | Start date (YYYY-MM-DD HH:MM) | No | +| status | string | Filter by success, failed, or paused | No | + #### AgentMemoryArtifactConfig | Name | Type | Description | Required | @@ -12248,15 +12034,47 @@ the current roster/workflow APIs scoped to Dify Agent. | state | string | | No | | status | string | | No | +#### AgentProviderListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| AgentProviderListResponse | array | | | + +#### AgentProviderResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| AgentProviderResponse | object | | | + +#### AgentPublishedReferenceResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_icon | string | | No | +| app_icon_background | string | | No | +| app_icon_type | string | | No | +| app_id | string | | Yes | +| app_mode | string | | Yes | +| app_name | string | | Yes | +| app_updated_at | integer | | No | +| node_ids | [ string ] | | No | +| workflow_id | string | | Yes | +| workflow_version | string | | Yes | + #### AgentReferencingWorkflowResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | +| app_icon | string | | No | +| app_icon_background | string | | No | +| app_icon_type | string | | No | | app_id | string | | Yes | | app_mode | string | | Yes | | app_name | string | | Yes | +| app_updated_at | integer | | No | | node_ids | [ string ] | | No | | workflow_id | string | | Yes | +| workflow_version | string | | Yes | #### AgentReferencingWorkflowsResponse @@ -12278,6 +12096,7 @@ the current roster/workflow APIs scoped to Dify Agent. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | +| active_config_is_published | boolean | | No | | active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | No | | active_config_snapshot_id | string | | No | | agent_kind | [AgentKind](#agentkind) | | Yes | @@ -12292,6 +12111,10 @@ the current roster/workflow APIs scoped to Dify Agent. | icon_type | [AgentIconType](#agenticontype) | | No | | id | string | | Yes | | name | string | | Yes | +| published_node_reference_count | integer | | No | +| published_reference_count | integer | | No | +| published_references | [ [AgentPublishedReferenceResponse](#agentpublishedreferenceresponse) ] | | No | +| role | string | | No | | scope | [AgentScope](#agentscope) | | Yes | | source | [AgentSource](#agentsource) | | Yes | | status | [AgentStatus](#agentstatus) | | Yes | @@ -12309,6 +12132,13 @@ the current roster/workflow APIs scoped to Dify Agent. | image | string | | No | | working_dir | string | | No | +#### AgentSandboxUploadPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conversation_id | string | Agent App conversation ID | Yes | +| path | string | File path relative to the sandbox workspace | Yes | + #### AgentScope Visibility and lifecycle scope of an Agent record. @@ -12332,6 +12162,7 @@ Visibility and lifecycle scope of an Agent record. | provider_credential_id | string | | No | | ref | string | | No | | type | string | | No | +| value | string | | No | | variable | string | | No | #### AgentSensitiveWordAvoidanceFeatureConfig @@ -12348,9 +12179,28 @@ Visibility and lifecycle scope of an Agent record. | ---- | ---- | ----------- | -------- | | description | string | | No | | file_id | string | | No | +| full_archive_file_id | string | | No | +| full_archive_key | string | | No | | id | string | | No | +| manifest_files | [ string ] | | No | | name | string | | No | | path | string | | No | +| skill_md_file_id | string | | No | +| skill_md_key | string | | No | + +#### AgentSkillStandardizeResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| manifest | [SkillManifest](#skillmanifest) | | Yes | +| skill | [AgentSkillRefConfig](#agentskillrefconfig) | | Yes | + +#### AgentSkillUploadResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| manifest | [SkillManifest](#skillmanifest) | | Yes | +| skill | [AgentSkillRefConfig](#agentskillrefconfig) | | Yes | #### AgentSoulAppFeaturesConfig @@ -12378,7 +12228,7 @@ Visibility and lifecycle scope of an Agent record. | model | [AgentSoulModelConfig](#agentsoulmodelconfig) | | No | | prompt | [AgentSoulPromptConfig](#agentsoulpromptconfig) | | No | | sandbox | [AgentSoulSandboxConfig](#agentsoulsandboxconfig) | | No | -| schema_version | integer | | No | +| schema_version | integer,
**Default:** 1 | | No | | skills_files | [AgentSoulSkillsFilesConfig](#agentsoulskillsfilesconfig) | | No | | tools | [AgentSoulToolsConfig](#agentsoultoolsconfig) | | No | @@ -12394,16 +12244,16 @@ new callers should send ``plugin_id`` + ``provider`` when available. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | credential_ref | [AgentSoulDifyToolCredentialRef](#agentsouldifytoolcredentialref) | | No | -| credential_type | string | *Enum:* `"api-key"`, `"oauth2"`, `"unauthorized"` | No | +| credential_type | string,
**Available values:** "api-key", "oauth2", "unauthorized",
**Default:** api-key | *Enum:* `"api-key"`, `"oauth2"`, `"unauthorized"` | No | | description | string | | No | -| enabled | boolean | | No | +| enabled | boolean,
**Default:** true | | No | | name | string | | No | | plugin_id | string | | No | | provider | string | | No | | provider_id | string | | No | -| provider_type | string | | No | +| provider_type | string,
**Default:** plugin | | No | | runtime_parameters | object | | No | -| tool_name | string | | Yes | +| tool_name | string | | No | #### AgentSoulDifyToolCredentialRef @@ -12417,7 +12267,7 @@ old Agent tool payloads can be read while new payloads stay explicit. | ---- | ---- | ----------- | -------- | | id | string | | No | | provider | string | | No | -| type | string | *Enum:* `"provider"`, `"tool"` | No | +| type | string,
**Available values:** "provider", "tool",
**Default:** tool | *Enum:* `"provider"`, `"tool"` | No | #### AgentSoulEnvConfig @@ -12518,6 +12368,50 @@ Origin that created or imported the Agent. | ---- | ---- | ----------- | -------- | | AgentSource | string | Origin that created or imported the Agent. | | +#### AgentStatisticChartsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| average_response_time | [ [AgentAverageResponseTimeStatisticResponse](#agentaverageresponsetimestatisticresponse) ] | | No | +| average_session_interactions | [ [AgentAverageSessionInteractionStatisticResponse](#agentaveragesessioninteractionstatisticresponse) ] | | No | +| daily_conversations | [ [AgentDailyConversationStatisticResponse](#agentdailyconversationstatisticresponse) ] | | No | +| daily_end_users | [ [AgentDailyEndUserStatisticResponse](#agentdailyenduserstatisticresponse) ] | | No | +| daily_messages | [ [AgentDailyMessageStatisticResponse](#agentdailymessagestatisticresponse) ] | | No | +| token_usage | [ [AgentTokenUsageStatisticResponse](#agenttokenusagestatisticresponse) ] | | No | +| tokens_per_second | [ [AgentTokensPerSecondStatisticResponse](#agenttokenspersecondstatisticresponse) ] | | No | +| user_satisfaction_rate | [ [AgentUserSatisfactionRateStatisticResponse](#agentusersatisfactionratestatisticresponse) ] | | No | + +#### AgentStatisticSummaryEnvelopeResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| charts | [AgentStatisticChartsResponse](#agentstatisticchartsresponse) | | Yes | +| source | string | | Yes | +| summary | [AgentStatisticSummaryResponse](#agentstatisticsummaryresponse) | | Yes | + +#### AgentStatisticSummaryResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| average_response_time | number | | Yes | +| average_session_interactions | number | | Yes | +| currency | string | | Yes | +| tokens_per_second | number | | Yes | +| total_conversations | integer | | Yes | +| total_end_users | integer | | Yes | +| total_messages | integer | | Yes | +| total_price | string | | Yes | +| total_tokens | integer | | Yes | +| user_satisfaction_rate | number | | Yes | + +#### AgentStatisticsQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| end | string | End date (YYYY-MM-DD HH:MM) | No | +| source | string | Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger | No | +| start | string | Start date (YYYY-MM-DD HH:MM) | No | + #### AgentStatus Soft lifecycle state for Agent records. @@ -12560,6 +12454,43 @@ Soft lifecycle state for Agent records. | tool_input | string | | No | | tool_labels | [JSONValue](#jsonvalue) | | Yes | +#### AgentTokenUsageStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| currency | string | | Yes | +| date | string | | Yes | +| token_count | integer | | Yes | +| total_price | string | | Yes | + +#### AgentTokensPerSecondStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| date | string | | Yes | +| tps | number | | Yes | + +#### AgentToolCallResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| error | string | | No | +| status | string | | Yes | +| time_cost | number
integer | | Yes | +| tool_icon | | | No | +| tool_input | object | | Yes | +| tool_label | string | | Yes | +| tool_name | string | | Yes | +| tool_output | object | | Yes | +| tool_parameters | object | | Yes | + +#### AgentUserSatisfactionRateStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| date | string | | Yes | +| rate | number | | Yes | + #### AllowedExtensionsResponse | Name | Type | Description | Required | @@ -12570,7 +12501,7 @@ Soft lifecycle state for Agent records. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| content | string | | No | +| answer | string | | No | | created_at | integer | | No | | hit_count | integer | | No | | id | string | | Yes | @@ -12582,6 +12513,13 @@ Soft lifecycle state for Agent records. | ---- | ---- | ----------- | -------- | | count | integer | Number of annotations | Yes | +#### AnnotationEmbeddingModelResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| embedding_model_name | string | | No | +| embedding_provider_name | string | | No | + #### AnnotationExportList | Name | Type | Description | Required | @@ -12598,11 +12536,11 @@ Soft lifecycle state for Agent records. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| annotation_content | string | | No | -| annotation_question | string | | No | | created_at | integer | | No | | id | string | | Yes | +| match | string | | No | | question | string | | No | +| response | string | | No | | score | number | | No | | source | string | | No | @@ -12616,6 +12554,22 @@ Soft lifecycle state for Agent records. | page | integer | | Yes | | total | integer | | Yes | +#### AnnotationHitHistoryListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| limit | integer,
**Default:** 20 | Page size | No | +| page | integer,
**Default:** 1 | Page number | No | + +#### AnnotationJobStatusResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| error_msg | string | | No | +| job_id | string | | No | +| job_status | string | | No | +| record_count | integer | | No | + #### AnnotationList | Name | Type | Description | Required | @@ -12631,8 +12585,8 @@ Soft lifecycle state for Agent records. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | keyword | string | Search keyword | No | -| limit | integer | Page size | No | -| page | integer | Page number | No | +| limit | integer,
**Default:** 20 | Page size | No | +| page | integer,
**Default:** 1 | Page number | No | #### AnnotationReplyPayload @@ -12646,7 +12600,16 @@ Soft lifecycle state for Agent records. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| action | string | *Enum:* `"disable"`, `"enable"` | Yes | +| action | string,
**Available values:** "disable", "enable" | *Enum:* `"disable"`, `"enable"` | Yes | + +#### AnnotationSettingResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| embedding_model | [AnnotationEmbeddingModelResponse](#annotationembeddingmodelresponse) | | No | +| enabled | boolean | | Yes | +| id | string | | No | +| score_threshold | number | | No | #### AnnotationSettingUpdatePayload @@ -12793,8 +12756,9 @@ Enum class for api provider schema type. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | access_mode | string | | No | +| active_config_is_published | boolean | | No | | api_base_url | string | | No | -| app_model_config | [ModelConfig](#modelconfig) | | No | +| app_id | string | | No | | bound_agent_id | string | | No | | created_at | integer | | No | | created_by | string | | No | @@ -12805,10 +12769,13 @@ Enum class for api provider schema type. | icon | string | | No | | icon_background | string | | No | | icon_type | string | | No | +| icon_url | string | | Yes | | id | string | | Yes | | max_active_requests | integer | | No | -| mode_compatible_with_agent | string | | Yes | +| mode | string | | Yes | +| model_config | [ModelConfig](#modelconfig) | | No | | name | string | | Yes | +| role | string | | No | | site | [Site](#site) | | No | | tags | [ [Tag](#tag) ] | | No | | tracing | [JSONValue](#jsonvalue) | | No | @@ -12864,10 +12831,11 @@ Enum class for api provider schema type. | ---- | ---- | ----------- | -------- | | creator_ids | [ string ] | Filter by creator account IDs | No | | is_created_by_me | boolean | Filter by creator | No | -| limit | integer | Page size (1-100) | No | -| mode | string | App mode filter
*Enum:* `"advanced-chat"`, `"agent"`, `"agent-chat"`, `"all"`, `"channel"`, `"chat"`, `"completion"`, `"workflow"` | No | +| limit | integer,
**Default:** 20 | Page size (1-100) | No | +| mode | string,
**Available values:** "advanced-chat", "agent", "agent-chat", "all", "channel", "chat", "completion", "workflow",
**Default:** all | App mode filter
*Enum:* `"advanced-chat"`, `"agent"`, `"agent-chat"`, `"all"`, `"channel"`, `"chat"`, `"completion"`, `"workflow"` | No | | name | string | Filter by app name | No | -| page | integer | Page number (1-99999) | No | +| page | integer,
**Default:** 1 | Page number (1-99999) | No | +| sort_by | string,
**Available values:** "earliest_created", "last_modified", "recently_created",
**Default:** last_modified | Sort apps by last modified, recently created, or earliest created
*Enum:* `"earliest_created"`, `"last_modified"`, `"recently_created"` | No | | tag_ids | [ string ] | Filter by tag IDs | No | #### AppMCPServerResponse @@ -12912,8 +12880,11 @@ AppMCPServer Status Enum | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | access_mode | string | | No | +| active_config_is_published | boolean | | No | +| app_id | string | | No | | app_model_config | [ModelConfigPartial](#modelconfigpartial) | | No | | author_name | string | | No | +| bound_agent_id | string | | No | | create_user_name | string | | No | | created_at | integer | | No | | created_by | string | | No | @@ -12923,9 +12894,11 @@ AppMCPServer Status Enum | icon_background | string | | No | | icon_type | string | | No | | id | string | | Yes | +| is_starred | boolean | | No | | max_active_requests | integer | | No | | mode_compatible_with_agent | string | | Yes | | name | string | | Yes | +| role | string | | No | | tags | [ [Tag](#tag) ] | | No | | updated_at | integer | | No | | updated_by | string | | No | @@ -12967,7 +12940,7 @@ AppMCPServer Status Enum | copyright | string | | No | | custom_disclaimer | string | | No | | customize_domain | string | | No | -| customize_token_strategy | string | *Enum:* `"allow"`, `"must"`, `"not_allow"` | No | +| customize_token_strategy | string | | No | | default_language | string | | No | | description | string | | No | | icon | string | | No | @@ -12986,6 +12959,13 @@ AppMCPServer Status Enum | enabled | boolean | Enable or disable tracing | Yes | | tracing_provider | string | Tracing provider | No | +#### AppTraceResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| enabled | boolean | | Yes | +| tracing_provider | string | | No | + #### AppVariableConfig | Name | Type | Description | Required | @@ -12995,11 +12975,17 @@ AppMCPServer Status Enum | required | boolean | | No | | type | string | | Yes | +#### AudioBinaryResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| AudioBinaryResponse | string | | | + #### AudioTranscriptResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| text | string | Transcribed text from audio | Yes | +| text | string | | Yes | #### AutoDisableLogsResponse @@ -13014,6 +13000,49 @@ AppMCPServer Status Enum | ---- | ---- | ----------- | -------- | | avatar_url | string | | Yes | +#### AverageResponseTimeStatisticItem + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| date | string | | Yes | +| latency | number | | Yes | + +#### AverageResponseTimeStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [AverageResponseTimeStatisticItem](#averageresponsetimestatisticitem) ] | | Yes | + +#### AverageSessionInteractionStatisticItem + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| date | string | | Yes | +| interactions | number | | Yes | + +#### AverageSessionInteractionStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [AverageSessionInteractionStatisticItem](#averagesessioninteractionstatisticitem) ] | | Yes | + +#### BannerListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| BannerListResponse | array | | | + +#### BannerResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| content | | | Yes | +| created_at | string | | No | +| id | string | | Yes | +| link | string | | No | +| sort | integer | | Yes | +| status | string | | Yes | + #### BatchImportPayload | Name | Type | Description | Required | @@ -13044,6 +13073,18 @@ Retrieval settings for Amazon Bedrock knowledge base queries. | enabled | boolean | | Yes | | subscription | [SubscriptionModel](#subscriptionmodel) | | Yes | +#### BillingResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| BillingResponse | object | | | + +#### BinaryFileResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| BinaryFileResponse | string | | | + #### BrandingModel | Name | Type | Description | Required | @@ -13054,6 +13095,12 @@ Retrieval settings for Amazon Bedrock knowledge base queries. | login_page_logo | string | | Yes | | workspace_logo | string | | Yes | +#### BuiltinCredentialListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| include_credential_ids | [ string ] | Credential IDs to include even if visibility would hide them | No | + #### BuiltinProviderDefaultCredentialPayload | Name | Type | Description | Required | @@ -13119,12 +13166,12 @@ Button styles for user actions. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| annotation_status | string | Annotation status filter
*Enum:* `"all"`, `"annotated"`, `"not_annotated"` | No | +| annotation_status | string,
**Available values:** "all", "annotated", "not_annotated",
**Default:** all | Annotation status filter
*Enum:* `"all"`, `"annotated"`, `"not_annotated"` | No | | end | string | End date (YYYY-MM-DD HH:MM) | No | | keyword | string | Search keyword | No | -| limit | integer | Page size (1-100) | No | -| page | integer | Page number | No | -| sort_by | string | Sort field and direction
*Enum:* `"-created_at"`, `"-updated_at"`, `"created_at"`, `"updated_at"` | No | +| limit | integer,
**Default:** 20 | Page size (1-100) | No | +| page | integer,
**Default:** 1 | Page number | No | +| sort_by | string,
**Available values:** "-created_at", "-updated_at", "created_at", "updated_at",
**Default:** -updated_at | Sort field and direction
*Enum:* `"-created_at"`, `"-updated_at"`, `"created_at"`, `"updated_at"` | No | | start | string | Start date (YYYY-MM-DD HH:MM) | No | #### ChatMessagePayload @@ -13132,13 +13179,13 @@ Button styles for user actions. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | conversation_id | string | Conversation ID | No | -| files | [ ] | Uploaded files | No | +| files | [ object ] | Uploaded files | No | | inputs | object | | Yes | | model_config | object | | No | | parent_message_id | string | Parent message ID | No | | query | string | User query | Yes | -| response_mode | string | Response mode
*Enum:* `"blocking"`, `"streaming"` | No | -| retriever_from | string | Retriever source | No | +| response_mode | string,
**Available values:** "blocking", "streaming",
**Default:** blocking | Response mode
*Enum:* `"blocking"`, `"streaming"` | No | +| retriever_from | string,
**Default:** dev | Retriever source | No | #### ChatMessagesQuery @@ -13146,18 +13193,18 @@ Button styles for user actions. | ---- | ---- | ----------- | -------- | | conversation_id | string | Conversation ID | Yes | | first_id | string | First message ID for pagination | No | -| limit | integer | Number of messages to return (1-100) | No | +| limit | integer,
**Default:** 20 | Number of messages to return (1-100) | No | #### ChatRequest | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | conversation_id | string | | No | -| files | [ ] | | No | +| files | [ object ] | | No | | inputs | object | | Yes | | parent_message_id | string | | No | | query | string | | Yes | -| retriever_from | string | | No | +| retriever_from | string,
**Default:** explore_app | | No | #### CheckDependenciesResult @@ -13209,8 +13256,8 @@ Button styles for user actions. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | keyword | string | | No | -| limit | integer | | No | -| page | integer | | No | +| limit | integer,
**Default:** 20 | | No | +| page | integer,
**Default:** 1 | | No | #### ChildChunkListResponse @@ -13248,6 +13295,23 @@ Button styles for user actions. | ---- | ---- | ----------- | -------- | | content | string | | Yes | +#### CliToolSuggestion + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| command | string | | No | +| description | string | | No | +| env_suggestions | [ [EnvSuggestion](#envsuggestion) ] | | No | +| inferred_from | string | | No | +| install_commands | [ string ] | | No | +| name | string | | Yes | + +#### CodeBasedExtensionQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| module | string | | Yes | + #### CodeBasedExtensionResponse | Name | Type | Description | Required | @@ -13259,11 +13323,11 @@ Button styles for user actions. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| annotation_status | string | Annotation status filter
*Enum:* `"all"`, `"annotated"`, `"not_annotated"` | No | +| annotation_status | string,
**Available values:** "all", "annotated", "not_annotated",
**Default:** all | Annotation status filter
*Enum:* `"all"`, `"annotated"`, `"not_annotated"` | No | | end | string | End date (YYYY-MM-DD HH:MM) | No | | keyword | string | Search keyword | No | -| limit | integer | Page size (1-100) | No | -| page | integer | Page number | No | +| limit | integer,
**Default:** 20 | Page size (1-100) | No | +| page | integer,
**Default:** 1 | Page number | No | | start | string | Start date (YYYY-MM-DD HH:MM) | No | #### CompletionMessageExplorePayload @@ -13273,29 +13337,29 @@ Button styles for user actions. | files | [ object ] | | No | | inputs | object | | Yes | | query | string | | No | -| response_mode | string | *Enum:* `"blocking"`, `"streaming"` | No | -| retriever_from | string | | No | +| response_mode | string | | No | +| retriever_from | string,
**Default:** explore_app | | No | #### CompletionMessagePayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| files | [ ] | Uploaded files | No | +| files | [ object ] | Uploaded files | No | | inputs | object | | Yes | | model_config | object | | No | | query | string | Query text | No | -| response_mode | string | Response mode
*Enum:* `"blocking"`, `"streaming"` | No | -| retriever_from | string | Retriever source | No | +| response_mode | string,
**Available values:** "blocking", "streaming",
**Default:** blocking | Response mode
*Enum:* `"blocking"`, `"streaming"` | No | +| retriever_from | string,
**Default:** dev | Retriever source | No | #### CompletionRequest | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| files | [ ] | | No | +| files | [ object ] | | No | | inputs | object | | Yes | | query | string | | No | -| response_mode | string | *Enum:* `"blocking"`, `"streaming"` | No | -| retriever_from | string | | No | +| response_mode | string | | No | +| retriever_from | string,
**Default:** explore_app | | No | #### ComplianceDownloadQuery @@ -13303,12 +13367,18 @@ Button styles for user actions. | ---- | ---- | ----------- | -------- | | doc_name | string | Compliance document name | Yes | +#### ComplianceDownloadResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ComplianceDownloadResponse | object | | | + #### ComposerBindingPayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | agent_id | string | | No | -| binding_type | string | *Enum:* `"inline_agent"`, `"roster_agent"` | Yes | +| binding_type | string,
**Available values:** "inline_agent", "roster_agent" | *Enum:* `"inline_agent"`, `"roster_agent"` | Yes | | current_snapshot_id | string | | No | #### ComposerCandidateCapabilities @@ -13349,7 +13419,7 @@ Button styles for user actions. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| locked | boolean | | No | +| locked | boolean,
**Default:** true | | No | | unlocked_from_version_id | string | | No | #### ComposerValidationFindingsResponse @@ -13381,10 +13451,18 @@ Condition detail | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| comparison_operator | string | *Enum:* `"<"`, `"="`, `">"`, `"after"`, `"before"`, `"contains"`, `"empty"`, `"end with"`, `"in"`, `"is"`, `"is not"`, `"not contains"`, `"not empty"`, `"not in"`, `"start with"`, `"≠"`, `"≤"`, `"≥"` | Yes | +| comparison_operator | string,
**Available values:** "<", "=", ">", "after", "before", "contains", "empty", "end with", "in", "is", "is not", "not contains", "not empty", "not in", "start with", "≠", "≤", "≥" | *Enum:* `"<"`, `"="`, `">"`, `"after"`, `"before"`, `"contains"`, `"empty"`, `"end with"`, `"in"`, `"is"`, `"is not"`, `"not contains"`, `"not empty"`, `"not in"`, `"start with"`, `"≠"`, `"≤"`, `"≥"` | Yes | | name | string | | Yes | | value | string
[ string ]
integer
number | | No | +#### ConfigurateMethod + +Enum class for configurate method of provider model. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ConfigurateMethod | string | Enum class for configurate method of provider model. | | + #### ConsoleDatasetListQuery | Name | Type | Description | Required | @@ -13392,10 +13470,22 @@ Condition detail | ids | [ string ] | Filter by dataset IDs | No | | include_all | boolean | Include all datasets | No | | keyword | string | Search keyword | No | -| limit | integer | Number of items per page | No | -| page | integer | Page number | No | +| limit | integer,
**Default:** 20 | Number of items per page | No | +| page | integer,
**Default:** 1 | Page number | No | | tag_ids | [ string ] | Filter by tag IDs | No | +#### ConsoleHumanInputFormDefinitionResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ConsoleHumanInputFormDefinitionResponse | object | | | + +#### ConsoleHumanInputFormSubmitResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ConsoleHumanInputFormSubmitResponse | object | | | + #### ConsoleSegmentListResponse | Name | Type | Description | Required | @@ -13462,12 +13552,20 @@ Condition detail | updated_at | integer | | No | | user_feedback_stats | [FeedbackStat](#feedbackstat) | | No | +#### ConversationInfiniteScrollPagination + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [SimpleConversation](#simpleconversation) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | + #### ConversationListQuery | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | last_id | string | | No | -| limit | integer | | No | +| limit | integer,
**Default:** 20 | | No | | pinned | boolean | | No | #### ConversationMessageDetail @@ -13594,7 +13692,7 @@ Condition detail | icon | string | Icon | No | | icon_background | string | Icon background color | No | | icon_type | [IconType](#icontype) | Icon type | No | -| mode | string | App mode
*Enum:* `"advanced-chat"`, `"agent"`, `"agent-chat"`, `"chat"`, `"completion"`, `"workflow"` | Yes | +| mode | string,
**Available values:** "advanced-chat", "agent-chat", "chat", "completion", "workflow" | App mode
*Enum:* `"advanced-chat"`, `"agent-chat"`, `"chat"`, `"completion"`, `"workflow"` | Yes | | name | string | App name | Yes | #### CreateSnippetPayload @@ -13608,7 +13706,32 @@ Payload for creating a new snippet. | icon_info | [IconInfo](#iconinfo) | | No | | input_fields | [ [InputFieldDefinition](#inputfielddefinition) ] | | No | | name | string | | Yes | -| type | string | *Enum:* `"group"`, `"node"` | No | +| type | string,
**Available values:** "group", "node",
**Default:** node | *Enum:* `"group"`, `"node"` | No | + +#### CredentialConfiguration + +Model class for credential configuration. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credential_id | string | | Yes | +| credential_name | string | | Yes | + +#### CredentialFormSchema + +Model class for credential form schema. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| default | string | | No | +| label | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | Yes | +| max_length | integer | | No | +| options | [ [FormOption](#formoption) ] | | No | +| placeholder | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | No | +| required | boolean,
**Default:** true | | No | +| show_on | [ [FormShowOnObject](#formshowonobject) ],
**Default:** | | No | +| type | [FormType](#formtype) | | Yes | +| variable | string | | Yes | #### CredentialType @@ -13616,6 +13739,41 @@ Payload for creating a new snippet. | ---- | ---- | ----------- | -------- | | CredentialType | string | | | +#### CustomConfigurationResponse + +Model class for provider custom configuration response. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| available_credentials | [ [CredentialConfiguration](#credentialconfiguration) ] | | No | +| can_added_models | [ [UnaddedModelConfiguration](#unaddedmodelconfiguration) ] | | No | +| current_credential_id | string | | No | +| current_credential_name | string | | No | +| custom_models | [ [CustomModelConfiguration](#custommodelconfiguration) ] | | No | +| status | [CustomConfigurationStatus](#customconfigurationstatus) | | Yes | + +#### CustomConfigurationStatus + +Enum class for custom configuration status. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| CustomConfigurationStatus | string | Enum class for custom configuration status. | | + +#### CustomModelConfiguration + +Model class for provider custom model configuration. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| available_model_credentials | [ [CredentialConfiguration](#credentialconfiguration) ],
**Default:** | | No | +| credentials | object | | Yes | +| current_credential_id | string | | No | +| current_credential_name | string | | No | +| model | string | | Yes | +| model_type | [ModelType](#modeltype) | | Yes | +| unadded_to_model_list | boolean | | No | + #### CustomizedPipelineTemplatePayload | Name | Type | Description | Required | @@ -13624,12 +13782,72 @@ Payload for creating a new snippet. | icon_info | object | | No | | name | string | | Yes | +#### DailyConversationStatisticItem + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conversation_count | integer | | Yes | +| date | string | | Yes | + +#### DailyConversationStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [DailyConversationStatisticItem](#dailyconversationstatisticitem) ] | | Yes | + +#### DailyMessageStatisticItem + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| date | string | | Yes | +| message_count | integer | | Yes | + +#### DailyMessageStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [DailyMessageStatisticItem](#dailymessagestatisticitem) ] | | Yes | + +#### DailyTerminalStatisticItem + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| date | string | | Yes | +| terminal_count | integer | | Yes | + +#### DailyTerminalStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [DailyTerminalStatisticItem](#dailyterminalstatisticitem) ] | | Yes | + +#### DailyTokenCostStatisticItem + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| currency | string | | Yes | +| date | string | | Yes | +| token_count | integer | | Yes | +| total_price | string
number | | Yes | + +#### DailyTokenCostStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [DailyTokenCostStatisticItem](#dailytokencoststatisticitem) ] | | Yes | + #### DataSource | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | info_list | [InfoList](#infolist) | | Yes | +#### DataSourceContentPreviewResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| DataSourceContentPreviewResponse | | | | + #### DataSourceIntegrateIconResponse | Name | Type | Description | Required | @@ -13694,7 +13912,7 @@ Payload for creating a new snippet. | indexing_technique | string | | No | | name | string | | Yes | | permission | [PermissionEnum](#permissionenum) | | No | -| provider | string | | No | +| provider | string,
**Default:** vendor | | No | #### DatasetDetail @@ -13704,7 +13922,7 @@ Payload for creating a new snippet. | author_name | string | | No | | built_in_field_enabled | boolean | | No | | chunk_structure | string | | No | -| created_at | object | | No | +| created_at | long | | No | | created_by | string | | No | | data_source_type | string | | No | | description | string | | No | @@ -13732,7 +13950,7 @@ Payload for creating a new snippet. | tags | [ [Tag](#tag) ] | | No | | total_available_documents | integer | | No | | total_documents | integer | | No | -| updated_at | object | | No | +| updated_at | long | | No | | updated_by | string | | No | | word_count | integer | | No | @@ -14151,6 +14369,12 @@ Payload for creating a new snippet. | credentials | object | | No | | name | string | | No | +#### DatasourceCredentialsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| result | | | Yes | + #### DatasourceCustomClientPayload | Name | Type | Description | Required | @@ -14172,6 +14396,21 @@ Payload for creating a new snippet. | datasource_type | string | | Yes | | inputs | object | | Yes | +#### DatasourceOAuthAuthorizationQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credential_id | string | Credential ID to reauthorize | No | + +#### DatasourceOAuthCallbackQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| code | string | Authorization code from OAuth provider | No | +| context_id | string | OAuth proxy context ID | No | +| error | string | Error message from OAuth provider | No | +| state | string | OAuth state parameter | No | + #### DatasourceUpdateNamePayload | Name | Type | Description | Required | @@ -14204,6 +14443,7 @@ about. Stage 4 §4.2. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | +| children | [ { **"array_item"**: { **"children"**: [ object ], **"description"**: , **"type"**: string,
**Available values:** "array", "boolean", "file", "number", "object", "string" }, **"children"**: [ object ], **"description"**: , **"file"**: object, **"name"**: string, **"required"**: boolean, **"type"**: string,
**Available values:** "array", "boolean", "file", "number", "object", "string" } ] | | No | | description | string | | No | | type | [DeclaredOutputType](#declaredoutputtype) | | Yes | @@ -14232,12 +14472,13 @@ code can call ``output.failure_strategy.on_failure`` without None-guards. | ---- | ---- | ----------- | -------- | | array_item | [DeclaredArrayItem](#declaredarrayitem) | | No | | check | [DeclaredOutputCheckConfig](#declaredoutputcheckconfig) | | No | +| children | [ { **"array_item"**: { **"children"**: [ object ], **"description"**: , **"type"**: string,
**Available values:** "array", "boolean", "file", "number", "object", "string" }, **"children"**: [ object ], **"description"**: , **"file"**: object, **"name"**: string, **"required"**: boolean, **"type"**: string,
**Available values:** "array", "boolean", "file", "number", "object", "string" } ] | | No | | description | string | | No | | failure_strategy | [DeclaredOutputFailureStrategy](#declaredoutputfailurestrategy) | | No | | file | [DeclaredOutputFileConfig](#declaredoutputfileconfig) | | No | | id | string | | No | | name | string | | Yes | -| required | boolean | | No | +| required | boolean,
**Default:** true | | No | | type | [DeclaredOutputType](#declaredoutputtype) | | Yes | #### DeclaredOutputFailureStrategy @@ -14284,6 +14525,34 @@ 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 | +| ---- | ---- | ----------- | -------- | +| data | [DefaultModelResponse](#defaultmodelresponse) | | No | + +#### DefaultModelResponse + +Default model entity. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| model | string | | Yes | +| model_type | [ModelType](#modeltype) | | Yes | +| provider | [SimpleProviderEntityResponse](#simpleproviderentityresponse) | | Yes | + #### DeletedTool | Name | Type | Description | Required | @@ -14292,6 +14561,12 @@ Per-output retry configuration that mirrors ``graphon.RetryConfig`` shape. | tool_name | string | | Yes | | type | string | | Yes | +#### DismissNotificationPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| notification_id | string | | Yes | + #### DocumentBatchDownloadZipPayload Request payload for bulk downloading documents as a zip archive. @@ -14484,12 +14759,18 @@ 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 | | ---- | ---- | ----------- | -------- | | keywords | string | | Yes | -| limit | integer | | No | +| limit | integer,
**Default:** 20 | | No | | page | integer | | No | #### EducationAutocompleteResponse @@ -14549,6 +14830,13 @@ Request payload for bulk downloading documents as a zip archive. | timezone | string | | No | | token | string | | Yes | +#### EmailRegisterResetResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [EmailRegisterTokenPairResponse](#emailregistertokenpairresponse) | | Yes | +| result | string | | Yes | + #### EmailRegisterSendPayload | Name | Type | Description | Required | @@ -14556,6 +14844,14 @@ Request payload for bulk downloading documents as a zip archive. | email | string | Email address | Yes | | language | string | Language code | No | +#### EmailRegisterTokenPairResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| access_token | string | | Yes | +| csrf_token | string | | Yes | +| refresh_token | string | | Yes | + #### EmailRegisterValidityPayload | Name | Type | Description | Required | @@ -14564,6 +14860,12 @@ Request payload for bulk downloading documents as a zip archive. | email | string | | Yes | | token | string | | Yes | +#### EmptyObjectResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| EmptyObjectResponse | object | | | + #### EndpointCreatePayload | Name | Type | Description | Required | @@ -14636,6 +14938,35 @@ Request payload for bulk downloading documents as a zip archive. | ---- | ---- | ----------- | -------- | | success | boolean | Operation success | Yes | +#### EnvSuggestion + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| key | string | | Yes | +| reason | string | | No | +| secret_likely | boolean | | No | + +#### EnvironmentVariableItemResponse + +| 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 | + +#### EnvironmentVariableListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| items | [ [EnvironmentVariableItemResponse](#environmentvariableitemresponse) ] | | Yes | + #### EnvironmentVariableUpdatePayload | Name | Type | Description | Required | @@ -14649,19 +14980,31 @@ Request payload for bulk downloading documents as a zip archive. | data | [ [DocumentStatusResponse](#documentstatusresponse) ] | | Yes | | total | integer | | Yes | +#### EventStreamResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| EventStreamResponse | string | | | + #### ExecutionContentType | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | ExecutionContentType | string | | | +#### ExploreAppMetaResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| tool_icons | object | | No | + #### ExternalApiTemplateListQuery | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | keyword | string | Search keyword | No | -| limit | integer | Number of items per page | No | -| page | integer | Page number | No | +| limit | integer,
**Default:** 20 | Number of items per page | No | +| page | integer,
**Default:** 1 | Page number | No | #### ExternalDatasetCreatePayload @@ -14681,6 +15024,16 @@ Request payload for bulk downloading documents as a zip archive. | metadata_filtering_conditions | object | | No | | query | string | | Yes | +#### ExternalKnowledgeApiListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [ExternalKnowledgeApiResponse](#externalknowledgeapiresponse) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | +| page | integer | | Yes | +| total | integer | | Yes | + #### ExternalKnowledgeApiPayload | Name | Type | Description | Required | @@ -14688,6 +15041,26 @@ Request payload for bulk downloading documents as a zip archive. | name | string | | Yes | | settings | object | | Yes | +#### ExternalKnowledgeApiResponse + +| 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 | + +#### ExternalKnowledgeDatasetBindingResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| id | string | | Yes | +| name | string | | Yes | + #### ExternalKnowledgeInfo | Name | Type | Description | Required | @@ -14705,6 +15078,12 @@ Request payload for bulk downloading documents as a zip archive. | score_threshold_enabled | boolean | | No | | top_k | integer | | No | +#### ExternalRetrievalTestResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ExternalRetrievalTestResponse | object
[ object ] | | | + #### FeatureModel | Name | Type | Description | Required | @@ -14715,13 +15094,13 @@ Request payload for bulk downloading documents as a zip archive. | billing | [BillingModel](#billingmodel) | | Yes | | can_replace_logo | boolean | | Yes | | dataset_operator_enabled | boolean | | Yes | -| docs_processing | string | | Yes | +| docs_processing | string,
**Default:** standard | | Yes | | documents_upload_quota | [LimitationModel](#limitationmodel) | | Yes | | education | [EducationModel](#educationmodel) | | Yes | | human_input_email_delivery_enabled | boolean | | Yes | -| is_allow_transfer_workspace | boolean | | Yes | +| is_allow_transfer_workspace | boolean,
**Default:** true | | Yes | | knowledge_pipeline | [KnowledgePipeline](#knowledgepipeline) | | Yes | -| knowledge_rate_limit | integer | | Yes | +| knowledge_rate_limit | integer,
**Default:** 10 | | Yes | | members | [LimitationModel](#limitationmodel) | | Yes | | model_load_balancing_enabled | boolean | | Yes | | next_credit_reset_date | integer | | Yes | @@ -14745,10 +15124,10 @@ Request payload for bulk downloading documents as a zip archive. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | end_date | string | End date (YYYY-MM-DD) | No | -| format | string | Export format
*Enum:* `"csv"`, `"json"` | No | -| from_source | string | Filter by feedback source
*Enum:* `"admin"`, `"user"` | No | +| format | string,
**Available values:** "csv", "json",
**Default:** csv | Export format
*Enum:* `"csv"`, `"json"` | No | +| from_source | string | Filter by feedback source | No | | has_comment | boolean | Only include feedback with comments | No | -| rating | string | Filter by rating
*Enum:* `"dislike"`, `"like"` | No | +| rating | string | Filter by rating | No | | start_date | string | Start date (YYYY-MM-DD) | No | #### FeedbackStat @@ -14758,6 +15137,21 @@ Request payload for bulk downloading documents as a zip archive. | dislike | integer | | Yes | | like | integer | | Yes | +#### FetchFrom + +Enum class for fetch from. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| FetchFrom | string | Enum class for fetch from. | | + +#### FieldModelSchema + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| label | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | Yes | +| placeholder | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | No | + #### FileInfo | Name | Type | Description | Required | @@ -14881,12 +15275,51 @@ Request payload for bulk downloading documents as a zip archive. | ---- | ---- | ----------- | -------- | | FormInputConfig | [ParagraphInputConfig](#paragraphinputconfig)
[SelectInputConfig](#selectinputconfig)
[FileInputConfig](#fileinputconfig)
[FileListInputConfig](#filelistinputconfig) | | | +#### FormOption + +Model class for form option. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| label | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | Yes | +| show_on | [ [FormShowOnObject](#formshowonobject) ],
**Default:** | | No | +| value | string | | Yes | + +#### FormShowOnObject + +Model class for form show on. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| value | string | | Yes | +| variable | string | | Yes | + +#### FormType + +Enum class for form type. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| FormType | string | Enum class for form type. | | + #### GenerateSummaryPayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | document_list | [ string ] | | Yes | +#### GeneratedAppResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| GeneratedAppResponse | | | | + +#### GeneratorResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| GeneratorResponse | | | | + #### Github | Name | Type | Description | Required | @@ -15025,6 +15458,21 @@ Request payload for bulk downloading documents as a zip archive. | ---- | ---- | ----------- | -------- | | inputs | object | Values used to fill missing upstream variables referenced in form_content | No | +#### HumanInputFormPreviewResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| actions | [ object ] | | No | +| display_in_ui | boolean | | No | +| expiration_time | integer | | No | +| form_content | string | | Yes | +| form_id | string | | Yes | +| form_token | string | | No | +| inputs | [ object ] | | No | +| node_id | string | | Yes | +| node_title | string | | Yes | +| resolved_default_values | object | | No | + #### HumanInputFormSubmissionData | Name | Type | Description | Required | @@ -15044,6 +15492,12 @@ Request payload for bulk downloading documents as a zip archive. | 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 | @@ -15052,6 +15506,17 @@ Request payload for bulk downloading documents as a zip archive. | form_id | string | | Yes | | type | string | | Yes | +#### I18nObject + +Model class for i18n object. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| en_US | string | | Yes | +| ja_JP | string | | No | +| pt_BR | string | | No | +| zh_Hans | string | | No | + #### IconInfo Icon information model. @@ -15060,7 +15525,7 @@ Icon information model. | ---- | ---- | ----------- | -------- | | icon | string | | No | | icon_background | string | | No | -| icon_type | string | *Enum:* `"emoji"`, `"image"` | No | +| icon_type | string | | No | | icon_url | string | | No | #### IconType @@ -15083,7 +15548,7 @@ How Dify forwards the end-user's identity to an MCP server. | ---- | ---- | ----------- | -------- | | app_id | string | | No | | app_mode | string | | No | -| current_dsl_version | string | | No | +| current_dsl_version | string,
**Default:** 0.6.0 | | No | | error | string | | No | | id | string | | Yes | | imported_dsl_version | string | | No | @@ -15101,7 +15566,7 @@ Query parameter for including secret variables in export. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| include_secret | string | Whether to include secret variables | No | +| include_secret | string,
**Default:** false | Whether to include secret variables | No | #### IndexingEstimate @@ -15116,8 +15581,8 @@ Query parameter for including secret variables in export. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | dataset_id | string | | No | -| doc_form | string | | No | -| doc_language | string | | No | +| doc_form | string,
**Default:** text_model | | No | +| doc_language | string,
**Default:** English | | No | | indexing_technique | string | | Yes | | info_list | object | | Yes | | process_rule | object | | Yes | @@ -15149,7 +15614,7 @@ Query parameter for including secret variables in export. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| data_source_type | string | *Enum:* `"notion_import"`, `"upload_file"`, `"website_crawl"` | Yes | +| data_source_type | string,
**Available values:** "notion_import", "upload_file", "website_crawl" | *Enum:* `"notion_import"`, `"upload_file"`, `"website_crawl"` | Yes | | file_info_list | [FileInfo](#fileinfo) | | No | | notion_info_list | [ [NotionInfo](#notioninfo) ] | | No | | website_info_list | [WebsiteInfo](#websiteinfo) | | No | @@ -15239,7 +15704,7 @@ Input field definition for snippet parameters. | flow_id | string | Workflow/Flow ID | Yes | | ideal_output | string | Expected ideal output | No | | instruction | string | Instruction for generation | Yes | -| language | string | Programming language (javascript/python) | No | +| language | string,
**Default:** javascript | Programming language (javascript/python) | No | | model_config | [ModelConfig](#modelconfig) | Model configuration | Yes | | node_id | string | Node ID for workflow context | No | @@ -15255,11 +15720,23 @@ Input field definition for snippet parameters. | ---- | ---- | ----------- | -------- | | inputs | object | | No | +#### JSONObject + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| JSONObject | object | | | + #### JSONValue | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| JSONValue | | | | +| JSONValue | string
integer
number
boolean
object
[ object ] | | | + +#### JSONValueType + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| JSONValueType | | | | #### JsonValue @@ -15272,12 +15749,12 @@ Input field definition for snippet parameters. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | data_source | [DataSource](#datasource) | | No | -| doc_form | string | | No | -| doc_language | string | | No | -| duplicate | boolean | | No | +| doc_form | string,
**Default:** text_model | | No | +| doc_language | string,
**Default:** English | | No | +| duplicate | boolean,
**Default:** true | | No | | embedding_model | string | | No | | embedding_model_provider | string | | No | -| indexing_technique | string | *Enum:* `"economy"`, `"high_quality"` | Yes | +| indexing_technique | string,
**Available values:** "economy", "high_quality" | *Enum:* `"economy"`, `"high_quality"` | Yes | | is_multimodal | boolean | | No | | name | string | | No | | original_document_id | string | | No | @@ -15299,6 +15776,12 @@ Enum class for large language model mode. | ---- | ---- | ----------- | -------- | | LLMMode | string | Enum class for large language model mode. | | +#### LearnDifyAppListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| recommended_apps | [ [RecommendedAppResponse](#recommendedappresponse) ] | | Yes | + #### LegacyEndpointUpdatePayload | Name | Type | Description | Required | @@ -15348,6 +15831,13 @@ Enum class for large language model mode. | model | string | | Yes | | model_type | [ModelType](#modeltype) | | Yes | +#### LoadBalancingCredentialValidateResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| error | string | | No | +| result | string | | Yes | + #### LoadBalancingPayload | Name | Type | Description | Required | @@ -15377,6 +15867,13 @@ Enum class for large language model mode. | authorization_code | string | | No | | provider_id | string | | Yes | +#### MCPCallbackQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| code | string | | Yes | +| state | string | | Yes | + #### MCPProviderCreatePayload | Name | Type | Description | Required | @@ -15437,6 +15934,13 @@ Enum class for large language model mode. | marketplace_plugin_unique_identifier | string | | Yes | | version | string | | No | +#### MemberActionTenantResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| result | string | | Yes | +| tenant_id | string | | Yes | + #### MemberInvitePayload | Name | Type | Description | Required | @@ -15445,6 +15949,23 @@ Enum class for large language model mode. | language | string | | No | | role | [TenantAccountRole](#tenantaccountrole) | | Yes | +#### MemberInviteResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| invitation_results | [ [MemberInviteResultResponse](#memberinviteresultresponse) ] | | Yes | +| result | string | | Yes | +| tenant_id | string | | Yes | + +#### MemberInviteResultResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| email | string | | Yes | +| message | string | | No | +| status | string | | Yes | +| url | string | | No | + #### MemberRoleUpdatePayload | Name | Type | Description | Required | @@ -15514,7 +16035,7 @@ Enum class for large language model mode. | ---- | ---- | ----------- | -------- | | content | string | | No | | message_id | string | Message ID | Yes | -| rating | string | *Enum:* `"dislike"`, `"like"` | No | +| rating | string | | No | #### MessageFile @@ -15530,6 +16051,14 @@ Enum class for large language model mode. | upload_file_id | string | | No | | url | string | | No | +#### MessageInfiniteScrollPagination + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [MessageListItem](#messagelistitem) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | + #### MessageInfiniteScrollPaginationResponse | Name | Type | Description | Required | @@ -15538,20 +16067,39 @@ Enum class for large language model mode. | has_more | boolean | | Yes | | limit | integer | | Yes | +#### MessageListItem + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| agent_thoughts | [ [AgentThought](#agentthought) ] | | Yes | +| answer | string | | Yes | +| conversation_id | string | | Yes | +| created_at | integer | | No | +| error | string | | No | +| extra_contents | [ [HumanInputContent](#humaninputcontent) ] | | Yes | +| feedback | [SimpleFeedback](#simplefeedback) | | No | +| id | string | | Yes | +| inputs | object | | Yes | +| message_files | [ [MessageFile](#messagefile) ] | | Yes | +| parent_message_id | string | | No | +| query | string | | Yes | +| retriever_resources | [ [RetrieverResource](#retrieverresource) ] | | Yes | +| status | string | | Yes | + #### MessageListQuery | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | conversation_id | string | Conversation UUID | Yes | | first_id | string | First message ID for pagination | No | -| limit | integer | Number of messages to return (1-100) | No | +| limit | integer,
**Default:** 20 | Number of messages to return (1-100) | No | #### MetadataArgs | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | name | string | | Yes | -| type | string | *Enum:* `"number"`, `"string"`, `"time"` | Yes | +| type | string,
**Available values:** "number", "string", "time" | *Enum:* `"number"`, `"string"`, `"time"` | Yes | #### MetadataDetail @@ -15568,7 +16116,7 @@ Metadata Filtering Condition. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | conditions | [ [Condition](#condition) ] | | No | -| logical_operator | string | *Enum:* `"and"`, `"or"` | No | +| logical_operator | string | | No | #### MetadataOperationData @@ -15599,7 +16147,7 @@ Metadata operation data | ---- | ---- | ----------- | -------- | | created_at | integer | | No | | created_by | string | | No | -| model_dict | [JSONValue](#jsonvalue) | | No | +| model | [JSONValue](#jsonvalue) | | No | | pre_prompt | string | | No | | updated_at | integer | | No | | updated_by | string | | No | @@ -15621,6 +16169,81 @@ 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 | +| current_credential_id | string | | No | +| current_credential_name | string | | No | +| load_balancing | [ModelCredentialLoadBalancingResponse](#modelcredentialloadbalancingresponse) | | Yes | + +#### ModelCredentialSchema + +Model class for model credential schema. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credential_form_schemas | [ [CredentialFormSchema](#credentialformschema) ] | | Yes | +| model | [FieldModelSchema](#fieldmodelschema) | | Yes | + +#### ModelCredentialValidateResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| error | string | | No | +| result | string | | Yes | + +#### ModelFeature + +Enum class for llm feature. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ModelFeature | string | Enum class for llm feature. | | + +#### ModelParameterRulesResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [ParameterRule](#parameterrule) ] | | Yes | + +#### ModelPropertyKey + +Enum class for model property key. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ModelPropertyKey | string | Enum class for model property key. | | + +#### ModelProviderListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [ProviderResponse](#providerresponse) ] | | Yes | + +#### ModelProviderPaymentCheckoutUrlResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| payment_link | string | | Yes | + +#### ModelStatus + +Enum class for model status. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ModelStatus | string | Enum class for model status. | | + #### ModelType Enum class for model type. @@ -15629,11 +16252,35 @@ Enum class for model type. | ---- | ---- | ----------- | -------- | | ModelType | string | Enum class for model type. | | +#### ModelWithProviderEntityResponse + +Model with provider entity. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| deprecated | boolean | | No | +| features | [ [ModelFeature](#modelfeature) ] | | No | +| fetch_from | [FetchFrom](#fetchfrom) | | Yes | +| has_invalid_load_balancing_configs | boolean | | No | +| label | [I18nObject](#i18nobject) | | Yes | +| load_balancing_enabled | boolean | | No | +| model | string | | Yes | +| model_properties | object | | Yes | +| model_type | [ModelType](#modeltype) | | Yes | +| provider | [SimpleProviderEntityResponse](#simpleproviderentityresponse) | | Yes | +| status | [ModelStatus](#modelstatus) | | Yes | + +#### ModelWithProviderListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [ModelWithProviderEntityResponse](#modelwithproviderentityresponse) ] | | Yes | + #### MoreLikeThisQuery | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| response_mode | string | *Enum:* `"blocking"`, `"streaming"` | Yes | +| response_mode | string,
**Available values:** "blocking", "streaming" | *Enum:* `"blocking"`, `"streaming"` | Yes | #### NewAppResponse @@ -15671,11 +16318,11 @@ Lifecycle status of a single declared output within a run. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| node_completed_at | dateTime | | No | +| node_completed_at | string | | No | | node_display_name | string | | Yes | | node_id | string | | Yes | | node_kind | string | | Yes | -| node_started_at | dateTime | | No | +| node_started_at | string | | No | | node_status | [NodeStatus](#nodestatus) | | Yes | | outputs | [ [NodeOutputView](#nodeoutputview) ] | | No | @@ -15699,12 +16346,31 @@ Coarse node-level status used by Inspector to pick a banner. | ---- | ---- | ----------- | -------- | | NodeStatus | string | Coarse node-level status used by Inspector to pick a banner. | | +#### NotificationItemResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| body | string | | Yes | +| frequency | string | | No | +| lang | string | | Yes | +| notification_id | string | | No | +| subtitle | string | | Yes | +| title | string | | Yes | +| title_pic_url | string | | Yes | + +#### NotificationResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| notifications | [ [NotificationItemResponse](#notificationitemresponse) ] | | Yes | +| should_show | boolean | | Yes | + #### NotionEstimatePayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| doc_form | string | | No | -| doc_language | string | | No | +| doc_form | string,
**Default:** text_model | | No | +| doc_language | string,
**Default:** English | | No | | notion_info_list | [ object ] | | Yes | | process_rule | object | | Yes | @@ -15759,12 +16425,38 @@ Coarse node-level status used by Inspector to pick a banner. | page_name | string | | Yes | | type | string | | Yes | +#### OAuthCallbackQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| code | string | Authorization code from OAuth provider | Yes | +| state | string | OAuth state parameter | No | + +#### OAuthClientPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| client_id | string | | Yes | + +#### OAuthDataSourceBindingQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| code | string | Authorization code from OAuth provider | Yes | + #### OAuthDataSourceBindingResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | result | string | Operation result | Yes | +#### OAuthDataSourceCallbackQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| code | string | Authorization code from OAuth provider | No | +| error | string | Error message from OAuth provider | No | + #### OAuthDataSourceResponse | Name | Type | Description | Required | @@ -15777,6 +16469,71 @@ Coarse node-level status used by Inspector to pick a banner. | ---- | ---- | ----------- | -------- | | result | string | Operation result | Yes | +#### OAuthLoginQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| invite_token | string | Optional invitation token | No | +| language | string | Preferred interface language | No | +| timezone | string | Preferred timezone | No | + +#### OAuthProviderAccountResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| avatar | string | | No | +| email | string | | Yes | +| interface_language | string | | Yes | +| name | string | | Yes | +| timezone | string | | Yes | + +#### OAuthProviderAppResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_icon | string | | Yes | +| app_label | object | | Yes | +| scope | string | | Yes | + +#### OAuthProviderAuthorizeResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| code | string | | Yes | + +#### OAuthProviderRequest + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| client_id | string | | Yes | +| redirect_uri | string | | Yes | + +#### OAuthProviderTokenResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| access_token | string | | Yes | +| expires_in | integer | | Yes | +| refresh_token | string | | Yes | +| token_type | string | | Yes | + +#### OAuthTokenRequest + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| client_id | string | | Yes | +| client_secret | string | | No | +| code | string | | No | +| grant_type | string | | Yes | +| redirect_uri | string | | No | +| refresh_token | string | | No | + +#### OpaqueObjectResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| OpaqueObjectResponse | object | | | + #### OutputErrorStrategy Per-output failure handling strategy. @@ -15835,6 +16592,13 @@ output check fails and any configured retry attempts have been exhausted. | page | integer | | Yes | | total | integer | | Yes | +#### PaginationQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| limit | integer,
**Default:** 20 | | No | +| page | integer,
**Default:** 1 | | No | + #### ParagraphInputConfig Form input definition. @@ -15845,6 +16609,49 @@ Form input definition. | output_variable_name | string | | Yes | | type | string | | No | +#### ParameterRule + +Model class for parameter rule. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| default | | | No | +| help | [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 | +| max | number | | No | +| min | number | | No | +| name | string | | Yes | +| options | [ string ],
**Default:** | | No | +| precision | integer | | No | +| required | boolean | | No | +| type | [ParameterType](#parametertype) | | Yes | +| use_template | string | | No | + +#### ParameterType + +Enum class for parameter type. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ParameterType | string | Enum class for parameter type. | | + +#### Parameters + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| annotation_reply | [JSONObject](#jsonobject) | | Yes | +| file_upload | [JSONObject](#jsonobject) | | Yes | +| more_like_this | [JSONObject](#jsonobject) | | Yes | +| opening_statement | | | No | +| retriever_resource | [JSONObject](#jsonobject) | | Yes | +| sensitive_word_avoidance | [JSONObject](#jsonobject) | | Yes | +| speech_to_text | [JSONObject](#jsonobject) | | Yes | +| suggested_questions | [ string ] | | Yes | +| suggested_questions_after_answer | [JSONObject](#jsonobject) | | Yes | +| system_parameters | [SystemParameters](#systemparameters) | | Yes | +| text_to_speech | [JSONObject](#jsonobject) | | Yes | +| user_input_form | [ [JSONObject](#jsonobject) ] | | Yes | + #### Parser | Name | Type | Description | Required | @@ -15860,6 +16667,19 @@ Form input definition. | file_name | string | | Yes | | plugin_unique_identifier | string | | Yes | +#### ParserAutoUpgradeChange + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| auto_upgrade | [PluginAutoUpgradeSettingsPayload](#pluginautoupgradesettingspayload) | | Yes | +| category | [PluginCategory](#plugincategory) | | Yes | + +#### ParserAutoUpgradeFetch + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| category | [PluginCategory](#plugincategory) | | Yes | + #### ParserCreateCredential | Name | Type | Description | Required | @@ -15932,7 +16752,7 @@ Form input definition. | parameter | string | | Yes | | plugin_id | string | | Yes | | provider | string | | Yes | -| provider_type | string | *Enum:* `"tool"`, `"trigger"` | Yes | +| provider_type | string,
**Available values:** "tool", "trigger" | *Enum:* `"tool"`, `"trigger"` | Yes | #### ParserDynamicOptionsWithCredentials @@ -15956,6 +16776,7 @@ Form input definition. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | +| category | [PluginCategory](#plugincategory) | | Yes | | plugin_id | string | | Yes | #### ParserGetCredentials @@ -16017,8 +16838,8 @@ Form input definition. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| page | integer | Page number | No | -| page_size | integer | Page size (1-256) | No | +| page | integer,
**Default:** 1 | Page number | No | +| page_size | integer,
**Default:** 256 | Page size (1-256) | No | #### ParserMarketplaceUpgrade @@ -16043,8 +16864,8 @@ Form input definition. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| debug_permission | [DebugPermission](#debugpermission) | | Yes | -| install_permission | [InstallPermission](#installpermission) | | Yes | +| debug_permission | [DebugPermission](#debugpermission) | | No | +| install_permission | [InstallPermission](#installpermission) | | No | #### ParserPluginIdentifierQuery @@ -16074,24 +16895,17 @@ Form input definition. | model | string | | Yes | | model_type | [ModelType](#modeltype) | | Yes | -#### ParserPreferencesChange - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| auto_upgrade | [PluginAutoUpgradeSettingsPayload](#pluginautoupgradesettingspayload) | | Yes | -| permission | [PluginPermissionSettingsPayload](#pluginpermissionsettingspayload) | | Yes | - #### ParserPreferredProviderType | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| preferred_provider_type | string | *Enum:* `"custom"`, `"system"` | Yes | +| preferred_provider_type | string,
**Available values:** "custom", "system" | *Enum:* `"custom"`, `"system"` | Yes | #### ParserReadme | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| language | string | | No | +| language | string,
**Default:** en-US | | No | | plugin_unique_identifier | string | | Yes | #### ParserSwitch @@ -16106,8 +16920,8 @@ Form input definition. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| page | integer | Page number | No | -| page_size | integer | Page size (1-256) | No | +| page | integer,
**Default:** 1 | Page number | No | +| page_size | integer,
**Default:** 256 | Page size (1-256) | No | #### ParserUninstall @@ -16165,7 +16979,7 @@ Shared permission levels for resources (datasets, credentials, etc.) | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| type | string | Template source: built-in or customized | No | +| type | string,
**Default:** built-in | Template source: built-in or customized | No | #### PipelineTemplateDetailResponse @@ -16197,8 +17011,8 @@ Shared permission levels for resources (datasets, credentials, etc.) | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| language | string | Template language | No | -| type | string | Template source: built-in or customized | No | +| language | string,
**Default:** en-US | Template language | No | +| type | string,
**Default:** built-in | Template source: built-in or customized | No | #### PipelineTemplateListResponse @@ -16214,7 +17028,7 @@ Shared permission levels for resources (datasets, credentials, etc.) | allowed_file_types | [ string ] | | No | | allowed_file_upload_methods | [ string ] | | No | | belong_to_node_id | string | | Yes | -| default_value | object | | No | +| default_value | | | No | | label | string | | Yes | | max_length | integer | | No | | options | [ string ] | | No | @@ -16225,6 +17039,20 @@ Shared permission levels for resources (datasets, credentials, etc.) | unit | string | | No | | variable | string | | Yes | +#### PluginAutoUpgradeChangeResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| message | string | | No | +| success | boolean | | Yes | + +#### PluginAutoUpgradeFetchResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| auto_upgrade | [PluginAutoUpgradeSettingsResponseModel](#pluginautoupgradesettingsresponsemodel) | | Yes | +| category | [PluginCategory](#plugincategory) | | Yes | + #### PluginAutoUpgradeSettingsPayload | Name | Type | Description | Required | @@ -16235,6 +17063,96 @@ Shared permission levels for resources (datasets, credentials, etc.) | upgrade_mode | [UpgradeMode](#upgrademode) | | No | | upgrade_time_of_day | integer | | No | +#### PluginAutoUpgradeSettingsResponseModel + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| exclude_plugins | [ string ] | | Yes | +| include_plugins | [ string ] | | Yes | +| strategy_setting | [StrategySetting](#strategysetting) | | Yes | +| upgrade_mode | [UpgradeMode](#upgrademode) | | Yes | +| upgrade_time_of_day | integer | | Yes | + +#### PluginCategory + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| PluginCategory | string | | | + +#### PluginCategoryBuiltinToolProviderResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| allow_delete | boolean | | Yes | +| author | string | | Yes | +| description | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | | Yes | +| icon | string
object | | Yes | +| icon_dark | string
object | | Yes | +| id | string | | Yes | +| is_team_authorization | boolean | | Yes | +| label | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | | Yes | +| labels | [ string ] | | Yes | +| name | string | | Yes | +| plugin_id | string | | Yes | +| plugin_unique_identifier | string | | Yes | +| team_credentials | object | | Yes | +| tools | [ [PluginCategoryBuiltinToolResponse](#plugincategorybuiltintoolresponse) ] | | Yes | +| type | [ToolProviderType](#toolprovidertype) | | Yes | + +#### PluginCategoryBuiltinToolResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| author | string | | Yes | +| description | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | | Yes | +| label | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | | Yes | +| labels | [ string ] | | Yes | +| name | string | | Yes | +| output_schema | object | | Yes | +| parameters | [ object ] | | No | + +#### PluginCategoryInstalledPluginResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| checksum | string | | Yes | +| created_at | dateTime | | Yes | +| declaration | [PluginDeclarationResponse](#plugindeclarationresponse) | | 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 | + +#### PluginCategoryListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| page | integer,
**Default:** 1 | Page number | No | +| page_size | integer,
**Default:** 256 | Page size (1-256) | No | + +#### PluginCategoryListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| builtin_tools | [ [PluginCategoryBuiltinToolProviderResponse](#plugincategorybuiltintoolproviderresponse) ] | | Yes | +| has_more | boolean | | Yes | +| plugins | [ [PluginCategoryInstalledPluginResponse](#plugincategoryinstalledpluginresponse) ] | | Yes | + +#### PluginDaemonOperationResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| PluginDaemonOperationResponse | | | | + #### PluginDebuggingKeyResponse | Name | Type | Description | Required | @@ -16243,6 +17161,32 @@ Shared permission levels for resources (datasets, credentials, etc.) | key | string | | Yes | | port | integer | | Yes | +#### PluginDeclarationResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| agent_strategy | object | | No | +| author | string | | Yes | +| category | [PluginCategory](#plugincategory) | | Yes | +| created_at | dateTime | | Yes | +| datasource | object | | No | +| description | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | | Yes | +| endpoint | object | | No | +| icon | string | | Yes | +| icon_dark | string | | No | +| label | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | | Yes | +| meta | object | | Yes | +| model | [ProviderEntityResponse](#providerentityresponse) | | No | +| name | string | | Yes | +| plugins | object | | Yes | +| repo | string | | No | +| resource | object | | Yes | +| tags | [ string ] | | No | +| tool | object | | No | +| trigger | object | | No | +| verified | boolean | | No | +| version | string | | Yes | + #### PluginDependency | Name | Type | Description | Required | @@ -16251,6 +17195,12 @@ Shared permission levels for resources (datasets, credentials, etc.) | type | [Type](#type) | | Yes | | value | [Github](#github)
[Marketplace](#marketplace)
[Package](#package) | | Yes | +#### PluginDynamicOptionsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| options | | | Yes | + #### PluginEndpointListResponse | Name | Type | Description | Required | @@ -16270,12 +17220,57 @@ Shared permission levels for resources (datasets, credentials, etc.) | ---- | ---- | ----------- | -------- | | PluginInstallationScope | string | | | +#### PluginInstallationSource + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| PluginInstallationSource | string | | | + +#### PluginInstallationsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| plugins | | | Yes | + +#### PluginListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| plugins | | | Yes | +| total | integer | | Yes | + #### PluginManagerModel | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | enabled | boolean | | Yes | +#### PluginManifestResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| manifest | | | Yes | + +#### PluginOAuthAuthorizationUrlResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| authorization_url | string | The URL of the authorization. | Yes | + +#### PluginOperationSuccessResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| message | string | | No | +| success | boolean | | Yes | + +#### PluginPermissionResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| debug_permission | [DebugPermission](#debugpermission) | | Yes | +| install_permission | [InstallPermission](#installpermission) | | Yes | + #### PluginPermissionSettingsPayload | Name | Type | Description | Required | @@ -16283,6 +17278,30 @@ Shared permission levels for resources (datasets, credentials, etc.) | debug_permission | [DebugPermission](#debugpermission) | | No | | install_permission | [InstallPermission](#installpermission) | | No | +#### PluginReadmeResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| readme | string | | Yes | + +#### PluginTaskResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| task | | | Yes | + +#### PluginTasksResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| tasks | | | Yes | + +#### PluginVersionsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| versions | | | Yes | + #### PreProcessingRule | Name | Type | Description | Required | @@ -16298,6 +17317,17 @@ Shared permission levels for resources (datasets, credentials, etc.) | content | string | | Yes | | summary | string | | No | +#### PriceConfigResponse + +Serialized pricing info with codegen-safe decimal string patterns. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| currency | string | | Yes | +| input | string | | Yes | +| output | string | | No | +| unit | string | | Yes | + #### ProcessRule | Name | Type | Description | Required | @@ -16313,6 +17343,134 @@ Dataset Process Rule Mode | ---- | ---- | ----------- | -------- | | ProcessRuleMode | string | Dataset Process Rule Mode | | +#### ProviderCredentialResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credentials | object | | No | + +#### ProviderCredentialSchema + +Model class for provider credential schema. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credential_form_schemas | [ [CredentialFormSchema](#credentialformschema) ] | | Yes | + +#### ProviderCredentialValidateResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| error | string | | No | +| result | string,
**Available values:** "error", "success" | *Enum:* `"error"`, `"success"` | Yes | + +#### 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. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| 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 | + +#### ProviderModelWithStatusEntity + +Model class for model response. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| deprecated | boolean | | No | +| features | [ [ModelFeature](#modelfeature) ] | | No | +| fetch_from | [FetchFrom](#fetchfrom) | | Yes | +| has_invalid_load_balancing_configs | boolean | | No | +| label | [I18nObject](#i18nobject) | | Yes | +| load_balancing_enabled | boolean | | No | +| model | string | | Yes | +| model_properties | object | | Yes | +| model_type | [ModelType](#modeltype) | | Yes | +| status | [ModelStatus](#modelstatus) | | Yes | + +#### ProviderQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| provider | string | | Yes | + +#### ProviderQuotaType + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ProviderQuotaType | string | | | + +#### ProviderResponse + +Model class for provider response. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| background | string | | No | +| configurate_methods | [ [ConfigurateMethod](#configuratemethod) ] | | Yes | +| custom_configuration | [CustomConfigurationResponse](#customconfigurationresponse) | | Yes | +| description | [I18nObject](#i18nobject) | | No | +| help | [ProviderHelpEntity](#providerhelpentity) | | No | +| icon_small | [I18nObject](#i18nobject) | | No | +| icon_small_dark | [I18nObject](#i18nobject) | | No | +| label | [I18nObject](#i18nobject) | | Yes | +| model_credential_schema | [ModelCredentialSchema](#modelcredentialschema) | | No | +| preferred_provider_type | [ProviderType](#providertype) | | Yes | +| provider | string | | Yes | +| provider_credential_schema | [ProviderCredentialSchema](#providercredentialschema) | | No | +| supported_model_types | [ [ModelType](#modeltype) ] | | Yes | +| system_configuration | [SystemConfigurationResponse](#systemconfigurationresponse) | | Yes | +| tenant_id | string | | Yes | + +#### ProviderType + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ProviderType | string | | | + +#### ProviderWithModelsDataResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [ProviderWithModelsResponse](#providerwithmodelsresponse) ] | | Yes | + +#### ProviderWithModelsResponse + +Model class for provider with models response. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| icon_small | [I18nObject](#i18nobject) | | No | +| icon_small_dark | [I18nObject](#i18nobject) | | No | +| label | [I18nObject](#i18nobject) | | Yes | +| models | [ [ProviderModelWithStatusEntity](#providermodelwithstatusentity) ] | | Yes | +| provider | string | | Yes | +| status | [CustomConfigurationStatus](#customconfigurationstatus) | | Yes | +| tenant_id | string | | Yes | + #### PublishWorkflowPayload Payload for publishing snippet workflow. @@ -16330,7 +17488,7 @@ Payload for publishing snippet workflow. | inputs | object | | Yes | | is_preview | boolean | | No | | original_document_id | string | | No | -| response_mode | string | *Enum:* `"blocking"`, `"streaming"` | No | +| response_mode | string,
**Available values:** "blocking", "streaming",
**Default:** streaming | *Enum:* `"blocking"`, `"streaming"` | No | | start_node_id | string | | Yes | #### QAPreviewDetail @@ -16345,9 +17503,28 @@ Payload for publishing snippet workflow. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | limit | integer | | Yes | -| reset_date | integer | | Yes | +| reset_date | integer,
**Default:** -1 | | Yes | | usage | integer | | Yes | +#### QuotaConfiguration + +Model class for provider quota configuration. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| is_valid | boolean | | Yes | +| quota_limit | integer | | Yes | +| quota_type | [ProviderQuotaType](#providerquotatype) | | Yes | +| quota_unit | [QuotaUnit](#quotaunit) | | Yes | +| quota_used | integer | | Yes | +| restrict_models | [ [RestrictModel](#restrictmodel) ],
**Default:** | | No | + +#### QuotaUnit + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| QuotaUnit | string | | | + #### RagPipelineDatasetImportPayload | Name | Type | Description | Required | @@ -16386,11 +17563,23 @@ Payload for publishing snippet workflow. | pipeline_id | string | | No | | status | [ImportStatus](#importstatus) | | Yes | +#### RagPipelineOpaqueResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| RagPipelineOpaqueResponse | | | | + #### RagPipelineRecommendedPluginQuery | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| type | string | | No | +| type | string,
**Default:** all | | No | + +#### RagPipelineStepParametersResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| variables | | | Yes | #### RagPipelineWorkflowPublishResponse @@ -16407,6 +17596,12 @@ Payload for publishing snippet workflow. | result | string | | Yes | | updated_at | integer | | Yes | +#### RecommendedAppDetailResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| RecommendedAppDetailResponse | object | | | + #### RecommendedAppInfoResponse | Name | Type | Description | Required | @@ -16446,6 +17641,12 @@ Payload for publishing snippet workflow. | ---- | ---- | ----------- | -------- | | language | string | Language code for recommended app localization | No | +#### RedirectResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| RedirectResponse | string | | | + #### RedirectUrlResponse | Name | Type | Description | Required | @@ -16492,6 +17693,14 @@ Payload for publishing snippet workflow. | reranking_model_name | string | | No | | reranking_provider_name | string | | No | +#### RestrictModel + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| base_model_name | string | | No | +| model | string | | Yes | +| model_type | [ModelType](#modeltype) | | Yes | + #### ResultResponse | Name | Type | Description | Required | @@ -16524,41 +17733,41 @@ Payload for publishing snippet workflow. | ---- | ---- | ----------- | -------- | | retrieval_method | [ string ] | | Yes | -#### RosterAgentCreatePayload +#### RetrieverResource | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| agent_soul | [AgentSoulConfig](#agentsoulconfig) | | No | -| description | string | | No | -| icon | string | | No | -| icon_background | string | | No | -| icon_type | [AgentIconType](#agenticontype) | | No | -| name | string | | Yes | -| version_note | string | | No | - -#### RosterAgentUpdatePayload - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| description | string | | No | -| icon | string | | No | -| icon_background | string | | No | -| icon_type | [AgentIconType](#agenticontype) | | No | -| name | string | | No | +| content | string | | No | +| created_at | integer | | No | +| data_source_type | string | | No | +| dataset_id | string | | No | +| dataset_name | string | | No | +| document_id | string | | No | +| document_name | string | | No | +| hit_count | integer | | No | +| id | string | | No | +| index_node_hash | string | | No | +| message_id | string | | No | +| position | integer | | Yes | +| score | number | | No | +| segment_id | string | | No | +| segment_position | integer | | No | +| summary | string | | No | +| word_count | integer | | No | #### RosterListQuery | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | keyword | string | | No | -| limit | integer | | No | -| page | integer | | No | +| limit | integer,
**Default:** 20 | | No | +| page | integer,
**Default:** 1 | | No | #### Rule | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| parent_mode | string | *Enum:* `"full-doc"`, `"paragraph"` | No | +| parent_mode | string | | No | | pre_processing_rules | [ [PreProcessingRule](#preprocessingrule) ] | | No | | segmentation | [Segmentation](#segmentation) | | No | | subchunk_segmentation | [Segmentation](#segmentation) | | No | @@ -16567,7 +17776,7 @@ Payload for publishing snippet workflow. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| code_language | string | Programming language for code generation | No | +| code_language | string,
**Default:** javascript | Programming language for code generation | No | | instruction | string | Rule generation instruction | Yes | | model_config | [ModelConfig](#modelconfig) | Model configuration | Yes | | no_variable | boolean | Whether to exclude variables | No | @@ -16587,18 +17796,85 @@ Payload for publishing snippet workflow. | instruction | string | Structured output generation instruction | Yes | | model_config | [ModelConfig](#modelconfig) | Model configuration | Yes | +#### SandboxFileEntryResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| mtime | integer | | No | +| name | string | | Yes | +| size | integer | | No | +| type | string,
**Available values:** "dir", "file", "other", "symlink" | *Enum:* `"dir"`, `"file"`, `"other"`, `"symlink"` | Yes | + +#### SandboxListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| entries | [ [SandboxFileEntryResponse](#sandboxfileentryresponse) ] | | No | +| path | string | | Yes | +| truncated | boolean | | No | + +#### SandboxReadResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| binary | boolean | | Yes | +| path | string | | Yes | +| size | integer | | No | +| text | string | | No | +| truncated | boolean | | Yes | + +#### SandboxToolFileResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| reference | string | | Yes | +| transfer_method | string,
**Default:** tool_file | | No | + +#### SandboxUploadResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| file | [SandboxToolFileResponse](#sandboxtoolfileresponse) | | Yes | +| path | string | | Yes | + #### SavedMessageCreatePayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | message_id | string | | Yes | +#### SavedMessageInfiniteScrollPagination + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [SavedMessageItem](#savedmessageitem) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | + +#### SavedMessageItem + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| answer | string | | Yes | +| created_at | integer | | No | +| feedback | [SimpleFeedback](#simplefeedback) | | No | +| id | string | | Yes | +| inputs | object | | Yes | +| message_files | [ [MessageFile](#messagefile) ] | | Yes | +| query | string | | Yes | + #### SavedMessageListQuery | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | last_id | string | | No | -| limit | integer | | No | +| limit | integer,
**Default:** 20 | | No | + +#### SchemaDefinitionsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| SchemaDefinitionsResponse | | | | #### SegmentAttachmentResponse @@ -16644,11 +17920,11 @@ Payload for publishing snippet workflow. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| enabled | string | | No | +| enabled | string,
**Default:** all | | No | | hit_count_gte | integer | | No | | keyword | string | | No | -| limit | integer | | No | -| page | integer | | No | +| limit | integer,
**Default:** 20 | | No | +| page | integer,
**Default:** 1 | | No | | status | [ string ] | | No | #### SegmentResponse @@ -16700,7 +17976,8 @@ Payload for publishing snippet workflow. | ---- | ---- | ----------- | -------- | | chunk_overlap | integer | | No | | max_tokens | integer | | Yes | -| separator | string | | No | +| separator | string,
**Default:** + | | No | #### SelectInputConfig @@ -16718,6 +17995,18 @@ Payload for publishing snippet workflow. | id | string | | Yes | | name | string | | Yes | +#### SimpleConversation + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | No | +| id | string | | Yes | +| inputs | object | | Yes | +| introduction | string | | No | +| name | string | | Yes | +| status | string | | Yes | +| updated_at | integer | | No | + #### SimpleDataResponse | Name | Type | Description | Required | @@ -16733,6 +18022,12 @@ Payload for publishing snippet workflow. | session_id | string | | No | | type | string | | Yes | +#### SimpleFeedback + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| rating | string | | No | + #### SimpleMessageDetail | Name | Type | Description | Required | @@ -16755,6 +18050,21 @@ Payload for publishing snippet workflow. | model_dict | [JSONValue](#jsonvalue) | | No | | pre_prompt | string | | No | +#### SimpleProviderEntityResponse + +Simple provider entity response. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| icon_small | [I18nObject](#i18nobject) | | No | +| icon_small_dark | [I18nObject](#i18nobject) | | No | +| label | [I18nObject](#i18nobject) | | Yes | +| models | [ [AIModelEntityResponse](#aimodelentityresponse) ],
**Default:** | | No | +| provider | string | | Yes | +| provider_name | string | | No | +| supported_model_types | [ [ModelType](#modeltype) ] | | Yes | +| tenant_id | string | | Yes | + #### SimpleResultDataResponse | Name | Type | Description | Required | @@ -16786,34 +18096,47 @@ Payload for publishing snippet workflow. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| app_base_url | string | | No | | chat_color_theme | string | | No | -| chat_color_theme_inverted | boolean | | No | -| code | string | | No | +| chat_color_theme_inverted | boolean | | Yes | | copyright | string | | No | -| created_at | integer | | No | -| created_by | string | | No | | custom_disclaimer | string | | No | -| customize_domain | string | | No | -| customize_token_strategy | string | | No | -| default_language | string | | No | +| default_language | string | | Yes | | description | string | | No | | icon | string | | No | | icon_background | string | | No | -| icon_type | string
[IconType](#icontype) | | No | +| icon_type | string | | No | +| icon_url | string | | Yes | | privacy_policy | string | | No | -| prompt_public | boolean | | No | -| show_workflow_steps | boolean | | No | -| title | string | | No | -| updated_at | integer | | No | -| updated_by | string | | No | -| use_icon_as_answer_icon | boolean | | No | +| show_workflow_steps | boolean | | Yes | +| title | string | | Yes | +| use_icon_as_answer_icon | boolean | | Yes | + +#### SkillManifest + +Validated metadata extracted from a Skill package. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | Yes | +| entry_path | string | | Yes | +| files | [ string ] | | Yes | +| hash | string | | Yes | +| name | string | | Yes | +| size | integer | | Yes | + +#### SkillToolInferenceResult + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| cli_tools | [ [CliToolSuggestion](#clitoolsuggestion) ] | | No | +| inferable | boolean | | Yes | +| reason | string | | No | #### Snippet | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| created_at | object | | No | +| created_at | long | | No | | created_by | [_AnonymousInlineModel_b0fd3f86d9d5](#_anonymousinlinemodel_b0fd3f86d9d5) | | No | | description | string | | No | | graph | object | | No | @@ -16824,11 +18147,23 @@ Payload for publishing snippet workflow. | name | string | | No | | tags | [ [_AnonymousInlineModel_7b8b49ca164e](#_anonymousinlinemodel_7b8b49ca164e) ] | | No | | type | string | | No | -| updated_at | object | | 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 | +| ---- | ---- | ----------- | -------- | +| parallel_depth_limit | integer | | Yes | + #### SnippetDraftNodeRunPayload Payload for running a single node in snippet draft workflow. @@ -16872,6 +18207,12 @@ 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. @@ -16885,7 +18226,7 @@ Payload for running an iteration node in snippet draft workflow. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | author_name | string | | No | -| created_at | object | | No | +| created_at | long | | No | | created_by | string | | No | | description | string | | No | | icon_info | object | | No | @@ -16894,7 +18235,7 @@ Payload for running an iteration node in snippet draft workflow. | name | string | | No | | tags | [ [_AnonymousInlineModel_7b8b49ca164e](#_anonymousinlinemodel_7b8b49ca164e) ] | | No | | type | string | | No | -| updated_at | object | | No | +| updated_at | long | | No | | updated_by | string | | No | | use_count | integer | | No | | version | integer | | No | @@ -16908,8 +18249,8 @@ Query parameters for listing snippets. | creators | [ string ] | Filter by creator account IDs | No | | is_published | boolean | Filter by published status | No | | keyword | string | | No | -| limit | integer | | No | -| page | integer | | No | +| limit | integer,
**Default:** 20 | | No | +| page | integer,
**Default:** 1 | | No | | tag_ids | [ string ] | Filter by tag IDs | No | #### SnippetLoopNodeRunPayload @@ -16924,20 +18265,27 @@ Payload for running a loop node in snippet draft workflow. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| data | [ [_AnonymousInlineModel_7b67ac8a4db8](#_anonymousinlinemodel_7b67ac8a4db8) ] | | No | +| data | [ [_AnonymousInlineModel_efd591151ea9](#_anonymousinlinemodel_efd591151ea9) ] | | No | | has_more | boolean | | No | | limit | integer | | No | | page | integer | | No | | total | integer | | No | +#### SnippetUseCountResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| result | string | | Yes | +| use_count | integer | | Yes | + #### SnippetWorkflowListQuery Query parameters for listing snippet published workflows. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| limit | integer | | No | -| page | integer | | No | +| limit | integer,
**Default:** 10 | | No | +| page | integer,
**Default:** 1 | | No | #### SnippetWorkflowResponse @@ -16960,6 +18308,19 @@ Query parameters for listing snippet published workflows. | updated_by | [SimpleAccount](#simpleaccount) | | No | | version | string | | Yes | +#### StarredAppListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| creator_ids | [ string ] | Filter by creator account IDs | No | +| is_created_by_me | boolean | Filter by creator | No | +| limit | integer,
**Default:** 20 | Page size (1-100) | No | +| mode | string,
**Available values:** "advanced-chat", "agent", "agent-chat", "all", "channel", "chat", "completion", "workflow",
**Default:** all | App mode filter
*Enum:* `"advanced-chat"`, `"agent"`, `"agent-chat"`, `"all"`, `"channel"`, `"chat"`, `"completion"`, `"workflow"` | No | +| name | string | Filter by app name | No | +| page | integer,
**Default:** 1 | Page number (1-99999) | No | +| sort_by | string,
**Available values:** "earliest_created", "last_modified", "recently_created",
**Default:** last_modified | Sort apps by last modified, recently created, or earliest created
*Enum:* `"earliest_created"`, `"last_modified"`, `"recently_created"` | No | +| tag_ids | [ string ] | Filter by tag IDs | No | + #### StatisticTimeRangeQuery | Name | Type | Description | Required | @@ -17005,14 +18366,14 @@ Default configuration for form inputs. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | interval | string | | Yes | -| plan | string | | Yes | +| plan | string,
**Default:** sandbox | | Yes | #### SubscriptionQuery | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| interval | string | Billing interval
*Enum:* `"month"`, `"year"` | Yes | -| plan | string | Subscription plan
*Enum:* `"professional"`, `"team"` | Yes | +| interval | string,
**Available values:** "month", "year" | Billing interval
*Enum:* `"month"`, `"year"` | Yes | +| plan | string,
**Available values:** "professional", "team" | Subscription plan
*Enum:* `"professional"`, `"team"` | Yes | #### SuccessResponse @@ -17024,7 +18385,7 @@ Default configuration for form inputs. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| data | [ string ] | Suggested question | Yes | +| data | [ string ] | | Yes | #### SwitchWorkspacePayload @@ -17032,6 +18393,13 @@ Default configuration for form inputs. | ---- | ---- | ----------- | -------- | | tenant_id | string | | Yes | +#### SwitchWorkspaceResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| new_tenant | [TenantInfoResponse](#tenantinforesponse) | | Yes | +| result | string | | Yes | + #### SyncDraftWorkflowPayload | Name | Type | Description | Required | @@ -17050,16 +18418,26 @@ Default configuration for form inputs. | result | string | | No | | updated_at | string | | No | +#### SystemConfigurationResponse + +Model class for provider system configuration response. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| current_quota_type | [ProviderQuotaType](#providerquotatype) | | No | +| enabled | boolean | | Yes | +| quota_configurations | [ [QuotaConfiguration](#quotaconfiguration) ],
**Default:** | | No | + #### SystemFeatureModel | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | branding | [BrandingModel](#brandingmodel) | | Yes | -| enable_change_email | boolean | | Yes | -| enable_collaboration_mode | boolean | | Yes | +| enable_change_email | boolean,
**Default:** true | | Yes | +| enable_collaboration_mode | boolean,
**Default:** true | | Yes | | enable_creators_platform | boolean | | Yes | | enable_email_code_login | boolean | | Yes | -| enable_email_password_login | boolean | | Yes | +| enable_email_password_login | boolean,
**Default:** true | | Yes | | enable_explore_banner | boolean | | Yes | | enable_marketplace | boolean | | Yes | | enable_social_oauth_login | boolean | | Yes | @@ -17068,13 +18446,23 @@ Default configuration for form inputs. | is_allow_register | boolean | | Yes | | is_email_setup | boolean | | Yes | | license | [LicenseModel](#licensemodel) | | Yes | -| max_plugin_package_size | integer | | Yes | +| max_plugin_package_size | integer,
**Default:** 15728640 | | Yes | | plugin_installation_permission | [PluginInstallationPermissionModel](#plugininstallationpermissionmodel) | | Yes | | plugin_manager | [PluginManagerModel](#pluginmanagermodel) | | Yes | | sso_enforced_for_signin | boolean | | Yes | | sso_enforced_for_signin_protocol | string | | Yes | | webapp_auth | [WebAppAuthModel](#webappauthmodel) | | Yes | +#### SystemParameters + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| audio_file_size_limit | integer | | Yes | +| file_size_limit | integer | | Yes | +| image_file_size_limit | integer | | Yes | +| video_file_size_limit | integer | | Yes | +| workflow_file_upload_limit | integer | | Yes | + #### Tag | Name | Type | Description | Required | @@ -17111,7 +18499,7 @@ Default configuration for form inputs. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | keyword | string | Search keyword | No | -| type | string | Tag type filter
*Enum:* `""`, `"app"`, `"knowledge"`, `"snippet"` | No | +| type | string,
**Available values:** "", "app", "knowledge", "snippet" | Tag type filter
*Enum:* `""`, `"app"`, `"knowledge"`, `"snippet"` | No | #### TagResponse @@ -17159,12 +18547,35 @@ Tag type | trial_credits_used | integer | | No | | trial_end_reason | string | | No | +#### TenantListItemResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | No | +| current | boolean | | Yes | +| id | string | | Yes | +| name | string | | No | +| plan | string | | No | +| status | string | | No | + +#### TenantListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| workspaces | [ [TenantListItemResponse](#tenantlistitemresponse) ] | | Yes | + #### TextContentResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | content | string | | Yes | +#### TextFileResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| TextFileResponse | string | | | + #### TextToAudioPayload | Name | Type | Description | Required | @@ -17192,12 +18603,37 @@ Tag type | text | string | | No | | voice | string | | No | +#### TextToSpeechVoiceListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| TextToSpeechVoiceListResponse | array | | | + #### TextToSpeechVoiceQuery | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | language | string | Language code | Yes | +#### TokensPerSecondStatisticItem + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| date | string | | Yes | +| tps | number | | Yes | + +#### TokensPerSecondStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [TokensPerSecondStatisticItem](#tokenspersecondstatisticitem) ] | | Yes | + +#### ToolOAuthClientSchemaResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ToolOAuthClientSchemaResponse | array | | | + #### ToolOAuthCustomClientPayload | Name | Type | Description | Required | @@ -17205,12 +18641,53 @@ Tag type | client_params | object | | No | | enable_oauth_custom_client | boolean | | No | +#### ToolOAuthCustomClientResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ToolOAuthCustomClientResponse | object | | | + #### ToolParameterForm | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | ToolParameterForm | string | | | +#### ToolProviderListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| type | string | | No | + +#### ToolProviderOpaqueResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ToolProviderOpaqueResponse | | | | + +#### ToolProviderType + +Enum class for tool provider + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ToolProviderType | string | Enum class for tool provider | | + +#### TraceAppConfigResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_id | string | | No | +| created_at | string | | No | +| error | string | | No | +| has_not_configured | boolean | | No | +| id | string | | No | +| is_active | boolean | | No | +| result | string | | No | +| tracing_config | object | | No | +| tracing_provider | string | | No | +| updated_at | string | | No | + #### TraceConfigPayload | Name | Type | Description | Required | @@ -17230,7 +18707,7 @@ Tag type | ---- | ---- | ----------- | -------- | | access_mode | string | | No | | api_base_url | string | | No | -| created_at | object | | No | +| created_at | long | | No | | created_by | string | | No | | deleted_tools | [ [TrialDeletedTool](#trialdeletedtool) ] | | No | | description | string | | No | @@ -17239,7 +18716,7 @@ Tag type | icon | string | | No | | icon_background | string | | No | | icon_type | string | | No | -| icon_url | object | | No | +| icon_url | string | | No | | id | string | | No | | max_active_requests | integer | | No | | mode | string | | No | @@ -17247,7 +18724,7 @@ Tag type | name | string | | No | | site | [TrialSite](#trialsite) | | No | | tags | [ [TrialTag](#trialtag) ] | | No | -| updated_at | object | | No | +| updated_at | long | | No | | updated_by | string | | No | | use_icon_as_answer_icon | boolean | | No | | workflow | [TrialWorkflowPartial](#trialworkflowpartial) | | No | @@ -17260,11 +18737,11 @@ Tag type | annotation_reply | object | | No | | chat_prompt_config | object | | No | | completion_prompt_config | object | | No | -| created_at | object | | No | +| created_at | long | | No | | created_by | string | | No | | dataset_configs | object | | No | | dataset_query_variable | string | | No | -| external_data_tools | object | | No | +| external_data_tools | [ object ] | | No | | file_upload | object | | No | | model | object | | No | | more_like_this | object | | No | @@ -17274,12 +18751,12 @@ Tag type | retriever_resource | object | | No | | sensitive_word_avoidance | object | | No | | speech_to_text | object | | No | -| suggested_questions | object | | No | +| suggested_questions | [ string ] | | No | | suggested_questions_after_answer | object | | No | | text_to_speech | object | | No | -| updated_at | object | | No | +| updated_at | long | | No | | updated_by | string | | No | -| user_input_form | object | | No | +| user_input_form | [ object ] | | No | #### TrialConversationVariable @@ -17288,9 +18765,40 @@ Tag type | description | string | | No | | id | string | | No | | name | string | | No | -| value | object | | No | +| value | string
integer
number
boolean
object
[ object ] | | No | | value_type | string | | No | +#### TrialDataset + +| 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 | + +#### TrialDatasetList + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [TrialDataset](#trialdataset) ] | | No | +| has_more | boolean | | No | +| limit | integer | | No | +| page | integer | | No | +| total | integer | | No | + +#### TrialDatasetListQuery + +| 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 | + #### TrialDeletedTool | Name | Type | Description | Required | @@ -17313,7 +18821,7 @@ Tag type | allow_file_upload_methods | [ string ] | | No | | allowed_file_types | [ string ] | | No | | belong_to_node_id | string | | No | -| default_value | object | | No | +| default_value | string
integer
number
boolean
object
[ object ] | | No | | label | string | | No | | max_length | integer | | No | | options | [ string ] | | No | @@ -17342,7 +18850,7 @@ Tag type | chat_color_theme_inverted | boolean | | No | | code | string | | No | | copyright | string | | No | -| created_at | object | | No | +| created_at | long | | No | | created_by | string | | No | | custom_disclaimer | string | | No | | customize_domain | string | | No | @@ -17352,12 +18860,12 @@ Tag type | icon | string | | No | | icon_background | string | | No | | icon_type | string | | No | -| icon_url | object | | No | +| icon_url | string | | No | | privacy_policy | string | | No | | prompt_public | boolean | | No | | show_workflow_steps | boolean | | No | | title | string | | No | -| updated_at | object | | No | +| updated_at | long | | No | | updated_by | string | | No | | use_icon_as_answer_icon | boolean | | No | @@ -17374,7 +18882,7 @@ Tag type | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | conversation_variables | [ [TrialConversationVariable](#trialconversationvariable) ] | | No | -| created_at | object | | No | +| created_at | long | | No | | created_by | [TrialSimpleAccount](#trialsimpleaccount) | | No | | environment_variables | [ object ] | | No | | features | object | | No | @@ -17385,7 +18893,7 @@ Tag type | marked_name | string | | No | | rag_pipeline_variables | [ [TrialPipelineVariable](#trialpipelinevariable) ] | | No | | tool_published | boolean | | No | -| updated_at | object | | No | +| updated_at | long | | No | | updated_by | [TrialSimpleAccount](#trialsimpleaccount) | | No | | version | string | | No | @@ -17393,12 +18901,20 @@ Tag type | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| created_at | object | | No | +| created_at | long | | No | | created_by | string | | No | | id | string | | No | -| updated_at | object | | No | +| updated_at | long | | No | | updated_by | string | | No | +#### TriggerOAuthAuthorizeResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| authorization_url | string | | Yes | +| subscription_builder | | | Yes | +| subscription_builder_id | string | | Yes | + #### TriggerOAuthClientPayload | Name | Type | Description | Required | @@ -17406,11 +18922,29 @@ Tag type | client_params | object | | No | | enabled | boolean | | No | +#### TriggerOAuthClientResponse + +| 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 | + +#### TriggerProviderOpaqueResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| TriggerProviderOpaqueResponse | | | | + #### TriggerSubscriptionBuilderCreatePayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| credential_type | string | | No | +| credential_type | string,
**Default:** unauthorized | | No | #### TriggerSubscriptionBuilderUpdatePayload @@ -17433,6 +18967,15 @@ Tag type | ---- | ---- | ----------- | -------- | | Type | string | | | +#### UnaddedModelConfiguration + +Model class for provider unadded model configuration. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| model | string | | Yes | +| model_type | [ModelType](#modeltype) | | Yes | + #### UpdateAnnotationPayload | Name | Type | Description | Required | @@ -17485,6 +19028,12 @@ Payload for updating a snippet. | video_file_size_limit | integer | | Yes | | workflow_file_upload_limit | integer | | Yes | +#### UrlQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| url | string (uri) | | Yes | + #### UrlResponse | Name | Type | Description | Required | @@ -17514,6 +19063,19 @@ User action configuration. | id | string | | Yes | | title | string | | Yes | +#### UserSatisfactionRateStatisticItem + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| date | string | | Yes | +| rate | number | | Yes | + +#### UserSatisfactionRateStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [UserSatisfactionRateStatisticItem](#usersatisfactionratestatisticitem) ] | | Yes | + #### ValueSourceType ValueSourceType records whether the value comes from a static setting @@ -17551,7 +19113,7 @@ in form definiton, or a variable while the workflow is running. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| created_at | dateTime | | No | +| created_at | string | | No | | id | string | | Yes | | node_id | string | | Yes | | webhook_debug_url | string | | Yes | @@ -17563,21 +19125,27 @@ in form definiton, or a variable while the workflow is running. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | options | object | | Yes | -| provider | string | *Enum:* `"firecrawl"`, `"jinareader"`, `"watercrawl"` | Yes | +| provider | string,
**Available values:** "firecrawl", "jinareader", "watercrawl" | *Enum:* `"firecrawl"`, `"jinareader"`, `"watercrawl"` | Yes | | url | string | | Yes | +#### WebsiteCrawlResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| WebsiteCrawlResponse | object | | | + #### WebsiteCrawlStatusQuery | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| provider | string | *Enum:* `"firecrawl"`, `"jinareader"`, `"watercrawl"` | Yes | +| provider | string,
**Available values:** "firecrawl", "jinareader", "watercrawl" | *Enum:* `"firecrawl"`, `"jinareader"`, `"watercrawl"` | Yes | #### WebsiteInfo | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | job_id | string | | Yes | -| only_main_content | boolean | | No | +| only_main_content | boolean,
**Default:** true | | No | | provider | string | | Yes | | urls | [ string ] | | Yes | @@ -17593,7 +19161,7 @@ in form definiton, or a variable while the workflow is running. | ---- | ---- | ----------- | -------- | | keyword_setting | [WeightKeywordSetting](#weightkeywordsetting) | | No | | vector_setting | [WeightVectorSetting](#weightvectorsetting) | | No | -| weight_type | string | *Enum:* `"customized"`, `"keyword_first"`, `"semantic_first"` | No | +| weight_type | string | | No | #### WeightVectorSetting @@ -17630,6 +19198,13 @@ How a workflow node is bound to an Agent. | variant | string | | Yes | | workflow_id | string | | No | +#### WorkflowAgentSandboxUploadPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| node_execution_id | string | Optional workflow node execution ID. When omitted, the latest active session for the node is used. | No | +| path | string | File path relative to the sandbox workspace | Yes | + #### WorkflowAppLogPaginationResponse | Name | Type | Description | Required | @@ -17657,14 +19232,14 @@ How a workflow node is bound to an Agent. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| created_at__after | dateTime | Filter logs created after this timestamp | No | -| created_at__before | dateTime | Filter logs created before this timestamp | No | +| created_at__after | string | Filter logs created after this timestamp | No | +| created_at__before | string | Filter logs created before this timestamp | No | | created_by_account | string | Filter by account | No | | created_by_end_user_session_id | string | Filter by end user session ID | No | | detail | boolean | Whether to return detailed logs | No | | keyword | string | Search keyword for filtering logs | No | -| limit | integer | Number of items per page (1-100) | No | -| page | integer | Page number (1-99999) | No | +| limit | integer,
**Default:** 20 | Number of items per page (1-100) | No | +| page | integer,
**Default:** 1 | Page number (1-99999) | No | | status | [WorkflowExecutionStatus](#workflowexecutionstatus) | Execution status filter (succeeded, failed, stopped, partial-succeeded) | No | #### WorkflowArchivedLogPaginationResponse @@ -17688,6 +19263,19 @@ How a workflow node is bound to an Agent. | trigger_metadata | | | No | | workflow_run | [WorkflowRunForArchivedLogResponse](#workflowrunforarchivedlogresponse) | | No | +#### WorkflowAverageAppInteractionStatisticItem + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| date | string | | Yes | +| interactions | number | | Yes | + +#### WorkflowAverageAppInteractionStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [WorkflowAverageAppInteractionStatisticItem](#workflowaverageappinteractionstatisticitem) ] | | Yes | + #### WorkflowCommentAccount | Name | Type | Description | Required | @@ -17835,9 +19423,48 @@ How a workflow node is bound to an Agent. | description | string | | Yes | | id | string | | Yes | | name | string | | Yes | -| value | object | | Yes | +| value | | | Yes | | value_type | string | | Yes | +#### WorkflowDailyRunsStatisticItem + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| date | string | | Yes | +| runs | integer | | Yes | + +#### WorkflowDailyRunsStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [WorkflowDailyRunsStatisticItem](#workflowdailyrunsstatisticitem) ] | | Yes | + +#### WorkflowDailyTerminalsStatisticItem + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| date | string | | Yes | +| terminal_count | integer | | Yes | + +#### WorkflowDailyTerminalsStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [WorkflowDailyTerminalsStatisticItem](#workflowdailyterminalsstatisticitem) ] | | Yes | + +#### WorkflowDailyTokenCostStatisticItem + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| date | string | | Yes | +| token_count | integer | | Yes | + +#### WorkflowDailyTokenCostStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [WorkflowDailyTokenCostStatisticItem](#workflowdailytokencoststatisticitem) ] | | Yes | + #### WorkflowDraftEnvVariable | Name | Type | Description | Required | @@ -17869,7 +19496,7 @@ How a workflow node is bound to an Agent. | name | string | | No | | selector | [ string ] | | No | | type | string | | No | -| value | object | | No | +| value | string
integer
number
boolean
object
[ object ] | | No | | value_type | string | | No | | visible | boolean | | No | @@ -17883,15 +19510,15 @@ How a workflow node is bound to an Agent. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| limit | integer | Items per page | No | -| page | integer | Page number | No | +| limit | integer,
**Default:** 20 | Items per page | No | +| page | integer,
**Default:** 1 | Page number | No | #### WorkflowDraftVariableListWithoutValue | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | items | [ [WorkflowDraftVariableWithoutValue](#workflowdraftvariablewithoutvalue) ] | | No | -| total | object | | No | +| total | integer | | No | #### WorkflowDraftVariablePatchPayload @@ -17928,7 +19555,7 @@ How a workflow node is bound to an Agent. | description | string | | Yes | | id | string | | Yes | | name | string | | Yes | -| value | object | | Yes | +| value | | | Yes | | value_type | string | | Yes | #### WorkflowExecutionStatus @@ -17956,16 +19583,16 @@ can reuse its existing handler. | current_graph | object | Existing draft graph to refine (cmd+k `/refine`); omit for create-from-scratch | No | | ideal_output | string | Optional sample output for grounding | No | | instruction | string | Natural-language workflow description | Yes | -| mode | string | Target app mode for the generated graph
*Enum:* `"advanced-chat"`, `"workflow"` | Yes | +| mode | string,
**Available values:** "advanced-chat", "workflow" | Target app mode for the generated graph
*Enum:* `"advanced-chat"`, `"workflow"` | Yes | | model_config | [ModelConfig](#modelconfig) | Model configuration | Yes | #### WorkflowListQuery | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| limit | integer | | No | +| limit | integer,
**Default:** 10 | | No | | named_only | boolean | | No | -| page | integer | | No | +| page | integer,
**Default:** 1 | | No | | user_id | string | | No | #### WorkflowNodeJobConfig @@ -17977,7 +19604,7 @@ can reuse its existing handler. | metadata | [WorkflowNodeJobMetadata](#workflownodejobmetadata) | | No | | mode | [WorkflowNodeJobMode](#workflownodejobmode) | | No | | previous_node_output_refs | [ [WorkflowPreviousNodeOutputRef](#workflowpreviousnodeoutputref) ] | | No | -| schema_version | integer | | No | +| schema_version | integer,
**Default:** 1 | | No | | workflow_prompt | string | | No | #### WorkflowNodeJobMetadata @@ -18054,10 +19681,17 @@ can reuse its existing handler. | name | string | | No | | node_id | string | | No | | output | string | | No | -| selector | [ ] | | No | -| value_selector | [ ] | | No | +| selector | [ string
integer
number
boolean ] | | No | +| value_selector | [ string
integer
number
boolean ] | | No | | variable | string | | No | -| variable_selector | [ ] | | No | +| variable_selector | [ string
integer
number
boolean ] | | No | + +#### WorkflowPublishResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | Yes | +| result | string | | Yes | #### WorkflowResponse @@ -18079,13 +19713,21 @@ can reuse its existing handler. | updated_by | [SimpleAccount](#simpleaccount) | | No | | version | string | | Yes | +#### WorkflowRestoreResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| hash | string | | Yes | +| result | string | | Yes | +| updated_at | integer | | Yes | + #### WorkflowRunCountQuery | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| status | string | Workflow run status filter
*Enum:* `"failed"`, `"partial-succeeded"`, `"running"`, `"stopped"`, `"succeeded"` | No | +| status | string | Workflow run status filter | No | | time_range | string | Filter by time range (optional): e.g., 7d (7 days), 4h (4 hours), 30m (30 minutes), 30s (30 seconds). Filters by created_at field. | No | -| triggered_from | string | Filter by trigger source: debugging or app-run. Default: debugging
*Enum:* `"app-run"`, `"debugging"` | No | +| triggered_from | string | Filter by trigger source: debugging or app-run. Default: debugging | No | #### WorkflowRunCountResponse @@ -18174,9 +19816,9 @@ can reuse its existing handler. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | last_id | string | Last run ID for pagination | No | -| limit | integer | Number of items per page (1-100) | No | -| status | string | Workflow run status filter
*Enum:* `"failed"`, `"partial-succeeded"`, `"running"`, `"stopped"`, `"succeeded"` | No | -| triggered_from | string | Filter by trigger source: debugging or app-run. Default: debugging
*Enum:* `"app-run"`, `"debugging"` | No | +| limit | integer,
**Default:** 20 | Number of items per page (1-100) | No | +| status | string | Workflow run status filter | No | +| triggered_from | string | Filter by trigger source: debugging or app-run. Default: debugging | No | #### WorkflowRunNodeExecutionListResponse @@ -18233,13 +19875,13 @@ Query parameters for workflow runs. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | last_id | string | | No | -| limit | integer | | No | +| limit | integer,
**Default:** 20 | | No | #### WorkflowRunRequest | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| files | [ ] | | No | +| files | [ object ] | | No | | inputs | object | | Yes | #### WorkflowRunSnapshotView @@ -18276,6 +19918,19 @@ Query parameters for workflow runs. | ---- | ---- | ----------- | -------- | | workflow_tool_id | string | | Yes | +#### WorkflowToolGetQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| workflow_app_id | string | | No | +| workflow_tool_id | string | | No | + +#### WorkflowToolListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| workflow_tool_id | string | | Yes | + #### WorkflowToolParameterConfiguration Workflow tool configuration @@ -18309,7 +19964,7 @@ Workflow tool configuration | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| created_at | dateTime | | No | +| created_at | string | | No | | icon | string | | Yes | | id | string | | Yes | | node_id | string | | Yes | @@ -18317,7 +19972,7 @@ Workflow tool configuration | status | string | | Yes | | title | string | | Yes | | trigger_type | string | | Yes | -| updated_at | dateTime | | No | +| updated_at | string | | No | #### WorkflowUpdatePayload @@ -18340,35 +19995,50 @@ Workflow tool configuration | remove_webapp_brand | boolean | | No | | replace_webapp_logo | string | | No | -#### WorkspaceFileEntryResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| mtime | integer | | Yes | -| name | string | | Yes | -| size | integer | | Yes | -| type | string | *Enum:* `"dir"`, `"file"`, `"symlink"` | Yes | - #### WorkspaceInfoPayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | name | string | | Yes | +#### WorkspaceListItemResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | No | +| id | string | | Yes | +| name | string | | No | +| status | string | | No | + #### WorkspaceListQuery | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| limit | integer | | No | -| page | integer | | No | +| limit | integer,
**Default:** 20 | | No | +| page | integer,
**Default:** 1 | | No | #### WorkspaceListResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| entries | [ [WorkspaceFileEntryResponse](#workspacefileentryresponse) ] | | No | -| path | string | | Yes | -| truncated | boolean | | No | +| 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 + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| result | string | | Yes | +| tenant | [TenantInfoResponse](#tenantinforesponse) | | Yes | #### WorkspacePermissionResponse @@ -18378,35 +20048,6 @@ Workflow tool configuration | allow_owner_transfer | boolean | | Yes | | workspace_id | string | | Yes | -#### WorkspacePreviewResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| binary | boolean | | Yes | -| path | string | | Yes | -| size | integer | | Yes | -| text | string | | No | -| truncated | boolean | | Yes | - -#### _AnonymousInlineModel_7b67ac8a4db8 - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| author_name | string | | No | -| created_at | object | | 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 | object | | No | -| updated_by | string | | No | -| use_count | integer | | No | -| version | integer | | No | - #### _AnonymousInlineModel_7b8b49ca164e | Name | Type | Description | Required | @@ -18432,7 +20073,46 @@ Workflow tool configuration | model_provider_name | string | | No | | summary_prompt | string | | No | -## FastOpenAPI Preview (OpenAPI 3.0) +#### _AnonymousInlineModel_efd591151ea9 + +| 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 | + +#### core__tools__entities__common_entities__I18nObject + +Model class for i18n object. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| en_US | string | | Yes | +| ja_JP | string | | No | +| pt_BR | string | | No | +| zh_Hans | string | | No | + +#### graphon__model_runtime__entities__common_entities__I18nObject + +Model class for i18n object. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| en_US | string | | Yes | +| zh_Hans | string | | No | + +## FastOpenAPI Preview (OpenAPI 3.1) ### Dify API (FastOpenAPI PoC) FastOpenAPI proof of concept for Dify API @@ -18564,7 +20244,7 @@ FastOpenAPI proof of concept for Dify API | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | email | string | Admin email address | Yes | -| language | | Admin language | No | +| language | string | Admin language | No | | name | string | Admin name (max 30 characters) | Yes | | password | string | Admin password | Yes | @@ -18578,7 +20258,7 @@ FastOpenAPI proof of concept for Dify API | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| setup_at | | Setup completion time (ISO format) | No | +| setup_at | string | Setup completion time (ISO format) | No | | step | string,
**Available values:** "finished", "not_started" | Setup step status
*Enum:* `"finished"`, `"not_started"` | Yes | ###### VersionFeatures diff --git a/api/openapi/markdown/openapi-swagger.md b/api/openapi/markdown/openapi-openapi.md similarity index 59% rename from api/openapi/markdown/openapi-swagger.md rename to api/openapi/markdown/openapi-openapi.md index 7214bcaa94d..d203eaa4c97 100644 --- a/api/openapi/markdown/openapi-swagger.md +++ b/api/openapi/markdown/openapi-openapi.md @@ -3,489 +3,487 @@ User-scoped programmatic API (bearer auth) ## Version: 1.0 -### Security -**Bearer** - -| apiKey | *API Key* | -| ------ | --------- | -| Description | Type: Bearer {your-api-key} | -| In | header | -| Name | Authorization | +### Available authorizations +#### Bearer (API Key Authentication) +Type: Bearer {your-api-key} +**Name:** Authorization +**In:** header --- ## openapi User-scoped operations -### /_health - -#### GET -##### Responses +### [GET] /_health +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Health check | [HealthResponse](#healthresponse) | +| 200 | Health check | **application/json**: [HealthResponse](#healthresponse)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| -### /_version - -#### GET -##### Responses +### [GET] /_version +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Server version | [ServerVersionResponse](#serverversionresponse) | +| 200 | Server version | **application/json**: [ServerVersionResponse](#serverversionresponse)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| -### /account - -#### GET -##### Responses +### [GET] /account +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Account info | [AccountResponse](#accountresponse) | +| 200 | Account info | **application/json**: [AccountResponse](#accountresponse)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| -### /account/sessions - -#### GET -##### Parameters +### [GET] /account/sessions +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| limit | query | | No | integer | -| page | query | | No | integer | +| limit | query | | No | integer,
**Default:** 100 | +| page | query | | No | integer,
**Default:** 1 | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Session list | [SessionListResponse](#sessionlistresponse) | +| 200 | Session list | **application/json**: [SessionListResponse](#sessionlistresponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| -### /account/sessions/self - -#### DELETE -##### Responses +### [DELETE] /account/sessions/self +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Session revoked | [RevokeResponse](#revokeresponse) | +| 200 | Session revoked | **application/json**: [RevokeResponse](#revokeresponse)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| -### /account/sessions/{session_id} - -#### DELETE -##### Parameters +### [DELETE] /account/sessions/{session_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | session_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Session revoked | [RevokeResponse](#revokeresponse) | +| 200 | Session revoked | **application/json**: [RevokeResponse](#revokeresponse)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| -### /apps - -#### GET -##### Parameters +### [GET] /apps +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| limit | query | | No | integer | -| mode | query | | No | string | +| limit | query | | No | integer,
**Default:** 20 | +| mode | query | | No | string,
**Available values:** "advanced-chat", "agent", "agent-chat", "channel", "chat", "completion", "rag-pipeline", "workflow" | | name | query | | No | string | -| page | query | | No | integer | +| page | query | | No | integer,
**Default:** 1 | | tag | query | | No | string | | workspace_id | query | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | App list | [AppListResponse](#applistresponse) | +| 200 | App list | **application/json**: [AppListResponse](#applistresponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| -### /apps/{app_id}/check-dependencies - -#### GET -##### Parameters +### [GET] /apps/{app_id}/check-dependencies +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Dependencies checked | [CheckDependenciesResult](#checkdependenciesresult) | +| 200 | Dependencies checked | **application/json**: [CheckDependenciesResult](#checkdependenciesresult)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| -### /apps/{app_id}/describe - -#### GET -##### Parameters +### [GET] /apps/{app_id}/describe +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | | fields | query | | No | string | +| app_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | App description | [AppDescribeResponse](#appdescriberesponse) | +| 200 | App description | **application/json**: [AppDescribeResponse](#appdescriberesponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| -### /apps/{app_id}/export - -#### GET -##### Parameters +### [GET] /apps/{app_id}/export +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | | include_secret | query | Include encrypted secret values in the exported DSL | No | boolean | | workflow_id | query | Export a specific workflow version instead of the current draft | No | string | +| app_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Export successful | [AppDslExportResponse](#appdslexportresponse) | - -### /apps/{app_id}/files/upload - -#### POST -##### Description +| 200 | Export successful | **application/json**: [AppDslExportResponse](#appdslexportresponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| +### [POST] /apps/{app_id}/files/upload Upload a file to use as an input variable when running the app -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 201 | File uploaded successfully | [FileResponse](#fileresponse) | +| 201 | File uploaded successfully | **application/json**: [FileResponse](#fileresponse)
| | 400 | Bad request — no file or filename missing | | | 401 | Unauthorized — invalid or expired bearer token | | | 413 | File too large | | | 415 | Unsupported file type or blocked extension | | +| default | Error | **application/json**: [ErrorBody](#errorbody)
| -### /apps/{app_id}/form/human_input/{form_token} - -#### GET -##### Parameters +### [GET] /apps/{app_id}/form/human_input/{form_token} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | | Yes | string | | form_token | path | | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Form definition | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Form definition | **application/json**: [HumanInputFormDefinitionResponse](#humaninputformdefinitionresponse)
| -#### POST -##### Parameters +### [POST] /apps/{app_id}/form/human_input/{form_token} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | | Yes | string | | form_token | path | | Yes | string | -| payload | body | | Yes | [HumanInputFormSubmitPayload](#humaninputformsubmitpayload) | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [HumanInputFormSubmitPayload](#humaninputformsubmitpayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Form submitted | [FormSubmitResponse](#formsubmitresponse) | +| 200 | Form submitted | **application/json**: [FormSubmitResponse](#formsubmitresponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| -### /apps/{app_id}/run - -#### POST -##### Parameters +### [POST] /apps/{app_id}/run +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | | Yes | string | -| payload | body | | Yes | [AppRunRequest](#apprunrequest) | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Run result (SSE stream) | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AppRunRequest](#apprunrequest)
| -### /apps/{app_id}/tasks/{task_id}/events +#### Responses -#### GET -##### Parameters +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Run result (SSE stream) | **application/json**: [EventStreamResponse](#eventstreamresponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| + +### [GET] /apps/{app_id}/tasks/{task_id}/events +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| continue_on_pause | query | Whether to keep the event stream open on pause | No | boolean | +| include_state_snapshot | query | Whether to include workflow state snapshots | No | boolean | +| app_id | path | | Yes | string | +| task_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | SSE event stream | **application/json**: [EventStreamResponse](#eventstreamresponse)
| + +### [POST] /apps/{app_id}/tasks/{task_id}/stop +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | | Yes | string | | task_id | path | | Yes | string | -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | SSE event stream | - -### /apps/{app_id}/tasks/{task_id}/stop - -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | -| task_id | path | | Yes | string | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Task stopped | [TaskStopResponse](#taskstopresponse) | +| 200 | Task stopped | **application/json**: [TaskStopResponse](#taskstopresponse)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| -### /oauth/device/approve +### [POST] /oauth/device/approve +#### Request Body -#### POST -##### Parameters +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DeviceMutateRequest](#devicemutaterequest)
| -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [DeviceMutateRequest](#devicemutaterequest) | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Approved | [DeviceMutateResponse](#devicemutateresponse) | +| 200 | Approved | **application/json**: [DeviceMutateResponse](#devicemutateresponse)
| -### /oauth/device/code +### [POST] /oauth/device/code +#### Request Body -#### POST -##### Parameters +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DeviceCodeRequest](#devicecoderequest)
| -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [DeviceCodeRequest](#devicecoderequest) | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Device code created | [DeviceCodeResponse](#devicecoderesponse) | +| 200 | Device code created | **application/json**: [DeviceCodeResponse](#devicecoderesponse)
| -### /oauth/device/deny +### [POST] /oauth/device/deny +#### Request Body -#### POST -##### Parameters +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DeviceMutateRequest](#devicemutaterequest)
| -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [DeviceMutateRequest](#devicemutaterequest) | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Denied | [DeviceMutateResponse](#devicemutateresponse) | +| 200 | Denied | **application/json**: [DeviceMutateResponse](#devicemutateresponse)
| -### /oauth/device/lookup - -#### GET -##### Parameters +### [GET] /oauth/device/lookup +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | user_code | query | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Device lookup result | [DeviceLookupResponse](#devicelookupresponse) | +| 200 | Device lookup result | **application/json**: [DeviceLookupResponse](#devicelookupresponse)
| -### /oauth/device/token +### [POST] /oauth/device/token +#### Request Body -#### POST -##### Parameters +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DevicePollRequest](#devicepollrequest)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Device token | **application/json**: [DeviceTokenResponse](#devicetokenresponse)
| + +### [GET] /permitted-external-apps +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [DevicePollRequest](#devicepollrequest) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /permitted-external-apps - -#### GET -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| limit | query | | No | integer | -| mode | query | | No | string | +| limit | query | | No | integer,
**Default:** 20 | +| mode | query | | No | string,
**Available values:** "advanced-chat", "agent", "agent-chat", "channel", "chat", "completion", "rag-pipeline", "workflow" | | name | query | | No | string | -| page | query | | No | integer | +| page | query | | No | integer,
**Default:** 1 | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Permitted external apps list | [PermittedExternalAppsListResponse](#permittedexternalappslistresponse) | +| 200 | Permitted external apps list | **application/json**: [PermittedExternalAppsListResponse](#permittedexternalappslistresponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| -### /workspaces - -#### GET -##### Responses +### [GET] /workspaces +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workspace list | [WorkspaceListResponse](#workspacelistresponse) | +| 200 | Workspace list | **application/json**: [WorkspaceListResponse](#workspacelistresponse)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| -### /workspaces/{workspace_id} - -#### GET -##### Parameters +### [GET] /workspaces/{workspace_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | workspace_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workspace detail | [WorkspaceDetailResponse](#workspacedetailresponse) | +| 200 | Workspace detail | **application/json**: [WorkspaceDetailResponse](#workspacedetailresponse)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| -### /workspaces/{workspace_id}/apps/imports - -#### POST -##### Parameters +### [POST] /workspaces/{workspace_id}/apps/imports +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | workspace_id | path | | Yes | string | -| payload | body | | Yes | [AppDslImportPayload](#appdslimportpayload) | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AppDslImportPayload](#appdslimportpayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Import completed | [Import](#import) | -| 202 | Import pending confirmation | [Import](#import) | -| 400 | Import failed | [Import](#import) | +| 200 | Import completed | **application/json**: [Import](#import)
| +| 202 | Import pending confirmation | **application/json**: [Import](#import)
| +| 400 | Import failed | **application/json**: [Import](#import)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| -### /workspaces/{workspace_id}/apps/imports/{import_id}/confirm - -#### POST -##### Parameters +### [POST] /workspaces/{workspace_id}/apps/imports/{import_id}/confirm +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | import_id | path | | Yes | string | | workspace_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Import confirmed | [Import](#import) | -| 400 | Import failed | [Import](#import) | +| 200 | Import confirmed | **application/json**: [Import](#import)
| +| 400 | Import failed | **application/json**: [Import](#import)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| -### /workspaces/{workspace_id}/members +### [GET] /workspaces/{workspace_id}/members +#### Parameters -#### GET -##### Parameters +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| limit | query | | No | integer,
**Default:** 20 | +| page | query | | No | integer,
**Default:** 1 | +| workspace_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Member list | **application/json**: [MemberListResponse](#memberlistresponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + +### [POST] /workspaces/{workspace_id}/members +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | workspace_id | path | | Yes | string | -| limit | query | | No | integer | -| page | query | | No | integer | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [MemberInvitePayload](#memberinvitepayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Member list | [MemberListResponse](#memberlistresponse) | +| 201 | Member invited | **application/json**: [MemberInviteResponse](#memberinviteresponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| -#### POST -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| workspace_id | path | | Yes | string | -| payload | body | | Yes | [MemberInvitePayload](#memberinvitepayload) | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 201 | Member invited | [MemberInviteResponse](#memberinviteresponse) | - -### /workspaces/{workspace_id}/members/{member_id} - -#### DELETE -##### Parameters +### [DELETE] /workspaces/{workspace_id}/members/{member_id} +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | member_id | path | | Yes | string | | workspace_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Member removed | [MemberActionResponse](#memberactionresponse) | +| 200 | Member removed | **application/json**: [MemberActionResponse](#memberactionresponse)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| -### /workspaces/{workspace_id}/members/{member_id}/role - -#### PUT -##### Parameters +### [PUT] /workspaces/{workspace_id}/members/{member_id}/role +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | member_id | path | | Yes | string | | workspace_id | path | | Yes | string | -| payload | body | | Yes | [MemberRoleUpdatePayload](#memberroleupdatepayload) | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [MemberRoleUpdatePayload](#memberroleupdatepayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Role updated | [MemberActionResponse](#memberactionresponse) | +| 200 | Role updated | **application/json**: [MemberActionResponse](#memberactionresponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| -### /workspaces/{workspace_id}/switch - -#### POST -##### Parameters +### [POST] /workspaces/{workspace_id}/switch +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | workspace_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workspace detail | [WorkspaceDetailResponse](#workspacedetailresponse) | +| 200 | Workspace detail | **application/json**: [WorkspaceDetailResponse](#workspacedetailresponse)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| --- -### Models +### Schemas #### AccountPayload @@ -504,7 +502,7 @@ Upload a file to use as an input variable when running the app | subject_email | string | | No | | subject_issuer | string | | No | | subject_type | string | | Yes | -| workspaces | [ [WorkspacePayload](#workspacepayload) ] | | No | +| workspaces | [ [WorkspacePayload](#workspacepayload) ],
**Default:** | | No | #### AppDescribeInfo @@ -517,7 +515,7 @@ Upload a file to use as an input variable when running the app | mode | string | | Yes | | name | string | | Yes | | service_api_enabled | boolean | | Yes | -| tags | [ [TagItem](#tagitem) ] | | No | +| tags | [ [TagItem](#tagitem) ],
**Default:** | | No | | updated_at | string | | No | #### AppDescribeQuery @@ -566,7 +564,7 @@ Request body for POST /workspaces//apps/imports. | icon | string | | No | | icon_background | string | | No | | icon_type | string | | No | -| mode | string | Import mode: yaml-content or yaml-url
*Enum:* `"yaml-content"`, `"yaml-url"` | Yes | +| mode | string,
**Available values:** "yaml-content", "yaml-url" | Import mode: yaml-content or yaml-url
*Enum:* `"yaml-content"`, `"yaml-url"` | Yes | | name | string | Override the app name from the DSL | No | | yaml_content | string | Inline YAML DSL string (required when mode is yaml-content) | No | | yaml_url | string | Remote URL to fetch YAML from (required when mode is yaml-url) | No | @@ -580,7 +578,7 @@ Request body for POST /workspaces//apps/imports. | id | string | | Yes | | mode | string | | Yes | | name | string | | Yes | -| tags | [ [TagItem](#tagitem) ] | | No | +| tags | [ [TagItem](#tagitem) ],
**Default:** | | No | #### AppListQuery @@ -588,10 +586,10 @@ mode is a closed enum. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| limit | integer | | No | +| limit | integer,
**Default:** 20 | | No | | mode | [AppMode](#appmode) | | No | | name | string | | No | -| page | integer | | No | +| page | integer,
**Default:** 1 | | No | | tag | string | | No | | workspace_id | string | | Yes | @@ -614,7 +612,7 @@ mode is a closed enum. | id | string | | Yes | | mode | [AppMode](#appmode) | | Yes | | name | string | | Yes | -| tags | [ [TagItem](#tagitem) ] | | No | +| tags | [ [TagItem](#tagitem) ],
**Default:** | | No | | updated_at | string | | No | | workspace_id | string | | No | | workspace_name | string | | No | @@ -629,7 +627,7 @@ mode is a closed enum. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| auto_generate_name | boolean | | No | +| auto_generate_name | boolean,
**Default:** true | | No | | conversation_id | string | | No | | files | [ object ] | | No | | inputs | object | | Yes | @@ -693,6 +691,48 @@ mode is a closed enum. | client_id | string | | Yes | | device_code | string | | Yes | +#### DeviceTokenResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| account | [AccountPayload](#accountpayload) | | No | +| default_workspace_id | string | | No | +| expires_at | string | | Yes | +| subject_email | string | | No | +| subject_issuer | string | | No | +| subject_type | string,
**Available values:** "account", "external_sso" | *Enum:* `"account"`, `"external_sso"` | Yes | +| token | string | | Yes | +| token_id | string | | Yes | +| workspaces | [ [WorkspacePayload](#workspacepayload) ],
**Default:** | | No | + +#### ErrorBody + +Canonical non-2xx body. ``code`` is typed ``str`` (not the enum) so the +generated client schema stays an open enum — old CLIs keep parsing when a +future server adds a code. Formatter tests pin emitted values to the enum. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| code | string | | Yes | +| details | [ [ErrorDetail](#errordetail) ] | | No | +| hint | string | | No | +| message | string | | Yes | +| status | integer | | Yes | + +#### ErrorDetail + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| loc | [ ],
**Default:** | | No | +| msg | string | | Yes | +| type | string | | Yes | + +#### EventStreamResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| EventStreamResponse | string | | | + #### FileResponse | Name | Type | Description | Required | @@ -739,6 +779,16 @@ Liveness payload for `GET /openapi/v1/_health` — no auth required. | ---- | ---- | ----------- | -------- | | ok | boolean | | Yes | +#### HumanInputFormDefinitionResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| expiration_time | integer | | No | +| form_content | string | | Yes | +| inputs | [ object ] | | No | +| resolved_default_values | object | | Yes | +| user_actions | [ object ] | | No | + #### HumanInputFormSubmitPayload | Name | Type | Description | Required | @@ -752,7 +802,7 @@ Liveness payload for `GET /openapi/v1/_health` — no auth required. | ---- | ---- | ----------- | -------- | | app_id | string | | No | | app_mode | string | | No | -| current_dsl_version | string | | No | +| current_dsl_version | string,
**Default:** 0.6.0 | | No | | error | string | | No | | id | string | | Yes | | imported_dsl_version | string | | No | @@ -781,14 +831,14 @@ Liveness payload for `GET /openapi/v1/_health` — no auth required. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| result | string | | No | +| result | string,
**Default:** success | | No | #### MemberInvitePayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | email | string | | Yes | -| role | string | *Enum:* `"admin"`, `"normal"` | Yes | +| role | string,
**Available values:** "admin", "normal" | *Enum:* `"admin"`, `"normal"` | Yes | #### MemberInviteResponse @@ -797,7 +847,7 @@ Liveness payload for `GET /openapi/v1/_health` — no auth required. | email | string | | Yes | | invite_url | string | | Yes | | member_id | string | | Yes | -| result | string | | No | +| result | string,
**Default:** success | | No | | role | string | | Yes | | tenant_id | string | | Yes | @@ -807,8 +857,8 @@ Strict (extra='forbid'). | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| limit | integer | | No | -| page | integer | | No | +| limit | integer,
**Default:** 20 | | No | +| page | integer,
**Default:** 1 | | No | #### MemberListResponse @@ -835,15 +885,21 @@ Strict (extra='forbid'). | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| role | string | *Enum:* `"admin"`, `"normal"` | Yes | +| role | string,
**Available values:** "admin", "normal" | *Enum:* `"admin"`, `"normal"` | Yes | #### MessageMetadata | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| retriever_resources | [ object ] | | No | +| retriever_resources | [ object ],
**Default:** | | No | | usage | [UsageInfo](#usageinfo) | | No | +#### OpenApiErrorCode + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| OpenApiErrorCode | string | | | + #### Package | Name | Type | Description | Required | @@ -857,10 +913,10 @@ Strict (extra='forbid'). | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| limit | integer | | No | +| limit | integer,
**Default:** 20 | | No | | mode | [AppMode](#appmode) | | No | | name | string | | No | -| page | integer | | No | +| page | integer,
**Default:** 1 | | No | #### PermittedExternalAppsListResponse @@ -892,7 +948,7 @@ Meta endpoint payload for `GET /openapi/v1/_version` — no auth required. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| edition | string | *Enum:* `"CLOUD"`, `"SELF_HOSTED"` | Yes | +| edition | string,
**Available values:** "CLOUD", "SELF_HOSTED" | *Enum:* `"CLOUD"`, `"SELF_HOSTED"` | Yes | | version | string | | Yes | #### SessionListQuery @@ -901,8 +957,8 @@ Pagination for GET /account/sessions. Strict (extra='forbid'). | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| limit | integer | | No | -| page | integer | | No | +| limit | integer,
**Default:** 100 | | No | +| page | integer,
**Default:** 1 | | No | #### SessionListResponse diff --git a/api/openapi/markdown/service-swagger.md b/api/openapi/markdown/service-openapi.md similarity index 56% rename from api/openapi/markdown/service-swagger.md rename to api/openapi/markdown/service-openapi.md index f32e998bd1c..5a0a128b4f9 100644 --- a/api/openapi/markdown/service-swagger.md +++ b/api/openapi/markdown/service-openapi.md @@ -3,170 +3,127 @@ API for application services ## Version: 1.0 -### Security -**Bearer** - -| apiKey | *API Key* | -| ------ | --------- | -| Description | Type: Bearer {your-api-key} | -| In | header | -| Name | Authorization | +### Available authorizations +#### Bearer (API Key Authentication) +Type: Bearer {your-api-key} +**Name:** Authorization +**In:** header --- ## service_api Service operations -### / - -#### GET -##### Responses +### [GET] / +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | [IndexInfoResponse](#indexinforesponse) | +| 200 | Success | **application/json**: [IndexInfoResponse](#indexinforesponse)
| -### /app/feedbacks - -#### GET -##### Summary - -Get all feedbacks for the application - -##### Description +### [GET] /app/feedbacks +**Get all feedbacks for the application** Get all feedbacks for the application Returns paginated list of all feedback submitted for messages in this app. -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [FeedbackListQuery](#feedbacklistquery) | +| limit | query | Number of feedbacks per page | No | integer,
**Default:** 20 | +| page | query | Page number | No | integer,
**Default:** 1 | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Feedbacks retrieved successfully | -| 401 | Unauthorized - invalid API token | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Feedbacks retrieved successfully | **application/json**: [AppFeedbackListResponse](#appfeedbacklistresponse)
| +| 401 | Unauthorized - invalid API token | | -### /apps/annotation-reply/{action} +### [POST] /apps/annotation-reply/{action} +**Enable or disable annotation reply feature** -#### POST -##### Summary - -Enable or disable annotation reply feature - -##### Description - -Enable or disable annotation reply feature - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [AnnotationReplyActionPayload](#annotationreplyactionpayload) | | action | path | Action to perform: 'enable' or 'disable' | Yes | string | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Action completed successfully | -| 401 | Unauthorized - invalid API token | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AnnotationReplyActionPayload](#annotationreplyactionpayload)
| -### /apps/annotation-reply/{action}/status/{job_id} +#### Responses -#### GET -##### Summary +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Action completed successfully | **application/json**: [AnnotationJobStatusResponse](#annotationjobstatusresponse)
| +| 401 | Unauthorized - invalid API token | | -Get the status of an annotation reply action job +### [GET] /apps/annotation-reply/{action}/status/{job_id} +**Get the status of an annotation reply action job** -##### Description - -Get the status of an annotation reply action job - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | action | path | Action type | Yes | string | | job_id | path | Job ID | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Job status retrieved successfully | -| 401 | Unauthorized - invalid API token | -| 404 | Job not found | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Job status retrieved successfully | **application/json**: [AnnotationJobStatusResponse](#annotationjobstatusresponse)
| +| 401 | Unauthorized - invalid API token | | +| 404 | Job not found | | -### /apps/annotations +### [GET] /apps/annotations +**List annotations for the application** -#### GET -##### Summary - -List annotations for the application - -##### Description - -List annotations for the application - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | keyword | query | Keyword to search annotations | No | string | -| limit | query | Number of annotations per page | No | integer | -| page | query | Page number | No | integer | +| limit | query | Number of annotations per page | No | integer,
**Default:** 20 | +| page | query | Page number | No | integer,
**Default:** 1 | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Annotations retrieved successfully | [AnnotationList](#annotationlist) | +| 200 | Annotations retrieved successfully | **application/json**: [AnnotationList](#annotationlist)
| | 401 | Unauthorized - invalid API token | | -#### POST -##### Summary +### [POST] /apps/annotations +**Create a new annotation** -Create a new annotation +#### Request Body -##### Description +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AnnotationCreatePayload](#annotationcreatepayload)
| -Create a new annotation - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [AnnotationCreatePayload](#annotationcreatepayload) | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 201 | Annotation created successfully | [Annotation](#annotation) | +| 201 | Annotation created successfully | **application/json**: [Annotation](#annotation)
| | 401 | Unauthorized - invalid API token | | -### /apps/annotations/{annotation_id} +### [DELETE] /apps/annotations/{annotation_id} +**Delete an annotation** -#### DELETE -##### Summary - -Delete an annotation - -##### Description - -Delete an annotation - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | annotation_id | path | Annotation ID | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | @@ -175,207 +132,160 @@ Delete an annotation | 403 | Forbidden - insufficient permissions | | 404 | Annotation not found | -#### PUT -##### Summary +### [PUT] /apps/annotations/{annotation_id} +**Update an existing annotation** -Update an existing annotation - -##### Description - -Update an existing annotation - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [AnnotationCreatePayload](#annotationcreatepayload) | | annotation_id | path | Annotation ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AnnotationCreatePayload](#annotationcreatepayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Annotation updated successfully | [Annotation](#annotation) | +| 200 | Annotation updated successfully | **application/json**: [Annotation](#annotation)
| | 401 | Unauthorized - invalid API token | | | 403 | Forbidden - insufficient permissions | | | 404 | Annotation not found | | -### /audio-to-text - -#### POST -##### Summary - -Convert audio to text using speech-to-text - -##### Description +### [POST] /audio-to-text +**Convert audio to text using speech-to-text** Convert audio to text using speech-to-text Accepts an audio file upload and returns the transcribed text. -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Audio successfully transcribed | -| 400 | Bad request - no audio or invalid audio | -| 401 | Unauthorized - invalid API token | -| 413 | Audio file too large | -| 415 | Unsupported audio type | -| 500 | Internal server error | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Audio successfully transcribed | **application/json**: [AudioTranscriptResponse](#audiotranscriptresponse)
| +| 400 | Bad request - no audio or invalid audio | | +| 401 | Unauthorized - invalid API token | | +| 413 | Audio file too large | | +| 415 | Unsupported audio type | | +| 500 | Internal server error | | -### /chat-messages - -#### POST -##### Summary - -Send a message in a chat conversation - -##### Description +### [POST] /chat-messages +**Send a message in a chat conversation** Send a message in a chat conversation This endpoint handles chat messages for chat, agent chat, and advanced chat applications. Supports conversation management and both blocking and streaming response modes. -##### Parameters +#### Request Body -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ChatRequestPayload](#chatrequestpayload) | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ChatRequestPayload](#chatrequestpayload)
| -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Message sent successfully | -| 400 | Bad request - invalid parameters or workflow issues | -| 401 | Unauthorized - invalid API token | -| 404 | Conversation or workflow not found | -| 429 | Rate limit exceeded | -| 500 | Internal server error | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Message sent successfully | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| +| 400 | Bad request - invalid parameters or workflow issues | | +| 401 | Unauthorized - invalid API token | | +| 404 | Conversation or workflow not found | | +| 429 | Rate limit exceeded | | +| 500 | Internal server error | | -### /chat-messages/{task_id}/stop +### [POST] /chat-messages/{task_id}/stop +**Stop a running chat message generation** -#### POST -##### Summary - -Stop a running chat message generation - -##### Description - -Stop a running chat message generation - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | task_id | path | The ID of the task to stop | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Task stopped successfully | [SimpleResultResponse](#simpleresultresponse) | +| 200 | Task stopped successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| | 401 | Unauthorized - invalid API token | | | 404 | Task not found | | -### /completion-messages - -#### POST -##### Summary - -Create a completion for the given prompt - -##### Description +### [POST] /completion-messages +**Create a completion for the given prompt** Create a completion for the given prompt This endpoint generates a completion based on the provided inputs and query. Supports both blocking and streaming response modes. -##### Parameters +#### Request Body -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [CompletionRequestPayload](#completionrequestpayload) | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [CompletionRequestPayload](#completionrequestpayload)
| -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Completion created successfully | -| 400 | Bad request - invalid parameters | -| 401 | Unauthorized - invalid API token | -| 404 | Conversation not found | -| 500 | Internal server error | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Completion created successfully | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| +| 400 | Bad request - invalid parameters | | +| 401 | Unauthorized - invalid API token | | +| 404 | Conversation not found | | +| 500 | Internal server error | | -### /completion-messages/{task_id}/stop +### [POST] /completion-messages/{task_id}/stop +**Stop a running completion task** -#### POST -##### Summary - -Stop a running completion task - -##### Description - -Stop a running completion task - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | task_id | path | The ID of the task to stop | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Task stopped successfully | [SimpleResultResponse](#simpleresultresponse) | +| 200 | Task stopped successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| | 401 | Unauthorized - invalid API token | | | 404 | Task not found | | -### /conversations - -#### GET -##### Summary - -List all conversations for the current user - -##### Description +### [GET] /conversations +**List all conversations for the current user** List all conversations for the current user Supports pagination using last_id and limit parameters. -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ConversationListQuery](#conversationlistquery) | +| last_id | query | Last conversation ID for pagination | No | string | +| limit | query | Number of conversations to return | No | integer,
**Default:** 20 | +| sort_by | query | Sort order for conversations | No | string,
**Available values:** "-created_at", "-updated_at", "created_at", "updated_at",
**Default:** -updated_at | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Conversations retrieved successfully | -| 401 | Unauthorized - invalid API token | -| 404 | Last conversation not found | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Conversations retrieved successfully | **application/json**: [ConversationInfiniteScrollPagination](#conversationinfinitescrollpagination)
| +| 401 | Unauthorized - invalid API token | | +| 404 | Last conversation not found | | -### /conversations/{c_id} +### [DELETE] /conversations/{c_id} +**Delete a specific conversation** -#### DELETE -##### Summary - -Delete a specific conversation - -##### Description - -Delete a specific conversation - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | c_id | path | Conversation ID | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | @@ -383,180 +293,148 @@ Delete a specific conversation | 401 | Unauthorized - invalid API token | | 404 | Conversation not found | -### /conversations/{c_id}/name +### [POST] /conversations/{c_id}/name +**Rename a conversation or auto-generate a name** -#### POST -##### Summary - -Rename a conversation or auto-generate a name - -##### Description - -Rename a conversation or auto-generate a name - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ConversationRenamePayload](#conversationrenamepayload) | | c_id | path | Conversation ID | Yes | string | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Conversation renamed successfully | -| 401 | Unauthorized - invalid API token | -| 404 | Conversation not found | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ConversationRenamePayload](#conversationrenamepayload)
| -### /conversations/{c_id}/variables +#### Responses -#### GET -##### Summary +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Conversation renamed successfully | **application/json**: [SimpleConversation](#simpleconversation)
| +| 401 | Unauthorized - invalid API token | | +| 404 | Conversation not found | | -List all variables for a conversation - -##### Description +### [GET] /conversations/{c_id}/variables +**List all variables for a conversation** List all variables for a conversation Conversational variables are only available for chat applications. -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ConversationVariablesQuery](#conversationvariablesquery) | | c_id | path | Conversation ID | Yes | string | +| last_id | query | Last variable ID for pagination | No | string | +| limit | query | Number of variables to return | No | integer,
**Default:** 20 | +| variable_name | query | Filter variables by name | No | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Variables retrieved successfully | [ConversationVariableInfiniteScrollPaginationResponse](#conversationvariableinfinitescrollpaginationresponse) | +| 200 | Variables retrieved successfully | **application/json**: [ConversationVariableInfiniteScrollPaginationResponse](#conversationvariableinfinitescrollpaginationresponse)
| | 401 | Unauthorized - invalid API token | | | 404 | Conversation not found | | -### /conversations/{c_id}/variables/{variable_id} - -#### PUT -##### Summary - -Update a conversation variable's value - -##### Description +### [PUT] /conversations/{c_id}/variables/{variable_id} +**Update a conversation variable's value** Update a conversation variable's value Allows updating the value of a specific conversation variable. The value must match the variable's expected type. -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ConversationVariableUpdatePayload](#conversationvariableupdatepayload) | | c_id | path | Conversation ID | Yes | string | | variable_id | path | Variable ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ConversationVariableUpdatePayload](#conversationvariableupdatepayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Variable updated successfully | [ConversationVariableResponse](#conversationvariableresponse) | +| 200 | Variable updated successfully | **application/json**: [ConversationVariableResponse](#conversationvariableresponse)
| | 400 | Bad request - type mismatch | | | 401 | Unauthorized - invalid API token | | | 404 | Conversation or variable not found | | -### /datasets - -#### GET -##### Summary - -Resource for getting datasets - -##### Description +### [GET] /datasets +**Resource for getting datasets** List all datasets -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | include_all | query | Include all datasets | No | boolean | | keyword | query | Search keyword | No | string | -| limit | query | Number of items per page | No | integer | -| page | query | Page number | No | integer | +| limit | query | Number of items per page | No | integer,
**Default:** 20 | +| page | query | Page number | No | integer,
**Default:** 1 | | tag_ids | query | Filter by tag IDs | No | [ string ] | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Datasets retrieved successfully | [DatasetListResponse](#datasetlistresponse) | +| 200 | Datasets retrieved successfully | **application/json**: [DatasetListResponse](#datasetlistresponse)
| | 401 | Unauthorized - invalid API token | | -#### POST -##### Summary - -Resource for creating datasets - -##### Description +### [POST] /datasets +**Resource for creating datasets** Create a new dataset -##### Parameters +#### Request Body -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [DatasetCreatePayload](#datasetcreatepayload) | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DatasetCreatePayload](#datasetcreatepayload)
| -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Dataset created successfully | [DatasetDetailResponse](#datasetdetailresponse) | +| 200 | Dataset created successfully | **application/json**: [DatasetDetailResponse](#datasetdetailresponse)
| | 400 | Bad request - invalid parameters | | | 401 | Unauthorized - invalid API token | | -### /datasets/pipeline/file-upload - -#### POST -##### Summary - -Upload a file for use in conversations - -##### Description +### [POST] /datasets/pipeline/file-upload +**Upload a file for use in conversations** Upload a file to a knowledgebase pipeline Accepts a single file upload via multipart/form-data. -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 201 | File uploaded successfully | -| 400 | Bad request - no file or invalid file | -| 401 | Unauthorized - invalid API token | -| 413 | File too large | -| 415 | Unsupported file type | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | File uploaded successfully | **application/json**: [PipelineUploadFileResponse](#pipelineuploadfileresponse)
| +| 400 | Bad request - no file or invalid file | | +| 401 | Unauthorized - invalid API token | | +| 413 | File too large | | +| 415 | Unsupported file type | | -### /datasets/tags +### [DELETE] /datasets/tags +**Delete a knowledge type tag** -#### DELETE -##### Summary +#### Request Body -Delete a knowledge type tag +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [TagDeletePayload](#tagdeletepayload)
| -##### Description - -Delete a knowledge type tag - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [TagDeletePayload](#tagdeletepayload) | - -##### Responses +#### Responses | Code | Description | | ---- | ----------- | @@ -564,78 +442,60 @@ Delete a knowledge type tag | 401 | Unauthorized - invalid API token | | 403 | Forbidden - insufficient permissions | -#### GET -##### Summary +### [GET] /datasets/tags +**Get all knowledge type tags** -Get all knowledge type tags - -##### Description - -Get all knowledge type tags - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Tags retrieved successfully | [KnowledgeTagListResponse](#knowledgetaglistresponse) | +| 200 | Tags retrieved successfully | **application/json**: [KnowledgeTagListResponse](#knowledgetaglistresponse)
| | 401 | Unauthorized - invalid API token | | -#### PATCH -##### Description - +### [PATCH] /datasets/tags Update a knowledge type tag -##### Parameters +#### Request Body -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [TagUpdatePayload](#tagupdatepayload) | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [TagUpdatePayload](#tagupdatepayload)
| -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Tag updated successfully | [KnowledgeTagResponse](#knowledgetagresponse) | +| 200 | Tag updated successfully | **application/json**: [KnowledgeTagResponse](#knowledgetagresponse)
| | 401 | Unauthorized - invalid API token | | | 403 | Forbidden - insufficient permissions | | -#### POST -##### Summary +### [POST] /datasets/tags +**Add a knowledge type tag** -Add a knowledge type tag +#### Request Body -##### Description +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [TagCreatePayload](#tagcreatepayload)
| -Add a knowledge type tag - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [TagCreatePayload](#tagcreatepayload) | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Tag created successfully | [KnowledgeTagResponse](#knowledgetagresponse) | +| 200 | Tag created successfully | **application/json**: [KnowledgeTagResponse](#knowledgetagresponse)
| | 401 | Unauthorized - invalid API token | | | 403 | Forbidden - insufficient permissions | | -### /datasets/tags/binding - -#### POST -##### Description - +### [POST] /datasets/tags/binding Bind tags to a dataset -##### Parameters +#### Request Body -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [TagBindingPayload](#tagbindingpayload) | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [TagBindingPayload](#tagbindingpayload)
| -##### Responses +#### Responses | Code | Description | | ---- | ----------- | @@ -643,20 +503,16 @@ Bind tags to a dataset | 401 | Unauthorized - invalid API token | | 403 | Forbidden - insufficient permissions | -### /datasets/tags/unbinding - -#### POST -##### Description - +### [POST] /datasets/tags/unbinding Unbind tags from a dataset -##### Parameters +#### Request Body -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [TagUnbindingPayload](#tagunbindingpayload) | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [TagUnbindingPayload](#tagunbindingpayload)
| -##### Responses +#### Responses | Code | Description | | ---- | ----------- | @@ -664,14 +520,8 @@ Unbind tags from a dataset | 401 | Unauthorized - invalid API token | | 403 | Forbidden - insufficient permissions | -### /datasets/{dataset_id} - -#### DELETE -##### Summary - -Deletes a dataset given its ID - -##### Description +### [DELETE] /datasets/{dataset_id} +**Deletes a dataset given its ID** Delete a dataset Args: @@ -686,13 +536,13 @@ Returns: Raises: NotFound: If the dataset with the given ID does not exist. -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | Dataset ID | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | @@ -701,220 +551,213 @@ Raises: | 404 | Dataset not found | | 409 | Conflict - dataset is in use | -#### GET -##### Description - +### [GET] /datasets/{dataset_id} Get a specific dataset by ID -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | Dataset ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Dataset retrieved successfully | [DatasetDetailWithPartialMembersResponse](#datasetdetailwithpartialmembersresponse) | +| 200 | Dataset retrieved successfully | **application/json**: [DatasetDetailWithPartialMembersResponse](#datasetdetailwithpartialmembersresponse)
| | 401 | Unauthorized - invalid API token | | | 403 | Forbidden - insufficient permissions | | | 404 | Dataset not found | | -#### PATCH -##### Description - +### [PATCH] /datasets/{dataset_id} Update an existing dataset -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [DatasetUpdatePayload](#datasetupdatepayload) | | dataset_id | path | Dataset ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DatasetUpdatePayload](#datasetupdatepayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Dataset updated successfully | [DatasetDetailWithPartialMembersResponse](#datasetdetailwithpartialmembersresponse) | +| 200 | Dataset updated successfully | **application/json**: [DatasetDetailWithPartialMembersResponse](#datasetdetailwithpartialmembersresponse)
| | 401 | Unauthorized - invalid API token | | | 403 | Forbidden - insufficient permissions | | | 404 | Dataset not found | | -### /datasets/{dataset_id}/document/create-by-file - -#### POST -##### Description - +### [POST] /datasets/{dataset_id}/document/create-by-file Create a new document by uploading a file -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| data | formData | Optional JSON string with document creation settings. | No | string | -| file | formData | Document file to upload. | Yes | file | | dataset_id | path | Dataset ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **multipart/form-data**: { **"data"**: string, **"file"**: binary }
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Document created successfully | [DocumentAndBatchResponse](#documentandbatchresponse) | +| 200 | Document created successfully | **application/json**: [DocumentAndBatchResponse](#documentandbatchresponse)
| | 400 | Bad request - invalid file or parameters | | | 401 | Unauthorized - invalid API token | | -### /datasets/{dataset_id}/document/create-by-text - -#### POST -##### Description - +### [POST] /datasets/{dataset_id}/document/create-by-text Create a new document by providing text content -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [DocumentTextCreatePayload](#documenttextcreatepayload) | | dataset_id | path | Dataset ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DocumentTextCreatePayload](#documenttextcreatepayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Document created successfully | [DocumentAndBatchResponse](#documentandbatchresponse) | +| 200 | Document created successfully | **application/json**: [DocumentAndBatchResponse](#documentandbatchresponse)
| | 400 | Bad request - invalid parameters | | | 401 | Unauthorized - invalid API token | | -### /datasets/{dataset_id}/document/create_by_file - -#### POST -##### Description - +### [POST] /datasets/{dataset_id}/document/create_by_file Create a new document by uploading a file -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| data | formData | Optional JSON string with document creation settings. | No | string | -| file | formData | Document file to upload. | Yes | file | | dataset_id | path | Dataset ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **multipart/form-data**: { **"data"**: string, **"file"**: binary }
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Document created successfully | [DocumentAndBatchResponse](#documentandbatchresponse) | +| 200 | Document created successfully | **application/json**: [DocumentAndBatchResponse](#documentandbatchresponse)
| | 400 | Bad request - invalid file or parameters | | | 401 | Unauthorized - invalid API token | | -### /datasets/{dataset_id}/document/create_by_text +### ~~[POST] /datasets/{dataset_id}/document/create_by_text~~ -#### POST ***DEPRECATED*** -##### Description Deprecated legacy alias for creating a new document by providing text content. Use /datasets/{dataset_id}/document/create-by-text instead. -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [DocumentTextCreatePayload](#documenttextcreatepayload) | | dataset_id | path | Dataset ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DocumentTextCreatePayload](#documenttextcreatepayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Document created successfully | [DocumentAndBatchResponse](#documentandbatchresponse) | +| 200 | Document created successfully | **application/json**: [DocumentAndBatchResponse](#documentandbatchresponse)
| | 400 | Bad request - invalid parameters | | | 401 | Unauthorized - invalid API token | | -### /datasets/{dataset_id}/documents - -#### GET -##### Description - +### [GET] /datasets/{dataset_id}/documents List all documents in a dataset -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | Dataset ID | Yes | string | | keyword | query | Search keyword | No | string | -| limit | query | Number of items per page | No | integer | -| page | query | Page number | No | integer | +| limit | query | Number of items per page | No | integer,
**Default:** 20 | +| page | query | Page number | No | integer,
**Default:** 1 | | status | query | Document status filter | No | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Documents retrieved successfully | [DocumentListResponse](#documentlistresponse) | +| 200 | Documents retrieved successfully | **application/json**: [DocumentListResponse](#documentlistresponse)
| | 401 | Unauthorized - invalid API token | | | 404 | Dataset not found | | -### /datasets/{dataset_id}/documents/download-zip - -#### POST -##### Description - +### [POST] /datasets/{dataset_id}/documents/download-zip Download selected uploaded documents as a single ZIP archive -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [DocumentBatchDownloadZipPayload](#documentbatchdownloadzippayload) | | dataset_id | path | Dataset ID | Yes | string | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | ZIP archive generated successfully | -| 401 | Unauthorized - invalid API token | -| 403 | Forbidden - insufficient permissions | -| 404 | Document or dataset not found | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DocumentBatchDownloadZipPayload](#documentbatchdownloadzippayload)
| -### /datasets/{dataset_id}/documents/metadata - -#### POST -##### Summary - -Update metadata for multiple documents - -##### Description - -Update metadata for multiple documents - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [MetadataOperationData](#metadataoperationdata) | -| dataset_id | path | Dataset ID | Yes | string | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Documents metadata updated successfully | [DatasetMetadataActionResponse](#datasetmetadataactionresponse) | +| 200 | ZIP archive generated successfully | **application/json**: [BinaryFileResponse](#binaryfileresponse)
| +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - insufficient permissions | | +| 404 | Document or dataset not found | | + +### [POST] /datasets/{dataset_id}/documents/metadata +**Update metadata for multiple documents** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Dataset ID | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [MetadataOperationData](#metadataoperationdata)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Documents metadata updated successfully | **application/json**: [DatasetMetadataActionResponse](#datasetmetadataactionresponse)
| | 401 | Unauthorized - invalid API token | | | 404 | Dataset not found | | -### /datasets/{dataset_id}/documents/status/{action} - -#### PATCH -##### Summary - -Batch update document status - -##### Description +### [PATCH] /datasets/{dataset_id}/documents/status/{action} +**Batch update document status** Batch update document status Args: @@ -931,64 +774,60 @@ Raises: Forbidden: If the user does not have permission. InvalidActionError: If the action is invalid or cannot be performed. -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | action | path | Action to perform: 'enable', 'disable', 'archive', or 'un_archive' | Yes | string | | dataset_id | path | Dataset ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DocumentStatusPayload](#documentstatuspayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Document status updated successfully | [SimpleResultResponse](#simpleresultresponse) | +| 200 | Document status updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| | 400 | Bad request - invalid action | | | 401 | Unauthorized - invalid API token | | | 403 | Forbidden - insufficient permissions | | | 404 | Dataset not found | | -### /datasets/{dataset_id}/documents/{batch}/indexing-status - -#### GET -##### Description - +### [GET] /datasets/{dataset_id}/documents/{batch}/indexing-status Get indexing status for documents in a batch -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | batch | path | Batch ID | Yes | string | | dataset_id | path | Dataset ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Indexing status retrieved successfully | [DocumentStatusListResponse](#documentstatuslistresponse) | +| 200 | Indexing status retrieved successfully | **application/json**: [DocumentStatusListResponse](#documentstatuslistresponse)
| | 401 | Unauthorized - invalid API token | | | 404 | Dataset or documents not found | | -### /datasets/{dataset_id}/documents/{document_id} - -#### DELETE -##### Summary - -Delete document - -##### Description +### [DELETE] /datasets/{dataset_id}/documents/{document_id} +**Delete document** Delete a document -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | Dataset ID | Yes | string | | document_id | path | Document ID | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | @@ -997,128 +836,120 @@ Delete a document | 403 | Forbidden - document is archived | | 404 | Document not found | -#### GET -##### Description - +### [GET] /datasets/{dataset_id}/documents/{document_id} Get a specific document by ID -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | Dataset ID | Yes | string | | document_id | path | Document ID | Yes | string | +| metadata | query | Metadata response mode | No | string,
**Available values:** "all", "only", "without",
**Default:** all | -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Document retrieved successfully | -| 401 | Unauthorized - invalid API token | -| 403 | Forbidden - insufficient permissions | -| 404 | Document not found | - -#### PATCH -##### Description - -Update an existing document by uploading a file - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| data | formData | Optional JSON string with document update settings. | No | string | -| file | formData | Replacement document file. | No | file | -| dataset_id | path | Dataset ID | Yes | string | -| document_id | path | Document ID | Yes | string | - -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Document updated successfully | [DocumentAndBatchResponse](#documentandbatchresponse) | +| 200 | Document retrieved successfully | **application/json**: [DocumentDetailResponse](#documentdetailresponse)
| +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - insufficient permissions | | +| 404 | 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 | Dataset ID | Yes | string | +| document_id | path | Document ID | Yes | string | + +#### 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 | | | 404 | Document not found | | -### /datasets/{dataset_id}/documents/{document_id}/download - -#### GET -##### Description - +### [GET] /datasets/{dataset_id}/documents/{document_id}/download Get a signed download URL for a document's original uploaded file -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | Dataset ID | Yes | string | | document_id | path | Document ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Download URL generated successfully | [UrlResponse](#urlresponse) | +| 200 | Download URL generated successfully | **application/json**: [UrlResponse](#urlresponse)
| | 401 | Unauthorized - invalid API token | | | 403 | Forbidden - insufficient permissions | | | 404 | Document or upload file not found | | -### /datasets/{dataset_id}/documents/{document_id}/segments - -#### GET -##### Description - +### [GET] /datasets/{dataset_id}/documents/{document_id}/segments List segments in a document -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | Dataset ID | Yes | string | | document_id | path | Document ID | Yes | string | | keyword | query | | No | string | -| limit | query | | No | integer | -| page | query | | No | integer | +| limit | query | | No | integer,
**Default:** 20 | +| page | query | | No | integer,
**Default:** 1 | | status | query | | No | [ string ] | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Segments retrieved successfully | [SegmentListResponse](#segmentlistresponse) | +| 200 | Segments retrieved successfully | **application/json**: [SegmentListResponse](#segmentlistresponse)
| | 401 | Unauthorized - invalid API token | | | 404 | Dataset or document not found | | -#### POST -##### Description - +### [POST] /datasets/{dataset_id}/documents/{document_id}/segments Create segments in a document -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [SegmentCreatePayload](#segmentcreatepayload) | | dataset_id | path | Dataset ID | Yes | string | | document_id | path | Document ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [SegmentCreatePayload](#segmentcreatepayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Segments created successfully | [SegmentCreateListResponse](#segmentcreatelistresponse) | +| 200 | Segments created successfully | **application/json**: [SegmentCreateListResponse](#segmentcreatelistresponse)
| | 400 | Bad request - segments data is missing | | | 401 | Unauthorized - invalid API token | | | 404 | Dataset or document not found | | -### /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id} - -#### DELETE -##### Description - +### [DELETE] /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id} Delete a specific segment -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | @@ -1126,7 +957,7 @@ Delete a specific segment | document_id | path | Document ID | Yes | string | | segment_id | path | Segment ID | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | @@ -1134,12 +965,10 @@ Delete a specific segment | 401 | Unauthorized - invalid API token | | 404 | Dataset, document, or segment not found | -#### GET -##### Description - +### [GET] /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id} Get a specific segment by ID -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | @@ -1147,44 +976,43 @@ Get a specific segment by ID | document_id | path | Document ID | Yes | string | | segment_id | path | Segment ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Segment retrieved successfully | [SegmentDetailResponse](#segmentdetailresponse) | +| 200 | Segment retrieved successfully | **application/json**: [SegmentDetailResponse](#segmentdetailresponse)
| | 401 | Unauthorized - invalid API token | | | 404 | Dataset, document, or segment not found | | -#### POST -##### Description - +### [POST] /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id} Update a specific segment -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [SegmentUpdatePayload](#segmentupdatepayload) | | dataset_id | path | Dataset ID | Yes | string | | document_id | path | Document ID | Yes | string | | segment_id | path | Segment ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [SegmentUpdatePayload](#segmentupdatepayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Segment updated successfully | [SegmentDetailResponse](#segmentdetailresponse) | +| 200 | Segment updated successfully | **application/json**: [SegmentDetailResponse](#segmentdetailresponse)
| | 401 | Unauthorized - invalid API token | | | 404 | Dataset, document, or segment not found | | -### /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks - -#### GET -##### Description - +### [GET] /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks List child chunks for a segment -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | @@ -1192,47 +1020,46 @@ List child chunks for a segment | document_id | path | Document ID | Yes | string | | segment_id | path | Parent segment ID | Yes | string | | keyword | query | | No | string | -| limit | query | | No | integer | -| page | query | | No | integer | +| limit | query | | No | integer,
**Default:** 20 | +| page | query | | No | integer,
**Default:** 1 | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Child chunks retrieved successfully | [ChildChunkListResponse](#childchunklistresponse) | +| 200 | Child chunks retrieved successfully | **application/json**: [ChildChunkListResponse](#childchunklistresponse)
| | 401 | Unauthorized - invalid API token | | | 404 | Dataset, document, or segment not found | | -#### POST -##### Description - +### [POST] /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks Create a new child chunk for a segment -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ChildChunkCreatePayload](#childchunkcreatepayload) | | dataset_id | path | Dataset ID | Yes | string | | document_id | path | Document ID | Yes | string | | segment_id | path | Parent segment ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ChildChunkCreatePayload](#childchunkcreatepayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Child chunk created successfully | [ChildChunkDetailResponse](#childchunkdetailresponse) | +| 200 | Child chunk created successfully | **application/json**: [ChildChunkDetailResponse](#childchunkdetailresponse)
| | 401 | Unauthorized - invalid API token | | | 404 | Dataset, document, or segment not found | | -### /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks/{child_chunk_id} - -#### DELETE -##### Description - +### [DELETE] /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks/{child_chunk_id} Delete a specific child chunk -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | @@ -1241,7 +1068,7 @@ Delete a specific child chunk | document_id | path | Document ID | Yes | string | | segment_id | path | Parent segment ID | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | @@ -1249,271 +1076,248 @@ Delete a specific child chunk | 401 | Unauthorized - invalid API token | | 404 | Dataset, document, segment, or child chunk not found | -#### PATCH -##### Description - +### [PATCH] /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks/{child_chunk_id} Update a specific child chunk -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ChildChunkUpdatePayload](#childchunkupdatepayload) | | child_chunk_id | path | Child chunk ID | Yes | string | | dataset_id | path | Dataset ID | Yes | string | | document_id | path | Document ID | Yes | string | | segment_id | path | Parent segment ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ChildChunkUpdatePayload](#childchunkupdatepayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Child chunk updated successfully | [ChildChunkDetailResponse](#childchunkdetailresponse) | +| 200 | Child chunk updated successfully | **application/json**: [ChildChunkDetailResponse](#childchunkdetailresponse)
| | 401 | Unauthorized - invalid API token | | | 404 | Dataset, document, segment, or child chunk not found | | -### /datasets/{dataset_id}/documents/{document_id}/update-by-file +### ~~[POST] /datasets/{dataset_id}/documents/{document_id}/update-by-file~~ -#### POST ***DEPRECATED*** -##### Description Deprecated legacy alias for updating an existing document by uploading a file. Use PATCH /datasets/{dataset_id}/documents/{document_id} instead. -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| data | formData | Optional JSON string with document update settings. | No | string | -| file | formData | Replacement document file. | No | file | | dataset_id | path | Dataset ID | Yes | string | | document_id | path | Document ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| No | **multipart/form-data**: { **"data"**: string, **"file"**: binary }
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Document updated successfully | [DocumentAndBatchResponse](#documentandbatchresponse) | +| 200 | Document updated successfully | **application/json**: [DocumentAndBatchResponse](#documentandbatchresponse)
| | 401 | Unauthorized - invalid API token | | | 404 | Document not found | | -### /datasets/{dataset_id}/documents/{document_id}/update-by-text - -#### POST -##### Description - +### [POST] /datasets/{dataset_id}/documents/{document_id}/update-by-text Update an existing document by providing text content -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [DocumentTextUpdate](#documenttextupdate) | | dataset_id | path | Dataset ID | Yes | string | | document_id | path | Document ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DocumentTextUpdate](#documenttextupdate)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Document updated successfully | [DocumentAndBatchResponse](#documentandbatchresponse) | +| 200 | Document updated successfully | **application/json**: [DocumentAndBatchResponse](#documentandbatchresponse)
| | 401 | Unauthorized - invalid API token | | | 404 | Document not found | | -### /datasets/{dataset_id}/documents/{document_id}/update_by_file +### ~~[POST] /datasets/{dataset_id}/documents/{document_id}/update_by_file~~ -#### POST ***DEPRECATED*** -##### Description Deprecated legacy alias for updating an existing document by uploading a file. Use PATCH /datasets/{dataset_id}/documents/{document_id} instead. -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| data | formData | Optional JSON string with document update settings. | No | string | -| file | formData | Replacement document file. | No | file | | dataset_id | path | Dataset ID | Yes | string | | document_id | path | Document ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| No | **multipart/form-data**: { **"data"**: string, **"file"**: binary }
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Document updated successfully | [DocumentAndBatchResponse](#documentandbatchresponse) | +| 200 | Document updated successfully | **application/json**: [DocumentAndBatchResponse](#documentandbatchresponse)
| | 401 | Unauthorized - invalid API token | | | 404 | Document not found | | -### /datasets/{dataset_id}/documents/{document_id}/update_by_text +### ~~[POST] /datasets/{dataset_id}/documents/{document_id}/update_by_text~~ -#### POST ***DEPRECATED*** -##### Description Deprecated legacy alias for updating an existing document by providing text content. Use /datasets/{dataset_id}/documents/{document_id}/update-by-text instead. -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [DocumentTextUpdate](#documenttextupdate) | | dataset_id | path | Dataset ID | Yes | string | | document_id | path | Document ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DocumentTextUpdate](#documenttextupdate)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Document updated successfully | [DocumentAndBatchResponse](#documentandbatchresponse) | +| 200 | Document updated successfully | **application/json**: [DocumentAndBatchResponse](#documentandbatchresponse)
| | 401 | Unauthorized - invalid API token | | | 404 | Document not found | | -### /datasets/{dataset_id}/hit-testing - -#### POST -##### Summary - -Perform hit testing on a dataset - -##### Description +### [POST] /datasets/{dataset_id}/hit-testing +**Perform hit testing on a dataset** Perform hit testing on a dataset Tests retrieval performance for the specified dataset. -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [HitTestingPayload](#hittestingpayload) | -| dataset_id | path | Dataset ID | Yes | string | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Hit testing results | [HitTestingResponse](#hittestingresponse) | -| 401 | Unauthorized - invalid API token | | -| 404 | Dataset not found | | - -### /datasets/{dataset_id}/metadata - -#### GET -##### Summary - -Get all metadata for a dataset - -##### Description - -Get all metadata for a dataset - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | Dataset ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [HitTestingPayload](#hittestingpayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Metadata retrieved successfully | [DatasetMetadataListResponse](#datasetmetadatalistresponse) | +| 200 | Hit testing results | **application/json**: [HitTestingResponse](#hittestingresponse)
| | 401 | Unauthorized - invalid API token | | | 404 | Dataset not found | | -#### POST -##### Summary +### [GET] /datasets/{dataset_id}/metadata +**Get all metadata for a dataset** -Create metadata for a dataset - -##### Description - -Create metadata for a dataset - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [MetadataArgs](#metadataargs) | | dataset_id | path | Dataset ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 201 | Metadata created successfully | [DatasetMetadataResponse](#datasetmetadataresponse) | +| 200 | Metadata retrieved successfully | **application/json**: [DatasetMetadataListResponse](#datasetmetadatalistresponse)
| | 401 | Unauthorized - invalid API token | | | 404 | Dataset not found | | -### /datasets/{dataset_id}/metadata/built-in +### [POST] /datasets/{dataset_id}/metadata +**Create metadata for a dataset** -#### GET -##### Summary +#### Parameters -Get all built-in metadata fields +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Dataset ID | Yes | string | -##### Description +#### Request Body -Get all built-in metadata fields +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [MetadataArgs](#metadataargs)
| -##### Parameters +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Metadata created successfully | **application/json**: [DatasetMetadataResponse](#datasetmetadataresponse)
| +| 401 | Unauthorized - invalid API token | | +| 404 | Dataset not found | | + +### [GET] /datasets/{dataset_id}/metadata/built-in +**Get all built-in metadata fields** + +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Built-in fields retrieved successfully | [DatasetMetadataBuiltInFieldsResponse](#datasetmetadatabuiltinfieldsresponse) | +| 200 | Built-in fields retrieved successfully | **application/json**: [DatasetMetadataBuiltInFieldsResponse](#datasetmetadatabuiltinfieldsresponse)
| | 401 | Unauthorized - invalid API token | | -### /datasets/{dataset_id}/metadata/built-in/{action} +### [POST] /datasets/{dataset_id}/metadata/built-in/{action} +**Enable or disable built-in metadata field** -#### POST -##### Summary - -Enable or disable built-in metadata field - -##### Description - -Enable or disable built-in metadata field - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | action | path | Action to perform: 'enable' or 'disable' | Yes | string | | dataset_id | path | Dataset ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Action completed successfully | [DatasetMetadataActionResponse](#datasetmetadataactionresponse) | +| 200 | Action completed successfully | **application/json**: [DatasetMetadataActionResponse](#datasetmetadataactionresponse)
| | 401 | Unauthorized - invalid API token | | | 404 | Dataset not found | | -### /datasets/{dataset_id}/metadata/{metadata_id} +### [DELETE] /datasets/{dataset_id}/metadata/{metadata_id} +**Delete metadata** -#### DELETE -##### Summary - -Delete metadata - -##### Description - -Delete metadata - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | Dataset ID | Yes | string | | metadata_id | path | Metadata ID | Yes | string | -##### Responses +#### Responses | Code | Description | | ---- | ----------- | @@ -1521,653 +1325,565 @@ Delete metadata | 401 | Unauthorized - invalid API token | | 404 | Dataset or metadata not found | -#### PATCH -##### Summary +### [PATCH] /datasets/{dataset_id}/metadata/{metadata_id} +**Update metadata name** -Update metadata name - -##### Description - -Update metadata name - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [MetadataUpdatePayload](#metadataupdatepayload) | | dataset_id | path | Dataset ID | Yes | string | | metadata_id | path | Metadata ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [MetadataUpdatePayload](#metadataupdatepayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Metadata updated successfully | [DatasetMetadataResponse](#datasetmetadataresponse) | +| 200 | Metadata updated successfully | **application/json**: [DatasetMetadataResponse](#datasetmetadataresponse)
| | 401 | Unauthorized - invalid API token | | | 404 | Dataset or metadata not found | | -### /datasets/{dataset_id}/pipeline/datasource-plugins - -#### GET -##### Summary - -Resource for getting datasource plugins - -##### Description +### [GET] /datasets/{dataset_id}/pipeline/datasource-plugins +**Resource for getting datasource plugins** List all datasource plugins for a rag pipeline -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | +| is_published | query | | No | boolean,
**Default:** true | | dataset_id | path | | Yes | string | -| is_published | query | Whether to get published or draft datasource plugins (true for published, false for draft, default: true) | No | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Datasource plugins retrieved successfully | -| 401 | Unauthorized - invalid API token | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Datasource plugins retrieved successfully | **application/json**: [DatasourcePluginListResponse](#datasourcepluginlistresponse)
| +| 401 | Unauthorized - invalid API token | | -### /datasets/{dataset_id}/pipeline/datasource/nodes/{node_id}/run - -#### POST -##### Summary - -Resource for getting datasource plugins - -##### Description +### [POST] /datasets/{dataset_id}/pipeline/datasource/nodes/{node_id}/run +**Resource for getting datasource plugins** Run a datasource node for a rag pipeline -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | | Yes | string | | node_id | path | | Yes | string | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Datasource node run successfully | -| 401 | Unauthorized - invalid API token | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DatasourceNodeRunPayload](#datasourcenoderunpayload)
| -### /datasets/{dataset_id}/pipeline/run +#### Responses -#### POST -##### Summary +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Datasource node run successfully | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| +| 401 | Unauthorized - invalid API token | | -Resource for running a rag pipeline - -##### Description +### [POST] /datasets/{dataset_id}/pipeline/run +**Resource for running a rag pipeline** Run a datasource node for a rag pipeline -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | | Yes | string | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Pipeline run successfully | -| 401 | Unauthorized - invalid API token | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [PipelineRunApiEntity](#pipelinerunapientity)
| -### /datasets/{dataset_id}/retrieve +#### Responses -#### POST -##### Summary +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Pipeline run successfully | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| +| 401 | Unauthorized - invalid API token | | -Perform hit testing on a dataset - -##### Description +### [POST] /datasets/{dataset_id}/retrieve +**Perform hit testing on a dataset** Perform hit testing on a dataset Tests retrieval performance for the specified dataset. -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [HitTestingPayload](#hittestingpayload) | | dataset_id | path | Dataset ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [HitTestingPayload](#hittestingpayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Hit testing results | [HitTestingResponse](#hittestingresponse) | +| 200 | Hit testing results | **application/json**: [HitTestingResponse](#hittestingresponse)
| | 401 | Unauthorized - invalid API token | | | 404 | Dataset not found | | -### /datasets/{dataset_id}/tags - -#### GET -##### Summary - -Get all knowledge type tags - -##### Description +### [GET] /datasets/{dataset_id}/tags +**Get all knowledge type tags** Get tags bound to a specific dataset -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | dataset_id | path | Dataset ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Tags retrieved successfully | [DatasetBoundTagListResponse](#datasetboundtaglistresponse) | +| 200 | Tags retrieved successfully | **application/json**: [DatasetBoundTagListResponse](#datasetboundtaglistresponse)
| | 401 | Unauthorized - invalid API token | | -### /end-users/{end_user_id} - -#### GET -##### Summary - -Get end user detail - -##### Description +### [GET] /end-users/{end_user_id} +**Get end user detail** Get an end user by ID This endpoint is scoped to the current app token's tenant/app to prevent cross-tenant/app access when an end-user ID is known. -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | end_user_id | path | End user ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | End user retrieved successfully | [EndUserDetail](#enduserdetail) | +| 200 | End user retrieved successfully | **application/json**: [EndUserDetail](#enduserdetail)
| | 401 | Unauthorized - invalid API token | | | 404 | End user not found | | -### /files/upload - -#### POST -##### Summary - -Upload a file for use in conversations - -##### Description +### [POST] /files/upload +**Upload a file for use in conversations** Upload a file for use in conversations Accepts a single file upload via multipart/form-data. -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 201 | File uploaded successfully | [FileResponse](#fileresponse) | +| 201 | File uploaded successfully | **application/json**: [FileResponse](#fileresponse)
| | 400 | Bad request - no file or invalid file | | | 401 | Unauthorized - invalid API token | | | 413 | File too large | | | 415 | Unsupported file type | | -### /files/{file_id}/preview - -#### GET -##### Summary - -Preview/Download a file that was uploaded via Service API - -##### Description +### [GET] /files/{file_id}/preview +**Preview/Download a file that was uploaded via Service API** Preview or download a file uploaded via Service API Provides secure file preview/download functionality. Files can only be accessed if they belong to messages within the requesting app's context. -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [FilePreviewQuery](#filepreviewquery) | | file_id | path | UUID of the file to preview | Yes | string | +| as_attachment | query | Download as attachment | No | boolean | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | File retrieved successfully | -| 401 | Unauthorized - invalid API token | -| 403 | Forbidden - file access denied | -| 404 | File not found | - -### /form/human_input/{form_token} - -#### GET -##### Description +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | File retrieved successfully | **application/json**: [BinaryFileResponse](#binaryfileresponse)
| +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - file access denied | | +| 404 | File not found | | +### [GET] /form/human_input/{form_token} Get a paused human input form by token -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | form_token | path | Human input form token | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Form retrieved successfully | -| 401 | Unauthorized - invalid API token | -| 404 | Form not found | -| 412 | Form already submitted or expired | - -#### POST -##### Description +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Form retrieved successfully | **application/json**: [HumanInputFormDefinitionResponse](#humaninputformdefinitionresponse)
| +| 401 | Unauthorized - invalid API token | | +| 404 | Form not found | | +| 412 | Form already submitted or expired | | +### [POST] /form/human_input/{form_token} Submit a paused human input form by token -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [HumanInputFormSubmitPayload](#humaninputformsubmitpayload) | | form_token | path | Human input form token | Yes | string | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Form submitted successfully | -| 400 | Bad request - invalid submission data | -| 401 | Unauthorized - invalid API token | -| 404 | Form not found | -| 412 | Form already submitted or expired | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [HumanInputFormSubmitPayload](#humaninputformsubmitpayload)
| -### /info +#### Responses -#### GET -##### Summary +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Form submitted successfully | **application/json**: [HumanInputFormSubmitResponse](#humaninputformsubmitresponse)
| +| 400 | Bad request - invalid submission data | | +| 401 | Unauthorized - invalid API token | | +| 404 | Form not found | | +| 412 | Form already submitted or expired | | -Get app information - -##### Description +### [GET] /info +**Get app information** Get basic application information Returns basic information about the application including name, description, tags, and mode. -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Application info retrieved successfully | [AppInfoResponse](#appinforesponse) | +| 200 | Application info retrieved successfully | **application/json**: [AppInfoResponse](#appinforesponse)
| | 401 | Unauthorized - invalid API token | | | 404 | Application not found | | -### /messages - -#### GET -##### Summary - -List messages in a conversation - -##### Description +### [GET] /messages +**List messages in a conversation** List messages in a conversation Retrieves messages with pagination support using first_id. -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [MessageListQuery](#messagelistquery) | +| conversation_id | query | Conversation UUID | Yes | string | +| first_id | query | First message ID for pagination | No | string | +| limit | query | Number of messages to return (1-100) | No | integer,
**Default:** 20 | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Messages retrieved successfully | -| 401 | Unauthorized - invalid API token | -| 404 | Conversation or first message not found | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Messages retrieved successfully | **application/json**: [MessageInfiniteScrollPagination](#messageinfinitescrollpagination)
| +| 401 | Unauthorized - invalid API token | | +| 404 | Conversation or first message not found | | -### /messages/{message_id}/feedbacks - -#### POST -##### Summary - -Submit feedback for a message - -##### Description +### [POST] /messages/{message_id}/feedbacks +**Submit feedback for a message** Submit feedback for a message Allows users to rate messages as like/dislike and provide optional feedback content. -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [MessageFeedbackPayload](#messagefeedbackpayload) | | message_id | path | Message ID | Yes | string | -##### Responses +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [MessageFeedbackPayload](#messagefeedbackpayload)
| + +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Feedback submitted successfully | [ResultResponse](#resultresponse) | +| 200 | Feedback submitted successfully | **application/json**: [ResultResponse](#resultresponse)
| | 401 | Unauthorized - invalid API token | | | 404 | Message not found | | -### /messages/{message_id}/suggested - -#### GET -##### Summary - -Get suggested follow-up questions for a message - -##### Description +### [GET] /messages/{message_id}/suggested +**Get suggested follow-up questions for a message** Get suggested follow-up questions for a message Returns AI-generated follow-up questions based on the message content. -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | message_id | path | Message ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Suggested questions retrieved successfully | [SimpleResultStringListResponse](#simpleresultstringlistresponse) | +| 200 | Suggested questions retrieved successfully | **application/json**: [SimpleResultStringListResponse](#simpleresultstringlistresponse)
| | 400 | Suggested questions feature is disabled | | | 401 | Unauthorized - invalid API token | | | 404 | Message not found | | | 500 | Internal server error | | -### /meta - -#### GET -##### Summary - -Get app metadata - -##### Description +### [GET] /meta +**Get app metadata** Get application metadata Returns metadata about the application including configuration and settings. -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Metadata retrieved successfully | -| 401 | Unauthorized - invalid API token | -| 404 | Application not found | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Metadata retrieved successfully | **application/json**: [AppMetaResponse](#appmetaresponse)
| +| 401 | Unauthorized - invalid API token | | +| 404 | Application not found | | -### /parameters - -#### GET -##### Summary - -Retrieve app parameters - -##### Description +### [GET] /parameters +**Retrieve app parameters** Retrieve application input parameters and configuration Returns the input form parameters and configuration for the application. -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Parameters retrieved successfully | -| 401 | Unauthorized - invalid API token | -| 404 | Application not found | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Parameters retrieved successfully | **application/json**: [Parameters](#parameters)
| +| 401 | Unauthorized - invalid API token | | +| 404 | Application not found | | -### /site - -#### GET -##### Summary - -Retrieve app site info - -##### Description +### [GET] /site +**Retrieve app site info** Get application site configuration Returns the site configuration for the application including theme, icons, and text. -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Site configuration retrieved successfully | [Site](#site) | +| 200 | Site configuration retrieved successfully | **application/json**: [Site](#site)
| | 401 | Unauthorized - invalid API token | | | 403 | Forbidden - site not found or tenant archived | | -### /text-to-audio - -#### POST -##### Summary - -Convert text to audio using text-to-speech - -##### Description +### [POST] /text-to-audio +**Convert text to audio using text-to-speech** Convert text to audio using text-to-speech Converts the provided text to audio using the specified voice. -##### Parameters +#### Request Body -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [TextToAudioPayload](#texttoaudiopayload) | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [TextToAudioPayload](#texttoaudiopayload)
| -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Text successfully converted to audio | -| 400 | Bad request - invalid parameters | -| 401 | Unauthorized - invalid API token | -| 500 | Internal server error | - -### /workflow/{task_id}/events - -#### GET -##### Description +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Text successfully converted to audio | **application/json**: [AudioBinaryResponse](#audiobinaryresponse)
| +| 400 | Bad request - invalid parameters | | +| 401 | Unauthorized - invalid API token | | +| 500 | Internal server error | | +### [GET] /workflow/{task_id}/events Get workflow execution events stream after resume -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | task_id | path | Workflow run ID | Yes | string | -| continue_on_pause | query | Whether to keep the stream open across workflow_paused events,specify `"true"` to keep the stream open for `workflow_paused` events. | No | string | -| include_state_snapshot | query | Whether to replay from persisted state snapshot, specify `"true"` to include a status snapshot of executed nodes | No | string | -| user | query | End user identifier (query param) | No | string | +| continue_on_pause | query | Keep the stream open across workflow_paused events | No | boolean | +| include_state_snapshot | query | Replay from persisted state snapshot | No | boolean | +| user | query | End user identifier | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | SSE event stream | -| 401 | Unauthorized - invalid API token | -| 404 | Workflow run not found | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | SSE event stream | **application/json**: [EventStreamResponse](#eventstreamresponse)
| +| 401 | Unauthorized - invalid API token | | +| 404 | Workflow run not found | | -### /workflows/logs - -#### GET -##### Summary - -Get workflow app logs - -##### Description +### [GET] /workflows/logs +**Get workflow app logs** Get workflow execution logs Returns paginated workflow execution logs with filtering options. -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [WorkflowLogQuery](#workflowlogquery) | +| created_at__after | query | | No | string | +| created_at__before | query | | No | string | +| created_by_account | query | | No | string | +| created_by_end_user_session_id | query | | No | string | +| keyword | query | | No | string | +| limit | query | | No | integer,
**Default:** 20 | +| page | query | | No | integer,
**Default:** 1 | +| status | query | | No | string,
**Available values:** "failed", "stopped", "succeeded" | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Logs retrieved successfully | [WorkflowAppLogPaginationResponse](#workflowapplogpaginationresponse) | +| 200 | Logs retrieved successfully | **application/json**: [WorkflowAppLogPaginationResponse](#workflowapplogpaginationresponse)
| | 401 | Unauthorized - invalid API token | | -### /workflows/run - -#### POST -##### Summary - -Execute a workflow - -##### Description +### [POST] /workflows/run +**Execute a workflow** Execute a workflow Runs a workflow with the provided inputs and returns the results. Supports both blocking and streaming response modes. -##### Parameters +#### Request Body -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [WorkflowRunPayload](#workflowrunpayload) | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [WorkflowRunPayload](#workflowrunpayload)
| -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Workflow executed successfully | -| 400 | Bad request - invalid parameters or workflow issues | -| 401 | Unauthorized - invalid API token | -| 404 | Workflow not found | -| 429 | Rate limit exceeded | -| 500 | Internal server error | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow executed successfully | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| +| 400 | Bad request - invalid parameters or workflow issues | | +| 401 | Unauthorized - invalid API token | | +| 404 | Workflow not found | | +| 429 | Rate limit exceeded | | +| 500 | Internal server error | | -### /workflows/run/{workflow_run_id} - -#### GET -##### Summary - -Get a workflow task running detail - -##### Description +### [GET] /workflows/run/{workflow_run_id} +**Get a workflow task running detail** Get workflow run details Returns detailed information about a specific workflow run. -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | workflow_run_id | path | Workflow run ID | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow run details retrieved successfully | [WorkflowRunResponse](#workflowrunresponse) | +| 200 | Workflow run details retrieved successfully | **application/json**: [WorkflowRunResponse](#workflowrunresponse)
| | 401 | Unauthorized - invalid API token | | | 404 | Workflow run not found | | -### /workflows/tasks/{task_id}/stop +### [POST] /workflows/tasks/{task_id}/stop +**Stop a running workflow task** -#### POST -##### Summary - -Stop a running workflow task - -##### Description - -Stop a running workflow task - -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | task_id | path | Task ID to stop | Yes | string | -##### Responses +#### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Task stopped successfully | [SimpleResultResponse](#simpleresultresponse) | +| 200 | Task stopped successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| | 401 | Unauthorized - invalid API token | | | 404 | Task not found | | -### /workflows/{workflow_id}/run - -#### POST -##### Summary - -Run specific workflow by ID - -##### Description +### [POST] /workflows/{workflow_id}/run +**Run specific workflow by ID** Execute a specific workflow by ID Executes a specific workflow version identified by its ID. -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [WorkflowRunPayload](#workflowrunpayload) | | workflow_id | path | Workflow ID to execute | Yes | string | -##### Responses +#### Request Body -| Code | Description | -| ---- | ----------- | -| 200 | Workflow executed successfully | -| 400 | Bad request - invalid parameters or workflow issues | -| 401 | Unauthorized - invalid API token | -| 404 | Workflow not found | -| 429 | Rate limit exceeded | -| 500 | Internal server error | +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [WorkflowRunPayload](#workflowrunpayload)
| -### /workspaces/current/models/model-types/{model_type} +#### Responses -#### GET -##### Summary +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow executed successfully | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| +| 400 | Bad request - invalid parameters or workflow issues | | +| 401 | Unauthorized - invalid API token | | +| 404 | Workflow not found | | +| 429 | Rate limit exceeded | | +| 500 | Internal server error | | -Get available models by model type - -##### Description +### [GET] /workspaces/current/models/model-types/{model_type} +**Get available models by model type** Get available models by model type Returns a list of available models for the specified model type. -##### Parameters +#### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | model_type | path | Type of model to retrieve | Yes | string | -##### Responses +#### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Models retrieved successfully | -| 401 | Unauthorized - invalid API token | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Models retrieved successfully | **application/json**: [ProviderWithModelsListResponse](#providerwithmodelslistresponse)
| +| 401 | Unauthorized - invalid API token | | --- -### Models +### Schemas + +#### AgentThought + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| chain_id | string | | No | +| created_at | integer | | No | +| files | [ string ] | | Yes | +| id | string | | Yes | +| message_id | string | | Yes | +| observation | string | | No | +| position | integer | | Yes | +| thought | string | | No | +| tool | string | | No | +| tool_input | string | | No | +| tool_labels | [JSONValue](#jsonvalue) | | Yes | #### Annotation @@ -2186,6 +1902,14 @@ Returns a list of available models for the specified model type. | answer | string | Annotation answer | Yes | | question | string | Annotation question | Yes | +#### AnnotationJobStatusResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| error_msg | string | | No | +| job_id | string | | Yes | +| job_status | string | | Yes | + #### AnnotationList | Name | Type | Description | Required | @@ -2201,8 +1925,8 @@ Returns a list of available models for the specified model type. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | keyword | string | Keyword to search annotations | No | -| limit | integer | Number of annotations per page | No | -| page | integer | Page number | No | +| limit | integer,
**Default:** 20 | Number of annotations per page | No | +| page | integer,
**Default:** 1 | Page number | No | #### AnnotationReplyActionPayload @@ -2212,6 +1936,28 @@ Returns a list of available models for the specified model type. | embedding_provider_name | string | Embedding provider name | Yes | | score_threshold | number | Score threshold for annotation matching | Yes | +#### AppFeedbackListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [AppFeedbackResponse](#appfeedbackresponse) ] | | Yes | + +#### AppFeedbackResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_id | string | | Yes | +| content | string | | No | +| conversation_id | string | | Yes | +| created_at | string | | Yes | +| from_account_id | string | | No | +| from_end_user_id | string | | No | +| from_source | string | | Yes | +| id | string | | Yes | +| message_id | string | | Yes | +| rating | string | | Yes | +| updated_at | string | | Yes | + #### AppInfoResponse | Name | Type | Description | Required | @@ -2222,17 +1968,49 @@ Returns a list of available models for the specified model type. | name | string | | Yes | | tags | [ string ] | | Yes | +#### AppMetaResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| tool_icons | object | | No | + +#### AudioBinaryResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| AudioBinaryResponse | string | | | + +#### AudioTranscriptResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| text | string | | Yes | + +#### BinaryFileResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| BinaryFileResponse | string | | | + +#### ButtonStyle + +Button styles for user actions. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ButtonStyle | string | Button styles for user actions. | | + #### ChatRequestPayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| auto_generate_name | boolean | Auto generate conversation name | No | +| auto_generate_name | boolean,
**Default:** true | Auto generate conversation name | No | | conversation_id | string | Conversation UUID | No | | files | [ object ] | | No | | inputs | object | | Yes | | query | string | | Yes | -| response_mode | string | *Enum:* `"blocking"`, `"streaming"` | No | -| retriever_from | string | | No | +| response_mode | string | | No | +| retriever_from | string,
**Default:** dev | | No | | trace_session_id | string | Trace session ID for observability grouping | No | | workflow_id | string | Workflow ID for advanced chat | No | @@ -2253,8 +2031,8 @@ Returns a list of available models for the specified model type. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | keyword | string | | No | -| limit | integer | | No | -| page | integer | | No | +| limit | integer,
**Default:** 20 | | No | +| page | integer,
**Default:** 1 | | No | #### ChildChunkListResponse @@ -2292,8 +2070,8 @@ Returns a list of available models for the specified model type. | files | [ object ] | | No | | inputs | object | | Yes | | query | string | | No | -| response_mode | string | *Enum:* `"blocking"`, `"streaming"` | No | -| retriever_from | string | | No | +| response_mode | string | | No | +| retriever_from | string,
**Default:** dev | | No | | trace_session_id | string | Trace session ID for observability grouping | No | #### Condition @@ -2302,17 +2080,25 @@ Condition detail | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| comparison_operator | string | *Enum:* `"<"`, `"="`, `">"`, `"after"`, `"before"`, `"contains"`, `"empty"`, `"end with"`, `"in"`, `"is"`, `"is not"`, `"not contains"`, `"not empty"`, `"not in"`, `"start with"`, `"≠"`, `"≤"`, `"≥"` | Yes | +| comparison_operator | string,
**Available values:** "<", "=", ">", "after", "before", "contains", "empty", "end with", "in", "is", "is not", "not contains", "not empty", "not in", "start with", "≠", "≤", "≥" | *Enum:* `"<"`, `"="`, `">"`, `"after"`, `"before"`, `"contains"`, `"empty"`, `"end with"`, `"in"`, `"is"`, `"is not"`, `"not contains"`, `"not empty"`, `"not in"`, `"start with"`, `"≠"`, `"≤"`, `"≥"` | Yes | | name | string | | Yes | | value | string
[ string ]
integer
number | | No | +#### ConversationInfiniteScrollPagination + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [SimpleConversation](#simpleconversation) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | + #### ConversationListQuery | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | last_id | string | Last conversation ID for pagination | No | -| limit | integer | Number of conversations to return | No | -| sort_by | string | Sort order for conversations
*Enum:* `"-created_at"`, `"-updated_at"`, `"created_at"`, `"updated_at"` | No | +| limit | integer,
**Default:** 20 | Number of conversations to return | No | +| sort_by | string,
**Available values:** "-created_at", "-updated_at", "created_at", "updated_at",
**Default:** -updated_at | Sort order for conversations
*Enum:* `"-created_at"`, `"-updated_at"`, `"created_at"`, `"updated_at"` | No | #### ConversationRenamePayload @@ -2352,9 +2138,17 @@ Condition detail | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | last_id | string | Last variable ID for pagination | No | -| limit | integer | Number of variables to return | No | +| limit | integer,
**Default:** 20 | Number of variables to return | No | | variable_name | string | Filter variables by name | No | +#### CustomConfigurationStatus + +Enum class for custom configuration status. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| CustomConfigurationStatus | string | Enum class for custom configuration status. | | + #### DatasetBoundTagListResponse | Name | Type | Description | Required | @@ -2378,10 +2172,10 @@ Condition detail | embedding_model_provider | string | | No | | external_knowledge_api_id | string | | No | | external_knowledge_id | string | | No | -| indexing_technique | string | *Enum:* `"economy"`, `"high_quality"` | No | +| indexing_technique | string | | No | | name | string | | Yes | | permission | [PermissionEnum](#permissionenum) | | No | -| provider | string | | No | +| provider | string,
**Default:** vendor | | No | | retrieval_model | [RetrievalModel](#retrievalmodel) | | No | | summary_index_setting | object | | No | @@ -2512,8 +2306,8 @@ Condition detail | ---- | ---- | ----------- | -------- | | include_all | boolean | Include all datasets | No | | keyword | string | Search keyword | No | -| limit | integer | Number of items per page | No | -| page | integer | Page number | No | +| limit | integer,
**Default:** 20 | Number of items per page | No | +| page | integer,
**Default:** 1 | Page number | No | | tag_ids | [ string ] | Filter by tag IDs | No | #### DatasetListResponse @@ -2616,7 +2410,7 @@ Condition detail | external_knowledge_api_id | string | | No | | external_knowledge_id | string | | No | | external_retrieval_model | object | | No | -| indexing_technique | string | *Enum:* `"economy"`, `"high_quality"` | No | +| indexing_technique | string | | No | | name | string | | No | | partial_member_list | [ object ] | | No | | permission | [PermissionEnum](#permissionenum) | | No | @@ -2638,6 +2432,15 @@ Condition detail | vector_setting | [DatasetVectorSettingResponse](#datasetvectorsettingresponse) | | No | | weight_type | string | | No | +#### DatasourceCredentialInfoResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| id | string | | No | +| is_default | boolean | | No | +| name | string | | No | +| type | string | | No | + #### DatasourceNodeRunPayload | Name | Type | Description | Required | @@ -2647,6 +2450,30 @@ Condition detail | inputs | object | | Yes | | is_published | boolean | | Yes | +#### DatasourcePluginListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| DatasourcePluginListResponse | array | | | + +#### DatasourcePluginResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credentials | [ [DatasourceCredentialInfoResponse](#datasourcecredentialinforesponse) ] | | Yes | +| datasource_type | string | | No | +| node_id | string | | No | +| plugin_id | string | | No | +| provider_name | string | | No | +| title | string | | No | +| user_input_variables | [ object ] | | No | + +#### DatasourcePluginsQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| is_published | boolean,
**Default:** true | | No | + #### DocumentAndBatchResponse | Name | Type | Description | Required | @@ -2662,13 +2489,55 @@ Request payload for bulk downloading documents as a zip archive. | ---- | ---- | ----------- | -------- | | document_ids | [ string (uuid) ] | | 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_info | object | | No | +| data_source_type | string | | No | +| dataset_process_rule | object | | 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 | object | | 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 | +| summary_index_status | string | | No | +| tokens | integer | | No | +| updated_at | integer | | No | + +#### DocumentGetQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| metadata | string,
**Available values:** "all", "only", "without",
**Default:** all | Metadata response mode
*Enum:* `"all"`, `"only"`, `"without"` | No | + #### DocumentListQuery | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | keyword | string | Search keyword | No | -| limit | integer | Number of items per page | No | -| page | integer | Page number | No | +| limit | integer,
**Default:** 20 | Number of items per page | No | +| page | integer,
**Default:** 1 | Page number | No | | status | string | Document status filter | No | #### DocumentListResponse @@ -2733,6 +2602,12 @@ Request payload for bulk downloading documents as a zip archive. | ---- | ---- | ----------- | -------- | | data | [ [DocumentStatusResponse](#documentstatusresponse) ] | | Yes | +#### DocumentStatusPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| document_ids | [ string ] | Document IDs to update | No | + #### DocumentStatusResponse | Name | Type | Description | Required | @@ -2754,8 +2629,8 @@ Request payload for bulk downloading documents as a zip archive. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| doc_form | string | | No | -| doc_language | string | | No | +| doc_form | string,
**Default:** text_model | | No | +| doc_language | string,
**Default:** English | | No | | embedding_model | string | | No | | embedding_model_provider | string | | No | | indexing_technique | string | | No | @@ -2769,8 +2644,8 @@ Request payload for bulk downloading documents as a zip archive. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| doc_form | string | | No | -| doc_language | string | | No | +| doc_form | string,
**Default:** text_model | | No | +| doc_language | string,
**Default:** English | | No | | name | string | | No | | process_rule | [ProcessRule](#processrule) | | No | | retrieval_model | [RetrievalModel](#retrievalmodel) | | No | @@ -2797,12 +2672,53 @@ Note: The SQLAlchemy model defines an `is_anonymous` property for Flask-Login se | type | string | | Yes | | updated_at | dateTime | | Yes | +#### EventStreamResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| EventStreamResponse | string | | | + +#### ExecutionContentType + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ExecutionContentType | string | | | + #### FeedbackListQuery | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| limit | integer | Number of feedbacks per page | No | -| page | integer | Page number | No | +| limit | integer,
**Default:** 20 | Number of feedbacks per page | No | +| page | integer,
**Default:** 1 | Page number | No | + +#### FetchFrom + +Enum class for fetch from. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| FetchFrom | string | Enum class for fetch from. | | + +#### FileInputConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| allowed_file_extensions | [ string ] | | No | +| allowed_file_types | [ [FileType](#filetype) ] | | No | +| allowed_file_upload_methods | [ [FileTransferMethod](#filetransfermethod) ] | | No | +| output_variable_name | string | | Yes | +| type | string | | No | + +#### FileListInputConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| allowed_file_extensions | [ string ] | | No | +| allowed_file_types | [ [FileType](#filetype) ] | | No | +| allowed_file_upload_methods | [ [FileTransferMethod](#filetransfermethod) ] | | No | +| number_limits | integer | | No | +| output_variable_name | string | | Yes | +| type | string | | No | #### FilePreviewQuery @@ -2830,6 +2746,30 @@ Note: The SQLAlchemy model defines an `is_anonymous` property for Flask-Login se | tenant_id | string | | No | | user_id | string | | No | +#### FileTransferMethod + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| FileTransferMethod | string | | | + +#### FileType + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| FileType | string | | | + +#### FormInputConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| FormInputConfig | [ParagraphInputConfig](#paragraphinputconfig)
[SelectInputConfig](#selectinputconfig)
[FileInputConfig](#fileinputconfig)
[FileListInputConfig](#filelistinputconfig) | | | + +#### GeneratedAppResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| GeneratedAppResponse | | | | + #### HitTestingChildChunk | Name | Type | Description | Required | @@ -2921,6 +2861,52 @@ Note: The SQLAlchemy model defines an `is_anonymous` property for Flask-Login se | tokens | integer | | Yes | | word_count | integer | | Yes | +#### HumanInputContent + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| form_definition | [HumanInputFormDefinition](#humaninputformdefinition) | | No | +| form_submission_data | [HumanInputFormSubmissionData](#humaninputformsubmissiondata) | | No | +| submitted | boolean | | Yes | +| type | [ExecutionContentType](#executioncontenttype) | | No | +| workflow_run_id | string | | Yes | + +#### HumanInputFormDefinition + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| actions | [ [UserActionConfig](#useractionconfig) ] | | No | +| display_in_ui | boolean | | No | +| expiration_time | integer | | Yes | +| form_content | string | | Yes | +| form_id | string | | Yes | +| form_token | string | | No | +| inputs | [ [FormInputConfig](#forminputconfig) ] | | No | +| node_id | string | | Yes | +| node_title | string | | Yes | +| resolved_default_values | object | | No | + +#### HumanInputFormDefinitionResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| expiration_time | integer | | No | +| form_content | string | | Yes | +| inputs | [ object ] | | No | +| resolved_default_values | object | | Yes | +| user_actions | [ object ] | | No | + +#### HumanInputFormSubmissionData + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| action_id | string | | Yes | +| action_text | string | | Yes | +| node_id | string | | Yes | +| node_title | string | | Yes | +| rendered_content | string | | Yes | +| submitted_data | object | | No | + #### HumanInputFormSubmitPayload | Name | Type | Description | Required | @@ -2928,6 +2914,20 @@ Note: The SQLAlchemy model defines an `is_anonymous` property for Flask-Login se | action | string | | Yes | | inputs | object | Submitted human input values keyed by output variable name. Use a string for paragraph or select input values, a file mapping for file inputs, and a list of file mappings for file-list inputs. Local file mappings use `transfer_method=local_file` with `upload_file_id`; remote file mappings use `transfer_method=remote_url` with `url` or `remote_url`. | Yes | +#### HumanInputFormSubmitResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | + +#### I18nObject + +Model class for i18n object. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| en_US | string | | Yes | +| zh_Hans | string | | No | + #### IndexInfoResponse | Name | Type | Description | Required | @@ -2936,6 +2936,24 @@ Note: The SQLAlchemy model defines an `is_anonymous` property for Flask-Login se | server_version | string | | Yes | | welcome | string | | Yes | +#### JSONObject + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| JSONObject | object | | | + +#### JSONValue + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| JSONValue | string
integer
number
boolean
object
[ object ] | | | + +#### JSONValueType + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| JSONValueType | | | | + #### JsonValue | Name | Type | Description | Required | @@ -2962,7 +2980,48 @@ Note: The SQLAlchemy model defines an `is_anonymous` property for Flask-Login se | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | content | string | | No | -| rating | string | *Enum:* `"dislike"`, `"like"` | No | +| rating | string | | No | + +#### MessageFile + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| belongs_to | string | | No | +| filename | string | | Yes | +| id | string | | Yes | +| mime_type | string | | No | +| size | integer | | No | +| transfer_method | string | | Yes | +| type | string | | Yes | +| upload_file_id | string | | No | +| url | string | | No | + +#### MessageInfiniteScrollPagination + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [MessageListItem](#messagelistitem) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | + +#### MessageListItem + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| agent_thoughts | [ [AgentThought](#agentthought) ] | | Yes | +| answer | string | | Yes | +| conversation_id | string | | Yes | +| created_at | integer | | No | +| error | string | | No | +| extra_contents | [ [HumanInputContent](#humaninputcontent) ] | | Yes | +| feedback | [SimpleFeedback](#simplefeedback) | | No | +| id | string | | Yes | +| inputs | object | | Yes | +| message_files | [ [MessageFile](#messagefile) ] | | Yes | +| parent_message_id | string | | No | +| query | string | | Yes | +| retriever_resources | [ [RetrieverResource](#retrieverresource) ] | | Yes | +| status | string | | Yes | #### MessageListQuery @@ -2970,14 +3029,14 @@ Note: The SQLAlchemy model defines an `is_anonymous` property for Flask-Login se | ---- | ---- | ----------- | -------- | | conversation_id | string | Conversation UUID | Yes | | first_id | string | First message ID for pagination | No | -| limit | integer | Number of messages to return (1-100) | No | +| limit | integer,
**Default:** 20 | Number of messages to return (1-100) | No | #### MetadataArgs | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | name | string | | Yes | -| type | string | *Enum:* `"number"`, `"string"`, `"time"` | Yes | +| type | string,
**Available values:** "number", "string", "time" | *Enum:* `"number"`, `"string"`, `"time"` | Yes | #### MetadataDetail @@ -2994,7 +3053,7 @@ Metadata Filtering Condition. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | conditions | [ [Condition](#condition) ] | | No | -| logical_operator | string | *Enum:* `"and"`, `"or"` | No | +| logical_operator | string | | No | #### MetadataOperationData @@ -3010,6 +3069,65 @@ Metadata operation data | ---- | ---- | ----------- | -------- | | name | string | | Yes | +#### ModelFeature + +Enum class for llm feature. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ModelFeature | string | Enum class for llm feature. | | + +#### ModelPropertyKey + +Enum class for model property key. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ModelPropertyKey | string | Enum class for model property key. | | + +#### ModelStatus + +Enum class for model status. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ModelStatus | string | Enum class for model status. | | + +#### ModelType + +Enum class for model type. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ModelType | string | Enum class for model type. | | + +#### ParagraphInputConfig + +Form input definition. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| default | [StringSource](#stringsource) | | No | +| output_variable_name | string | | Yes | +| type | string | | No | + +#### Parameters + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| annotation_reply | [JSONObject](#jsonobject) | | Yes | +| file_upload | [JSONObject](#jsonobject) | | Yes | +| more_like_this | [JSONObject](#jsonobject) | | Yes | +| opening_statement | | | No | +| retriever_resource | [JSONObject](#jsonobject) | | Yes | +| sensitive_word_avoidance | [JSONObject](#jsonobject) | | Yes | +| speech_to_text | [JSONObject](#jsonobject) | | Yes | +| suggested_questions | [ string ] | | Yes | +| suggested_questions_after_answer | [JSONObject](#jsonobject) | | Yes | +| system_parameters | [SystemParameters](#systemparameters) | | Yes | +| text_to_speech | [JSONObject](#jsonobject) | | Yes | +| user_input_form | [ [JSONObject](#jsonobject) ] | | Yes | + #### PermissionEnum Shared permission levels for resources (datasets, credentials, etc.) @@ -3029,6 +3147,18 @@ Shared permission levels for resources (datasets, credentials, etc.) | response_mode | string | | Yes | | start_node_id | string | | Yes | +#### PipelineUploadFileResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | string | | No | +| created_by | string | | Yes | +| extension | string | | Yes | +| id | string | | Yes | +| mime_type | string | | No | +| name | string | | Yes | +| size | integer | | Yes | + #### PreProcessingRule | Name | Type | Description | Required | @@ -3051,6 +3181,43 @@ Dataset Process Rule Mode | ---- | ---- | ----------- | -------- | | ProcessRuleMode | string | Dataset Process Rule Mode | | +#### ProviderModelWithStatusEntity + +Model class for model response. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| deprecated | boolean | | No | +| features | [ [ModelFeature](#modelfeature) ] | | No | +| fetch_from | [FetchFrom](#fetchfrom) | | Yes | +| has_invalid_load_balancing_configs | boolean | | No | +| label | [I18nObject](#i18nobject) | | Yes | +| load_balancing_enabled | boolean | | No | +| model | string | | Yes | +| model_properties | object | | Yes | +| model_type | [ModelType](#modeltype) | | Yes | +| status | [ModelStatus](#modelstatus) | | Yes | + +#### ProviderWithModelsListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [ProviderWithModelsResponse](#providerwithmodelsresponse) ] | | Yes | + +#### ProviderWithModelsResponse + +Model class for provider with models response. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| icon_small | [I18nObject](#i18nobject) | | No | +| icon_small_dark | [I18nObject](#i18nobject) | | No | +| label | [I18nObject](#i18nobject) | | Yes | +| models | [ [ProviderModelWithStatusEntity](#providermodelwithstatusentity) ] | | Yes | +| provider | string | | Yes | +| status | [CustomConfigurationStatus](#customconfigurationstatus) | | Yes | +| tenant_id | string | | Yes | + #### RerankingModel | Name | Type | Description | Required | @@ -3084,11 +3251,33 @@ Dataset Process Rule Mode | top_k | integer | | Yes | | weights | [WeightModel](#weightmodel) | | No | +#### RetrieverResource + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| content | string | | No | +| created_at | integer | | No | +| data_source_type | string | | No | +| dataset_id | string | | No | +| dataset_name | string | | No | +| document_id | string | | No | +| document_name | string | | No | +| hit_count | integer | | No | +| id | string | | No | +| index_node_hash | string | | No | +| message_id | string | | No | +| position | integer | | Yes | +| score | number | | No | +| segment_id | string | | No | +| segment_position | integer | | No | +| summary | string | | No | +| word_count | integer | | No | + #### Rule | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| parent_mode | string | *Enum:* `"full-doc"`, `"paragraph"` | No | +| parent_mode | string | | No | | pre_processing_rules | [ [PreProcessingRule](#preprocessingrule) ] | | No | | segmentation | [Segmentation](#segmentation) | | No | | subchunk_segmentation | [Segmentation](#segmentation) | | No | @@ -3138,8 +3327,8 @@ Dataset Process Rule Mode | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | keyword | string | | No | -| limit | integer | | No | -| page | integer | | No | +| limit | integer,
**Default:** 20 | | No | +| page | integer,
**Default:** 1 | | No | | status | [ string ] | | No | #### SegmentListResponse @@ -3209,7 +3398,16 @@ Dataset Process Rule Mode | ---- | ---- | ----------- | -------- | | chunk_overlap | integer | | No | | max_tokens | integer | | Yes | -| separator | string | | No | +| separator | string,
**Default:** + | | No | + +#### SelectInputConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| option_source | [StringListSource](#stringlistsource) | | Yes | +| output_variable_name | string | | Yes | +| type | string | | No | #### SimpleAccount @@ -3219,6 +3417,18 @@ Dataset Process Rule Mode | id | string | | Yes | | name | string | | Yes | +#### SimpleConversation + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | No | +| id | string | | Yes | +| inputs | object | | Yes | +| introduction | string | | No | +| name | string | | Yes | +| status | string | | Yes | +| updated_at | integer | | No | + #### SimpleEndUser | Name | Type | Description | Required | @@ -3228,6 +3438,12 @@ Dataset Process Rule Mode | session_id | string | | No | | type | string | | Yes | +#### SimpleFeedback + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| rating | string | | No | + #### SimpleResultResponse | Name | Type | Description | Required | @@ -3260,6 +3476,34 @@ Dataset Process Rule Mode | title | string | | Yes | | use_icon_as_answer_icon | boolean | | Yes | +#### StringListSource + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| selector | [ string ] | | No | +| type | [ValueSourceType](#valuesourcetype) | | Yes | +| value | [ string ] | | No | + +#### StringSource + +Default configuration for form inputs. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| selector | [ string ] | | No | +| type | [ValueSourceType](#valuesourcetype) | | Yes | +| value | string | | No | + +#### SystemParameters + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| audio_file_size_limit | integer | | Yes | +| file_size_limit | integer | | Yes | +| image_file_size_limit | integer | | Yes | +| video_file_size_limit | integer | | Yes | +| workflow_file_upload_limit | integer | | Yes | + #### TagBindingPayload | Name | Type | Description | Required | @@ -3311,6 +3555,25 @@ Accept the legacy single-tag Service API payload while exposing a normalized tag | ---- | ---- | ----------- | -------- | | url | string | | Yes | +#### UserActionConfig + +User action configuration. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| button_style | [ButtonStyle](#buttonstyle) | | No | +| id | string | | Yes | +| title | string | | Yes | + +#### ValueSourceType + +ValueSourceType records whether the value comes from a static setting +in form definiton, or a variable while the workflow is running. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ValueSourceType | string | ValueSourceType records whether the value comes from a static setting in form definiton, or a variable while the workflow is running. | | + #### WeightKeywordSetting | Name | Type | Description | Required | @@ -3323,7 +3586,7 @@ Accept the legacy single-tag Service API payload while exposing a normalized tag | ---- | ---- | ----------- | -------- | | keyword_setting | [WeightKeywordSetting](#weightkeywordsetting) | | No | | vector_setting | [WeightVectorSetting](#weightvectorsetting) | | No | -| weight_type | string | *Enum:* `"customized"`, `"keyword_first"`, `"semantic_first"` | No | +| weight_type | string | | No | #### WeightVectorSetting @@ -3356,6 +3619,14 @@ Accept the legacy single-tag Service API payload while exposing a normalized tag | id | string | | Yes | | workflow_run | [WorkflowRunForLogResponse](#workflowrunforlogresponse) | | No | +#### WorkflowEventsQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| continue_on_pause | boolean | Keep the stream open across workflow_paused events | No | +| include_state_snapshot | boolean | Replay from persisted state snapshot | No | +| user | string | End user identifier | Yes | + #### WorkflowLogQuery | Name | Type | Description | Required | @@ -3365,9 +3636,9 @@ Accept the legacy single-tag Service API payload while exposing a normalized tag | created_by_account | string | | No | | created_by_end_user_session_id | string | | No | | keyword | string | | No | -| limit | integer | | No | -| page | integer | | No | -| status | string | *Enum:* `"failed"`, `"stopped"`, `"succeeded"` | No | +| limit | integer,
**Default:** 20 | | No | +| page | integer,
**Default:** 1 | | No | +| status | string | | No | #### WorkflowRunForLogResponse @@ -3391,7 +3662,7 @@ Accept the legacy single-tag Service API payload while exposing a normalized tag | ---- | ---- | ----------- | -------- | | files | [ object ] | | No | | inputs | object | | Yes | -| response_mode | string | *Enum:* `"blocking"`, `"streaming"` | No | +| response_mode | string | | No | | trace_session_id | string | Trace session ID for observability grouping | No | #### WorkflowRunResponse diff --git a/api/openapi/markdown/web-openapi.md b/api/openapi/markdown/web-openapi.md new file mode 100644 index 00000000000..302c2a55e43 --- /dev/null +++ b/api/openapi/markdown/web-openapi.md @@ -0,0 +1,1715 @@ +# Web API +Public APIs for web applications including file uploads, chat interactions, and app management + +## Version: 1.0 + +### Available authorizations +#### Bearer (API Key Authentication) +Type: Bearer {your-api-key} +**Name:** Authorization +**In:** header + +--- +## web +Web application API operations + +### [POST] /audio-to-text +**Convert audio to text** + +Convert audio file to text using speech-to-text service. + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [AudioTranscriptResponse](#audiotranscriptresponse)
| +| 400 | Bad Request | | +| 401 | Unauthorized | | +| 403 | Forbidden | | +| 413 | Audio file too large | | +| 415 | Unsupported audio type | | +| 500 | Internal Server Error | | + +### [POST] /chat-messages +Create a chat message for conversational applications. + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ChatMessagePayload](#chatmessagepayload)
| + +#### 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 | | + +### [POST] /chat-messages/{task_id}/stop +Stop a running chat message task. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| task_id | path | Task ID to stop | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +| 400 | Bad Request | | +| 401 | Unauthorized | | +| 403 | Forbidden | | +| 404 | Task Not Found | | +| 500 | Internal Server Error | | + +### [POST] /completion-messages +Create a completion message for text generation applications. + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [CompletionMessagePayload](#completionmessagepayload)
| + +#### 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 | | + +### [POST] /completion-messages/{task_id}/stop +Stop a running completion message task. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| task_id | path | Task ID to stop | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +| 400 | Bad Request | | +| 401 | Unauthorized | | +| 403 | Forbidden | | +| 404 | Task Not Found | | +| 500 | Internal Server Error | | + +### [GET] /conversations +Retrieve paginated list of conversations for a chat application. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| last_id | query | | No | string | +| limit | query | | No | integer,
**Default:** 20 | +| pinned | query | | No | boolean | +| sort_by | query | | No | string,
**Available values:** "-created_at", "-updated_at", "created_at", "updated_at",
**Default:** -updated_at | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ConversationInfiniteScrollPagination](#conversationinfinitescrollpagination)
| +| 400 | Bad Request | | +| 401 | Unauthorized | | +| 403 | Forbidden | | +| 404 | App Not Found or Not a Chat App | | +| 500 | Internal Server Error | | + +### [DELETE] /conversations/{c_id} +Delete a specific conversation. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| c_id | path | Conversation UUID | Yes | string | + +#### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Conversation deleted successfully | +| 400 | Bad Request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Conversation Not Found or Not a Chat App | +| 500 | Internal Server Error | + +### [POST] /conversations/{c_id}/name +Rename a specific conversation with a custom name or auto-generate one. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| c_id | path | Conversation UUID | Yes | string | +| auto_generate | query | Auto-generate conversation name | No | boolean | +| name | query | New conversation name | No | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ConversationRenamePayload](#conversationrenamepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Conversation renamed successfully | **application/json**: [SimpleConversation](#simpleconversation)
| +| 400 | Bad Request | | +| 401 | Unauthorized | | +| 403 | Forbidden | | +| 404 | Conversation Not Found or Not a Chat App | | +| 500 | Internal Server Error | | + +### [PATCH] /conversations/{c_id}/pin +Pin a specific conversation to keep it at the top of the list. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| c_id | path | Conversation UUID | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Conversation pinned successfully | **application/json**: [ResultResponse](#resultresponse)
| +| 400 | Bad Request | | +| 401 | Unauthorized | | +| 403 | Forbidden | | +| 404 | Conversation Not Found or Not a Chat App | | +| 500 | Internal Server Error | | + +### [PATCH] /conversations/{c_id}/unpin +Unpin a specific conversation to remove it from the top of the list. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| c_id | path | Conversation UUID | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Conversation unpinned successfully | **application/json**: [ResultResponse](#resultresponse)
| +| 400 | Bad Request | | +| 401 | Unauthorized | | +| 403 | Forbidden | | +| 404 | Conversation Not Found or Not a Chat App | | +| 500 | Internal Server Error | | + +### [POST] /email-code-login +Send email verification code for login + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [EmailCodeLoginSendPayload](#emailcodeloginsendpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Email code sent successfully | **application/json**: [SimpleResultDataResponse](#simpleresultdataresponse)
| +| 400 | Bad request - invalid email format | | +| 404 | Account not found | | + +### [POST] /email-code-login/validity +Verify email code and complete login + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [EmailCodeLoginVerifyPayload](#emailcodeloginverifypayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Email code verified and login successful | **application/json**: [AccessTokenResultResponse](#accesstokenresultresponse)
| +| 400 | Bad request - invalid code or token | | +| 401 | Invalid token or expired code | | +| 404 | Account not found | | + +### [POST] /files/upload +**Upload a file for use in web applications** + +Upload a file for use in web applications +Accepts file uploads for use within web applications, supporting +multiple file types with automatic validation and storage. + +Args: + app_model: The associated application model + end_user: The end user uploading the file + +Form Parameters: + file: The file to upload (required) + source: Optional source type (datasets or None) + +Returns: + dict: File information including ID, URL, and metadata + int: HTTP status code 201 for success + +Raises: + NoFileUploadedError: No file provided in request + TooManyFilesError: Multiple files provided (only one allowed) + FilenameNotExistsError: File has no filename + FileTooLargeError: File exceeds size limit + UnsupportedFileTypeError: File type not supported + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | File uploaded successfully | **application/json**: [FileResponse](#fileresponse)
| +| 400 | Bad request - invalid file or parameters | | +| 413 | File too large | | +| 415 | Unsupported file type | | + +### [POST] /forgot-password +Send password reset email + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ForgotPasswordSendPayload](#forgotpasswordsendpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Password reset email sent successfully | **application/json**: [SimpleResultDataResponse](#simpleresultdataresponse)
| +| 400 | Bad request - invalid email format | | +| 404 | Account not found | | +| 429 | Too many requests - rate limit exceeded | | + +### [POST] /forgot-password/resets +Reset user password with verification token + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ForgotPasswordResetPayload](#forgotpasswordresetpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Password reset successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +| 400 | Bad request - invalid parameters or password mismatch | | +| 401 | Invalid or expired token | | +| 404 | Account not found | | + +### [POST] /forgot-password/validity +Verify password reset token validity + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ForgotPasswordCheckPayload](#forgotpasswordcheckpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Token is valid | **application/json**: [VerificationTokenResponse](#verificationtokenresponse)
| +| 400 | Bad request - invalid token format | | +| 401 | Invalid or expired token | | + +### [GET] /form/human_input/{form_token} +**Get human input form definition by token** + +GET /api/form/human_input/ + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| form_token | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [HumanInputFormDefinitionResponse](#humaninputformdefinitionresponse)
| + +### [POST] /form/human_input/{form_token} +**Submit human input form by token** + +POST /api/form/human_input/ + +Request body: +{ + "inputs": { + "content": "User input content" + }, + "action": "Approve" +} + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| form_token | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [HumanInputFormSubmitPayload](#humaninputformsubmitpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [HumanInputFormSubmitResponse](#humaninputformsubmitresponse)
| + +### [POST] /form/human_input/{form_token}/upload-token +**Issue an upload token for a human input form** + +POST /api/form/human_input//upload-token + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| form_token | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [HumanInputUploadTokenResponse](#humaninputuploadtokenresponse)
| + +### [POST] /human-input-forms/files +**Upload one local file or remote URL file for a HITL human input form** + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | File uploaded successfully | **application/json**: [FileResponse](#fileresponse)
| + +### [POST] /login +**Authenticate user and login** + +Authenticate user for web application access + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [LoginPayload](#loginpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Authentication successful | **application/json**: [AccessTokenResultResponse](#accesstokenresultresponse)
| +| 400 | Bad request - invalid email or password format | | +| 401 | Authentication failed - email or password mismatch | | +| 403 | Account banned or login disabled | | +| 404 | Account not found | | + +### [GET] /login/status +Check login status + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_code | query | Web app code | No | string | +| user_id | query | End user session ID | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Login status | **application/json**: [LoginStatusResponse](#loginstatusresponse)
| +| 401 | Login status | | + +### [POST] /logout +Logout user from web application + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Logout successful | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| + +### [GET] /messages +Retrieve paginated list of messages from a conversation in a chat application. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| conversation_id | query | Conversation UUID | Yes | string | +| first_id | query | First message ID for pagination | No | string | +| limit | query | Number of messages to return (1-100) | No | integer,
**Default:** 20 | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [WebMessageInfiniteScrollPagination](#webmessageinfinitescrollpagination)
| +| 400 | Bad Request | | +| 401 | Unauthorized | | +| 403 | Forbidden | | +| 404 | Conversation Not Found or Not a Chat App | | +| 500 | Internal Server Error | | + +### [POST] /messages/{message_id}/feedbacks +Submit feedback (like/dislike) for a specific message. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| message_id | path | Message UUID | Yes | string | +| content | query | Feedback content | No | string | +| rating | query | Feedback rating | No | string,
**Available values:** "dislike", "like" | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [MessageFeedbackPayload](#messagefeedbackpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Feedback submitted successfully | **application/json**: [ResultResponse](#resultresponse)
| +| 400 | Bad Request | | +| 401 | Unauthorized | | +| 403 | Forbidden | | +| 404 | Message Not Found | | +| 500 | Internal Server Error | | + +### [GET] /messages/{message_id}/more-like-this +Generate a new completion similar to an existing message (completion apps only). + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| response_mode | query | Response mode | Yes | string,
**Available values:** "blocking", "streaming" | +| message_id | path | | Yes | string | + +#### 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 | | + +### [GET] /messages/{message_id}/suggested-questions +Get suggested follow-up questions after a message (chat apps only). + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| message_id | path | Message UUID | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SuggestedQuestionsResponse](#suggestedquestionsresponse)
| +| 400 | Bad Request - Not a chat app or feature disabled | | +| 401 | Unauthorized | | +| 403 | Forbidden | | +| 404 | Message Not Found or Conversation Not Found | | +| 500 | Internal Server Error | | + +### [GET] /meta +**Get app meta** + +Retrieve the metadata for a specific app. + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [AppMetaResponse](#appmetaresponse)
| +| 400 | Bad Request | | +| 401 | Unauthorized | | +| 403 | Forbidden | | +| 404 | App Not Found | | +| 500 | Internal Server Error | | + +### [GET] /parameters +**Retrieve app parameters** + +Retrieve the parameters for a specific app. + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [Parameters](#parameters)
| +| 400 | Bad Request | | +| 401 | Unauthorized | | +| 403 | Forbidden | | +| 404 | App Not Found | | +| 500 | Internal Server Error | | + +### [GET] /passport +Get authentication passport for web application access + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| user_id | query | End user session ID | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Passport retrieved successfully | **application/json**: [AccessTokenData](#accesstokendata)
| +| 401 | Unauthorized - missing app code or invalid authentication | | +| 404 | Application or user not found | | + +### [POST] /remote-files/upload +**Upload a file from a remote URL** + +Upload a file from a remote URL +Downloads a file from the provided remote URL and uploads it +to the platform storage for use in web applications. + +Args: + app_model: The associated application model + end_user: The end user making the request + +JSON Parameters: + url: The remote URL to download the file from (required) + +Returns: + dict: File information including ID, signed URL, and metadata + int: HTTP status code 201 for success + +Raises: + RemoteFileUploadError: Failed to fetch file from remote URL + FileTooLargeError: File exceeds size limit + UnsupportedFileTypeError: File type not supported + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [RemoteFileUploadPayload](#remotefileuploadpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Remote file uploaded successfully | **application/json**: [FileWithSignedUrl](#filewithsignedurl)
| +| 400 | Bad request - invalid URL or parameters | | +| 413 | File too large | | +| 415 | Unsupported file type | | +| 500 | Failed to fetch remote file | | + +### [GET] /remote-files/{url} +**Get information about a remote file** + +Get information about a remote file +Retrieves basic information about a file located at a remote URL, +including content type and content length. + +Args: + app_model: The associated application model + end_user: The end user making the request + url: URL-encoded path to the remote file + +Returns: + dict: Remote file information including type and length + +Raises: + HTTPException: If the remote file cannot be accessed + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| url | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Remote file information retrieved successfully | **application/json**: [RemoteFileInfo](#remotefileinfo)
| +| 400 | Bad request - invalid URL | | +| 404 | Remote file not found | | +| 500 | Failed to fetch remote file | | + +### [GET] /saved-messages +Retrieve paginated list of saved messages for a completion application. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| last_id | query | | No | string | +| limit | query | | No | integer,
**Default:** 20 | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SavedMessageInfiniteScrollPagination](#savedmessageinfinitescrollpagination)
| +| 400 | Bad Request - Not a completion app | | +| 401 | Unauthorized | | +| 403 | Forbidden | | +| 404 | App Not Found | | +| 500 | Internal Server Error | | + +### [POST] /saved-messages +Save a specific message for later reference. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| message_id | query | Message UUID to save | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [SavedMessageCreatePayload](#savedmessagecreatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Message saved successfully | **application/json**: [ResultResponse](#resultresponse)
| +| 400 | Bad Request - Not a completion app | | +| 401 | Unauthorized | | +| 403 | Forbidden | | +| 404 | Message Not Found | | +| 500 | Internal Server Error | | + +### [DELETE] /saved-messages/{message_id} +Remove a message from saved messages. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| message_id | path | Message UUID to delete | Yes | string | + +#### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Message removed successfully | +| 400 | Bad Request - Not a completion app | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Message Not Found | +| 500 | Internal Server Error | + +### [GET] /site +**Retrieve app site info** + +Retrieve app site information and configuration. + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [AppSiteInfoResponse](#appsiteinforesponse)
| +| 400 | Bad Request | | +| 401 | Unauthorized | | +| 403 | Forbidden | | +| 404 | App Not Found | | +| 500 | Internal Server Error | | + +### [GET] /system-features +**Get system feature flags and configuration** + +Get system feature flags and configuration +Returns the current system feature flags and configuration +that control various functionalities across the platform. + +Returns: + dict: System feature configuration object + +This endpoint is akin to the `SystemFeatureApi` endpoint in api/controllers/console/feature.py, +except it is intended for use by the web app, instead of the console dashboard. + +NOTE: This endpoint is unauthenticated by design, as it provides system features +data required for webapp initialization. + +Authentication would create circular dependency (can't authenticate without webapp loading). + +Only non-sensitive configuration data should be returned by this endpoint. + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | System features retrieved successfully | **application/json**: [SystemFeatureModel](#systemfeaturemodel)
| +| 500 | Internal server error | | + +### [POST] /text-to-audio +**Convert text to audio** + +Convert text to audio using text-to-speech service. + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [TextToAudioPayload](#texttoaudiopayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [AudioBinaryResponse](#audiobinaryresponse)
| +| 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). + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| appCode | query | Application code | No | string | +| appId | query | Application ID | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [AccessModeResponse](#accessmoderesponse)
| +| 400 | Bad Request | | +| 500 | Internal Server Error | | + +### [GET] /webapp/permission +Check if user has permission to access a web application. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| appId | query | Application ID | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [BooleanResultResponse](#booleanresultresponse)
| +| 400 | Bad Request | | +| 401 | Unauthorized | | +| 500 | Internal Server Error | | + +### [POST] /workflows/run +**Run workflow** + +Execute a workflow with provided inputs and files. + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [WorkflowRunPayload](#workflowrunpayload)
| + +#### 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 | | + +### [POST] /workflows/tasks/{task_id}/stop +**Stop workflow task** + +Stop a running workflow task. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| task_id | path | Task ID to stop | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +| 400 | Bad Request | | +| 401 | Unauthorized | | +| 403 | Forbidden | | +| 404 | Task Not Found | | +| 500 | Internal Server Error | | + +--- +## default +Default namespace + +### [GET] /workflow/{task_id}/events +**Get workflow execution events stream after resume** + +GET /api/workflow//events + +Returns Server-Sent Events stream. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| task_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | SSE event stream | **application/json**: [EventStreamResponse](#eventstreamresponse)
| + +--- +### Schemas + +#### AccessModeResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| accessMode | string | | Yes | + +#### AccessTokenData + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| access_token | string | | Yes | + +#### AccessTokenResultResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [AccessTokenData](#accesstokendata) | | Yes | +| result | string | | Yes | + +#### AgentThought + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| chain_id | string | | No | +| created_at | integer | | No | +| files | [ string ] | | Yes | +| id | string | | Yes | +| message_id | string | | Yes | +| observation | string | | No | +| position | integer | | Yes | +| thought | string | | No | +| tool | string | | No | +| tool_input | string | | No | +| tool_labels | [JSONValue](#jsonvalue) | | Yes | + +#### AppAccessModeQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| appCode | string | Application code | No | +| appId | string | Application ID | No | + +#### AppMetaResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| tool_icons | object | Tool icon metadata keyed by tool name | No | + +#### AppPermissionQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| 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 + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| text | string | | Yes | + +#### BooleanResultResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| result | boolean | | Yes | + +#### BrandingModel + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| application_title | string | | Yes | +| enabled | boolean | | Yes | +| favicon | string | | Yes | +| login_page_logo | string | | Yes | +| workspace_logo | string | | Yes | + +#### ButtonStyle + +Button styles for user actions. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ButtonStyle | string | Button styles for user actions. | | + +#### ChatMessagePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conversation_id | string | Conversation ID | No | +| files | [ object ] | Files to be processed | No | +| inputs | object | Input variables for the chat | Yes | +| parent_message_id | string | Parent message ID | No | +| query | string | User query/message | Yes | +| response_mode | string | Response mode: blocking or streaming | No | +| retriever_from | string,
**Default:** web_app | Source of retriever | No | + +#### CompletionMessagePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| files | [ object ] | Files to be processed | No | +| inputs | object | Input variables for the completion | Yes | +| query | string | Query text for completion | No | +| response_mode | string | Response mode: blocking or streaming | No | +| retriever_from | string,
**Default:** web_app | Source of retriever | No | + +#### ConversationInfiniteScrollPagination + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [SimpleConversation](#simpleconversation) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | + +#### ConversationListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| last_id | string | | No | +| limit | integer,
**Default:** 20 | | No | +| pinned | boolean | | No | +| sort_by | string,
**Available values:** "-created_at", "-updated_at", "created_at", "updated_at",
**Default:** -updated_at | *Enum:* `"-created_at"`, `"-updated_at"`, `"created_at"`, `"updated_at"` | No | + +#### ConversationRenamePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| auto_generate | boolean | | No | +| name | string | | No | + +#### EmailCodeLoginSendPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| email | string | | Yes | +| language | string | | No | + +#### EmailCodeLoginVerifyPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| code | string | | Yes | +| email | string | | Yes | +| token | string | | Yes | + +#### EventStreamResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| EventStreamResponse | string | | | + +#### ExecutionContentType + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ExecutionContentType | string | | | + +#### FileInputConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| allowed_file_extensions | [ string ] | | No | +| allowed_file_types | [ [FileType](#filetype) ] | | No | +| allowed_file_upload_methods | [ [FileTransferMethod](#filetransfermethod) ] | | No | +| output_variable_name | string | | Yes | +| type | string | | No | + +#### FileListInputConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| allowed_file_extensions | [ string ] | | No | +| allowed_file_types | [ [FileType](#filetype) ] | | No | +| allowed_file_upload_methods | [ [FileTransferMethod](#filetransfermethod) ] | | No | +| number_limits | integer | | No | +| output_variable_name | string | | Yes | +| type | string | | No | + +#### FileResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conversation_id | string | | No | +| created_at | integer | | No | +| created_by | string | | No | +| extension | string | | No | +| file_key | string | | No | +| id | string | | Yes | +| mime_type | string | | No | +| name | string | | Yes | +| original_url | string | | No | +| preview_url | string | | No | +| reference | string | | No | +| size | integer | | Yes | +| source_url | string | | No | +| tenant_id | string | | No | +| user_id | string | | No | + +#### FileTransferMethod + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| FileTransferMethod | string | | | + +#### FileType + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| FileType | string | | | + +#### FileWithSignedUrl + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | Yes | +| created_by | string | | Yes | +| extension | string | | Yes | +| id | string | | Yes | +| mime_type | string | | Yes | +| name | string | | Yes | +| size | integer | | Yes | +| url | string | | Yes | + +#### ForgotPasswordCheckPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| code | string | | Yes | +| email | string | | Yes | +| token | string | | Yes | + +#### ForgotPasswordResetPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| new_password | string | | Yes | +| password_confirm | string | | Yes | +| token | string | | Yes | + +#### ForgotPasswordSendPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| email | string | | Yes | +| language | string | | No | + +#### FormInputConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| FormInputConfig | [ParagraphInputConfig](#paragraphinputconfig)
[SelectInputConfig](#selectinputconfig)
[FileInputConfig](#fileinputconfig)
[FileListInputConfig](#filelistinputconfig) | | | + +#### GeneratedAppResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| GeneratedAppResponse | | | | + +#### HumanInputContent + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| form_definition | [HumanInputFormDefinition](#humaninputformdefinition) | | No | +| form_submission_data | [HumanInputFormSubmissionData](#humaninputformsubmissiondata) | | No | +| submitted | boolean | | Yes | +| type | [ExecutionContentType](#executioncontenttype) | | No | +| workflow_run_id | string | | Yes | + +#### HumanInputFileUploadFormPayload + +Parsed multipart form fields for HITL uploads. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| url | string | Remote file URL | No | + +#### HumanInputFormDefinition + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| actions | [ [UserActionConfig](#useractionconfig) ] | | No | +| display_in_ui | boolean | | No | +| expiration_time | integer | | Yes | +| form_content | string | | Yes | +| form_id | string | | Yes | +| form_token | string | | No | +| inputs | [ [FormInputConfig](#forminputconfig) ] | | No | +| node_id | string | | Yes | +| node_title | string | | Yes | +| resolved_default_values | object | | No | + +#### HumanInputFormDefinitionResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| expiration_time | integer | | Yes | +| form_content | | | Yes | +| inputs | | | Yes | +| resolved_default_values | object | | Yes | +| site | object | | No | +| user_actions | | | Yes | + +#### HumanInputFormSubmissionData + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| action_id | string | | Yes | +| action_text | string | | Yes | +| node_id | string | | Yes | +| node_title | string | | Yes | +| rendered_content | string | | Yes | +| submitted_data | object | | No | + +#### HumanInputFormSubmitPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| action | string | | Yes | +| inputs | object | Submitted human input values keyed by output variable name. Use a string for paragraph or select input values, a file mapping for file inputs, and a list of file mappings for file-list inputs. Local file mappings use `transfer_method=local_file` with `upload_file_id`; remote file mappings use `transfer_method=remote_url` with `url` or `remote_url`. | Yes | + +#### HumanInputFormSubmitResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | + +#### HumanInputUploadTokenResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| expires_at | integer | | Yes | +| upload_token | string | | Yes | + +#### JSONObject + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| JSONObject | object | | | + +#### JSONValue + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| JSONValue | string
integer
number
boolean
object
[ object ] | | | + +#### JSONValueType + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| JSONValueType | | | | + +#### JsonValue + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| JsonValue | | | | + +#### LicenseLimitationModel + +- enabled: whether this limit is enforced +- size: current usage count +- limit: maximum allowed count; 0 means unlimited + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| enabled | boolean | Whether this limit is currently active | Yes | +| limit | integer | Maximum number of resources allowed; 0 means no limit | Yes | +| size | integer | Number of resources already consumed | Yes | + +#### LicenseModel + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| expired_at | string | | Yes | +| status | [LicenseStatus](#licensestatus) | | Yes | +| workspaces | [LicenseLimitationModel](#licenselimitationmodel) | | Yes | + +#### LicenseStatus + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| LicenseStatus | string | | | + +#### LoginPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| email | string | | Yes | +| password | string | | Yes | + +#### LoginStatusQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_code | string | Web app code | No | +| user_id | string | End user session ID | No | + +#### LoginStatusResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_logged_in | boolean | | Yes | +| logged_in | boolean | | Yes | + +#### MessageFeedbackPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| content | string | | No | +| rating | string | | No | + +#### MessageFile + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| belongs_to | string | | No | +| filename | string | | Yes | +| id | string | | Yes | +| mime_type | string | | No | +| size | integer | | No | +| transfer_method | string | | Yes | +| type | string | | Yes | +| upload_file_id | string | | No | +| url | string | | No | + +#### MessageListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conversation_id | string | Conversation UUID | Yes | +| first_id | string | First message ID for pagination | No | +| limit | integer,
**Default:** 20 | Number of messages to return (1-100) | No | + +#### MessageMoreLikeThisQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| response_mode | string,
**Available values:** "blocking", "streaming" | Response mode
*Enum:* `"blocking"`, `"streaming"` | Yes | + +#### ParagraphInputConfig + +Form input definition. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| default | [StringSource](#stringsource) | | No | +| output_variable_name | string | | Yes | +| type | string | | No | + +#### Parameters + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| annotation_reply | [JSONObject](#jsonobject) | | Yes | +| file_upload | [JSONObject](#jsonobject) | | Yes | +| more_like_this | [JSONObject](#jsonobject) | | Yes | +| opening_statement | | | No | +| retriever_resource | [JSONObject](#jsonobject) | | Yes | +| sensitive_word_avoidance | [JSONObject](#jsonobject) | | Yes | +| speech_to_text | [JSONObject](#jsonobject) | | Yes | +| suggested_questions | [ string ] | | Yes | +| suggested_questions_after_answer | [JSONObject](#jsonobject) | | Yes | +| system_parameters | [SystemParameters](#systemparameters) | | Yes | +| text_to_speech | [JSONObject](#jsonobject) | | Yes | +| user_input_form | [ [JSONObject](#jsonobject) ] | | Yes | + +#### PassportQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| user_id | string | End user session ID | No | + +#### PluginInstallationPermissionModel + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| plugin_installation_scope | [PluginInstallationScope](#plugininstallationscope) | | Yes | +| restrict_to_marketplace_only | boolean | | Yes | + +#### PluginInstallationScope + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| PluginInstallationScope | string | | | + +#### PluginManagerModel + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| enabled | boolean | | Yes | + +#### RemoteFileInfo + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| file_length | integer | | Yes | +| file_type | string | | Yes | + +#### RemoteFileUploadPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| url | string (uri) | Remote file URL | Yes | + +#### ResultResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| result | string | | Yes | + +#### RetrieverResource + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| content | string | | No | +| created_at | integer | | No | +| data_source_type | string | | No | +| dataset_id | string | | No | +| dataset_name | string | | No | +| document_id | string | | No | +| document_name | string | | No | +| hit_count | integer | | No | +| id | string | | No | +| index_node_hash | string | | No | +| message_id | string | | No | +| position | integer | | Yes | +| score | number | | No | +| segment_id | string | | No | +| segment_position | integer | | No | +| summary | string | | No | +| word_count | integer | | No | + +#### SavedMessageCreatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| message_id | string | | Yes | + +#### SavedMessageInfiniteScrollPagination + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [SavedMessageItem](#savedmessageitem) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | + +#### SavedMessageItem + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| answer | string | | Yes | +| created_at | integer | | No | +| feedback | [SimpleFeedback](#simplefeedback) | | No | +| id | string | | Yes | +| inputs | object | | Yes | +| message_files | [ [MessageFile](#messagefile) ] | | Yes | +| query | string | | Yes | + +#### SavedMessageListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| last_id | string | | No | +| limit | integer,
**Default:** 20 | | No | + +#### SelectInputConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| option_source | [StringListSource](#stringlistsource) | | Yes | +| output_variable_name | string | | Yes | +| type | string | | No | + +#### SimpleConversation + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | No | +| id | string | | Yes | +| inputs | object | | Yes | +| introduction | string | | No | +| name | string | | Yes | +| status | string | | Yes | +| updated_at | integer | | No | + +#### SimpleFeedback + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| rating | string | | No | + +#### SimpleResultDataResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | string | | Yes | +| result | string | | Yes | + +#### SimpleResultResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| result | string | | Yes | + +#### StringListSource + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| selector | [ string ] | | No | +| type | [ValueSourceType](#valuesourcetype) | | Yes | +| value | [ string ] | | No | + +#### StringSource + +Default configuration for form inputs. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| selector | [ string ] | | No | +| type | [ValueSourceType](#valuesourcetype) | | Yes | +| value | string | | No | + +#### SuggestedQuestionsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ string ] | | Yes | + +#### SystemFeatureModel + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| branding | [BrandingModel](#brandingmodel) | | Yes | +| enable_change_email | boolean,
**Default:** true | | Yes | +| enable_collaboration_mode | boolean,
**Default:** true | | Yes | +| enable_creators_platform | boolean | | Yes | +| enable_email_code_login | boolean | | Yes | +| enable_email_password_login | boolean,
**Default:** true | | Yes | +| enable_explore_banner | boolean | | Yes | +| enable_marketplace | boolean | | Yes | +| enable_social_oauth_login | boolean | | Yes | +| enable_trial_app | boolean | | Yes | +| is_allow_create_workspace | boolean | | Yes | +| is_allow_register | boolean | | Yes | +| is_email_setup | boolean | | Yes | +| license | [LicenseModel](#licensemodel) | | Yes | +| max_plugin_package_size | integer,
**Default:** 15728640 | | Yes | +| plugin_installation_permission | [PluginInstallationPermissionModel](#plugininstallationpermissionmodel) | | Yes | +| plugin_manager | [PluginManagerModel](#pluginmanagermodel) | | Yes | +| sso_enforced_for_signin | boolean | | Yes | +| sso_enforced_for_signin_protocol | string | | Yes | +| webapp_auth | [WebAppAuthModel](#webappauthmodel) | | Yes | + +#### SystemParameters + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| audio_file_size_limit | integer | | Yes | +| file_size_limit | integer | | Yes | +| image_file_size_limit | integer | | Yes | +| video_file_size_limit | integer | | Yes | +| workflow_file_upload_limit | integer | | Yes | + +#### TextToAudioPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| message_id | string | Message ID | No | +| streaming | boolean | Enable streaming response | No | +| text | string | Text to convert to audio | No | +| voice | string | Voice to use for TTS | No | + +#### UserActionConfig + +User action configuration. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| button_style | [ButtonStyle](#buttonstyle) | | No | +| id | string | | Yes | +| title | string | | Yes | + +#### ValueSourceType + +ValueSourceType records whether the value comes from a static setting +in form definiton, or a variable while the workflow is running. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ValueSourceType | string | ValueSourceType records whether the value comes from a static setting in form definiton, or a variable while the workflow is running. | | + +#### VerificationTokenResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| email | string | | Yes | +| is_valid | boolean | | Yes | +| token | string | | Yes | + +#### WebAppAuthModel + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| allow_email_code_login | boolean | | Yes | +| allow_email_password_login | boolean | | Yes | +| allow_sso | boolean | | Yes | +| enabled | boolean | | Yes | +| sso_config | [WebAppAuthSSOModel](#webappauthssomodel) | | Yes | + +#### WebAppAuthSSOModel + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| protocol | string | | Yes | + +#### WebMessageInfiniteScrollPagination + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [WebMessageListItem](#webmessagelistitem) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | + +#### WebMessageListItem + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| agent_thoughts | [ [AgentThought](#agentthought) ] | | Yes | +| answer | string | | Yes | +| conversation_id | string | | Yes | +| created_at | integer | | No | +| error | string | | No | +| extra_contents | [ [HumanInputContent](#humaninputcontent) ] | | Yes | +| feedback | [SimpleFeedback](#simplefeedback) | | No | +| id | string | | Yes | +| inputs | object | | Yes | +| message_files | [ [MessageFile](#messagefile) ] | | Yes | +| metadata | [JSONValueType](#jsonvaluetype) | | No | +| parent_message_id | string | | No | +| query | string | | Yes | +| retriever_resources | [ [RetrieverResource](#retrieverresource) ] | | Yes | +| status | string | | Yes | + +#### WorkflowRunPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| files | [ object ] | | No | +| inputs | object | | Yes | diff --git a/api/openapi/markdown/web-swagger.md b/api/openapi/markdown/web-swagger.md deleted file mode 100644 index d24dc48ee69..00000000000 --- a/api/openapi/markdown/web-swagger.md +++ /dev/null @@ -1,1437 +0,0 @@ -# Web API -Public APIs for web applications including file uploads, chat interactions, and app management - -## Version: 1.0 - -### Security -**Bearer** - -| apiKey | *API Key* | -| ------ | --------- | -| Description | Type: Bearer {your-api-key} | -| In | header | -| Name | Authorization | - ---- -## web -Web application API operations - -### /audio-to-text - -#### POST -##### Summary - -Convert audio to text - -##### Description - -Convert audio file to text using speech-to-text service. - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | -| 400 | Bad Request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 413 | Audio file too large | -| 415 | Unsupported audio type | -| 500 | Internal Server Error | - -### /chat-messages - -#### POST -##### Description - -Create a chat message for conversational applications. - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ChatMessagePayload](#chatmessagepayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | -| 400 | Bad Request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | App Not Found | -| 500 | Internal Server Error | - -### /chat-messages/{task_id}/stop - -#### POST -##### Description - -Stop a running chat message task. - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| task_id | path | Task ID to stop | Yes | string | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | [SimpleResultResponse](#simpleresultresponse) | -| 400 | Bad Request | | -| 401 | Unauthorized | | -| 403 | Forbidden | | -| 404 | Task Not Found | | -| 500 | Internal Server Error | | - -### /completion-messages - -#### POST -##### Description - -Create a completion message for text generation applications. - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [CompletionMessagePayload](#completionmessagepayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | -| 400 | Bad Request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | App Not Found | -| 500 | Internal Server Error | - -### /completion-messages/{task_id}/stop - -#### POST -##### Description - -Stop a running completion message task. - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| task_id | path | Task ID to stop | Yes | string | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | [SimpleResultResponse](#simpleresultresponse) | -| 400 | Bad Request | | -| 401 | Unauthorized | | -| 403 | Forbidden | | -| 404 | Task Not Found | | -| 500 | Internal Server Error | | - -### /conversations - -#### GET -##### Description - -Retrieve paginated list of conversations for a chat application. - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| last_id | query | Last conversation ID for pagination | No | string | -| limit | query | Number of conversations to return (1-100) | No | integer | -| pinned | query | Filter by pinned status | No | string | -| sort_by | query | Sort order | No | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | -| 400 | Bad Request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | App Not Found or Not a Chat App | -| 500 | Internal Server Error | - -### /conversations/{c_id} - -#### DELETE -##### Description - -Delete a specific conversation. - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| c_id | path | Conversation UUID | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 204 | Conversation deleted successfully | -| 400 | Bad Request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Conversation Not Found or Not a Chat App | -| 500 | Internal Server Error | - -### /conversations/{c_id}/name - -#### POST -##### Description - -Rename a specific conversation with a custom name or auto-generate one. - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| c_id | path | Conversation UUID | Yes | string | -| auto_generate | query | Auto-generate conversation name | No | boolean | -| name | query | New conversation name | No | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Conversation renamed successfully | -| 400 | Bad Request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Conversation Not Found or Not a Chat App | -| 500 | Internal Server Error | - -### /conversations/{c_id}/pin - -#### PATCH -##### Description - -Pin a specific conversation to keep it at the top of the list. - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| c_id | path | Conversation UUID | Yes | string | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Conversation pinned successfully | [ResultResponse](#resultresponse) | -| 400 | Bad Request | | -| 401 | Unauthorized | | -| 403 | Forbidden | | -| 404 | Conversation Not Found or Not a Chat App | | -| 500 | Internal Server Error | | - -### /conversations/{c_id}/unpin - -#### PATCH -##### Description - -Unpin a specific conversation to remove it from the top of the list. - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| c_id | path | Conversation UUID | Yes | string | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Conversation unpinned successfully | [ResultResponse](#resultresponse) | -| 400 | Bad Request | | -| 401 | Unauthorized | | -| 403 | Forbidden | | -| 404 | Conversation Not Found or Not a Chat App | | -| 500 | Internal Server Error | | - -### /email-code-login - -#### POST -##### Description - -Send email verification code for login - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [EmailCodeLoginSendPayload](#emailcodeloginsendpayload) | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Email code sent successfully | [SimpleResultDataResponse](#simpleresultdataresponse) | -| 400 | Bad request - invalid email format | | -| 404 | Account not found | | - -### /email-code-login/validity - -#### POST -##### Description - -Verify email code and complete login - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [EmailCodeLoginVerifyPayload](#emailcodeloginverifypayload) | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Email code verified and login successful | [AccessTokenResultResponse](#accesstokenresultresponse) | -| 400 | Bad request - invalid code or token | | -| 401 | Invalid token or expired code | | -| 404 | Account not found | | - -### /files/upload - -#### POST -##### Summary - -Upload a file for use in web applications - -##### Description - -Upload a file for use in web applications -Accepts file uploads for use within web applications, supporting -multiple file types with automatic validation and storage. - -Args: - app_model: The associated application model - end_user: The end user uploading the file - -Form Parameters: - file: The file to upload (required) - source: Optional source type (datasets or None) - -Returns: - dict: File information including ID, URL, and metadata - int: HTTP status code 201 for success - -Raises: - NoFileUploadedError: No file provided in request - TooManyFilesError: Multiple files provided (only one allowed) - FilenameNotExistsError: File has no filename - FileTooLargeError: File exceeds size limit - UnsupportedFileTypeError: File type not supported - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 201 | File uploaded successfully | [FileResponse](#fileresponse) | -| 400 | Bad request - invalid file or parameters | | -| 413 | File too large | | -| 415 | Unsupported file type | | - -### /forgot-password - -#### POST -##### Description - -Send password reset email - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ForgotPasswordSendPayload](#forgotpasswordsendpayload) | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Password reset email sent successfully | [SimpleResultDataResponse](#simpleresultdataresponse) | -| 400 | Bad request - invalid email format | | -| 404 | Account not found | | -| 429 | Too many requests - rate limit exceeded | | - -### /forgot-password/resets - -#### POST -##### Description - -Reset user password with verification token - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ForgotPasswordResetPayload](#forgotpasswordresetpayload) | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Password reset successfully | [SimpleResultResponse](#simpleresultresponse) | -| 400 | Bad request - invalid parameters or password mismatch | | -| 401 | Invalid or expired token | | -| 404 | Account not found | | - -### /forgot-password/validity - -#### POST -##### Description - -Verify password reset token validity - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [ForgotPasswordCheckPayload](#forgotpasswordcheckpayload) | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Token is valid | [VerificationTokenResponse](#verificationtokenresponse) | -| 400 | Bad request - invalid token format | | -| 401 | Invalid or expired token | | - -### /form/human_input/{form_token} - -#### GET -##### Summary - -Get human input form definition by token - -##### Description - -GET /api/form/human_input/ - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| form_token | path | | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -#### POST -##### Summary - -Submit human input form by token - -##### Description - -POST /api/form/human_input/ - -Request body: -{ - "inputs": { - "content": "User input content" - }, - "action": "Approve" -} - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| form_token | path | | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /form/human_input/{form_token}/upload-token - -#### POST -##### Summary - -Issue an upload token for a human input form - -##### Description - -POST /api/form/human_input//upload-token - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| form_token | path | | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - -### /human-input-forms/files - -#### POST -##### Summary - -Upload one local file or remote URL file for a HITL human input form - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 201 | File uploaded successfully | [FileResponse](#fileresponse) | - -### /login - -#### POST -##### Summary - -Authenticate user and login - -##### Description - -Authenticate user for web application access - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [LoginPayload](#loginpayload) | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Authentication successful | [AccessTokenResultResponse](#accesstokenresultresponse) | -| 400 | Bad request - invalid email or password format | | -| 401 | Authentication failed - email or password mismatch | | -| 403 | Account banned or login disabled | | -| 404 | Account not found | | - -### /login/status - -#### GET -##### Description - -Check login status - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Login status | [LoginStatusResponse](#loginstatusresponse) | -| 401 | Login status | | - -### /logout - -#### POST -##### Description - -Logout user from web application - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Logout successful | [SimpleResultResponse](#simpleresultresponse) | - -### /messages - -#### GET -##### Description - -Retrieve paginated list of messages from a conversation in a chat application. - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| conversation_id | query | Conversation UUID | Yes | string | -| first_id | query | First message ID for pagination | No | string | -| limit | query | Number of messages to return (1-100) | No | integer | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | -| 400 | Bad Request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Conversation Not Found or Not a Chat App | -| 500 | Internal Server Error | - -### /messages/{message_id}/feedbacks - -#### POST -##### Description - -Submit feedback (like/dislike) for a specific message. - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| message_id | path | Message UUID | Yes | string | -| content | query | Feedback content | No | string | -| rating | query | Feedback rating | No | string | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Feedback submitted successfully | [ResultResponse](#resultresponse) | -| 400 | Bad Request | | -| 401 | Unauthorized | | -| 403 | Forbidden | | -| 404 | Message Not Found | | -| 500 | Internal Server Error | | - -### /messages/{message_id}/more-like-this - -#### GET -##### Description - -Generate a new completion similar to an existing message (completion apps only). - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| message_id | path | | Yes | string | -| payload | body | | Yes | [MessageMoreLikeThisQuery](#messagemorelikethisquery) | - -##### Responses - -| 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 | - -### /messages/{message_id}/suggested-questions - -#### GET -##### Description - -Get suggested follow-up questions after a message (chat apps only). - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| message_id | path | Message UUID | Yes | string | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | [SuggestedQuestionsResponse](#suggestedquestionsresponse) | -| 400 | Bad Request - Not a chat app or feature disabled | | -| 401 | Unauthorized | | -| 403 | Forbidden | | -| 404 | Message Not Found or Conversation Not Found | | -| 500 | Internal Server Error | | - -### /meta - -#### GET -##### Summary - -Get app meta - -##### Description - -Retrieve the metadata for a specific app. - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | -| 400 | Bad Request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | App Not Found | -| 500 | Internal Server Error | - -### /parameters - -#### GET -##### Summary - -Retrieve app parameters - -##### Description - -Retrieve the parameters for a specific app. - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | -| 400 | Bad Request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | App Not Found | -| 500 | Internal Server Error | - -### /passport - -#### GET -##### Description - -Get authentication passport for web application access - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Passport retrieved successfully | -| 401 | Unauthorized - missing app code or invalid authentication | -| 404 | Application or user not found | - -### /remote-files/upload - -#### POST -##### Summary - -Upload a file from a remote URL - -##### Description - -Upload a file from a remote URL -Downloads a file from the provided remote URL and uploads it -to the platform storage for use in web applications. - -Args: - app_model: The associated application model - end_user: The end user making the request - -JSON Parameters: - url: The remote URL to download the file from (required) - -Returns: - dict: File information including ID, signed URL, and metadata - int: HTTP status code 201 for success - -Raises: - RemoteFileUploadError: Failed to fetch file from remote URL - FileTooLargeError: File exceeds size limit - UnsupportedFileTypeError: File type not supported - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 201 | Remote file uploaded successfully | [FileWithSignedUrl](#filewithsignedurl) | -| 400 | Bad request - invalid URL or parameters | | -| 413 | File too large | | -| 415 | Unsupported file type | | -| 500 | Failed to fetch remote file | | - -### /remote-files/{url} - -#### GET -##### Summary - -Get information about a remote file - -##### Description - -Get information about a remote file -Retrieves basic information about a file located at a remote URL, -including content type and content length. - -Args: - app_model: The associated application model - end_user: The end user making the request - url: URL-encoded path to the remote file - -Returns: - dict: Remote file information including type and length - -Raises: - HTTPException: If the remote file cannot be accessed - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| url | path | | Yes | string | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Remote file information retrieved successfully | [RemoteFileInfo](#remotefileinfo) | -| 400 | Bad request - invalid URL | | -| 404 | Remote file not found | | -| 500 | Failed to fetch remote file | | - -### /saved-messages - -#### GET -##### Description - -Retrieve paginated list of saved messages for a completion application. - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| last_id | query | Last message ID for pagination | No | string | -| limit | query | Number of messages to return (1-100) | No | integer | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | -| 400 | Bad Request - Not a completion app | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | App Not Found | -| 500 | Internal Server Error | - -#### POST -##### Description - -Save a specific message for later reference. - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| message_id | query | Message UUID to save | Yes | string | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Message saved successfully | [ResultResponse](#resultresponse) | -| 400 | Bad Request - Not a completion app | | -| 401 | Unauthorized | | -| 403 | Forbidden | | -| 404 | Message Not Found | | -| 500 | Internal Server Error | | - -### /saved-messages/{message_id} - -#### DELETE -##### Description - -Remove a message from saved messages. - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| message_id | path | Message UUID to delete | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 204 | Message removed successfully | -| 400 | Bad Request - Not a completion app | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Message Not Found | -| 500 | Internal Server Error | - -### /site - -#### GET -##### Summary - -Retrieve app site info - -##### Description - -Retrieve app site information and configuration. - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | -| 400 | Bad Request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | App Not Found | -| 500 | Internal Server Error | - -### /system-features - -#### GET -##### Summary - -Get system feature flags and configuration - -##### Description - -Get system feature flags and configuration -Returns the current system feature flags and configuration -that control various functionalities across the platform. - -Returns: - dict: System feature configuration object - -This endpoint is akin to the `SystemFeatureApi` endpoint in api/controllers/console/feature.py, -except it is intended for use by the web app, instead of the console dashboard. - -NOTE: This endpoint is unauthenticated by design, as it provides system features -data required for webapp initialization. - -Authentication would create circular dependency (can't authenticate without webapp loading). - -Only non-sensitive configuration data should be returned by this endpoint. - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | System features retrieved successfully | [SystemFeatureModel](#systemfeaturemodel) | -| 500 | Internal server error | | - -### /text-to-audio - -#### POST -##### Summary - -Convert text to audio - -##### Description - -Convert text to audio using text-to-speech service. - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [TextToAudioPayload](#texttoaudiopayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | -| 400 | Bad Request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal Server Error | - -### /webapp/access-mode - -#### GET -##### Description - -Retrieve the access mode for a web application (public or restricted). - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| appCode | query | Application code | No | string | -| appId | query | Application ID | No | string | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | [AccessModeResponse](#accessmoderesponse) | -| 400 | Bad Request | | -| 500 | Internal Server Error | | - -### /webapp/permission - -#### GET -##### Description - -Check if user has permission to access a web application. - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| appId | query | Application ID | Yes | string | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | [BooleanResultResponse](#booleanresultresponse) | -| 400 | Bad Request | | -| 401 | Unauthorized | | -| 500 | Internal Server Error | | - -### /workflows/run - -#### POST -##### Summary - -Run workflow - -##### Description - -Execute a workflow with provided inputs and files. - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [WorkflowRunPayload](#workflowrunpayload) | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | -| 400 | Bad Request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | App Not Found | -| 500 | Internal Server Error | - -### /workflows/tasks/{task_id}/stop - -#### POST -##### Summary - -Stop workflow task - -##### Description - -Stop a running workflow task. - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| task_id | path | Task ID to stop | Yes | string | - -##### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | [SimpleResultResponse](#simpleresultresponse) | -| 400 | Bad Request | | -| 401 | Unauthorized | | -| 403 | Forbidden | | -| 404 | Task Not Found | | -| 500 | Internal Server Error | | - ---- -## default -Default namespace - -### /workflow/{task_id}/events - -#### GET -##### Summary - -Get workflow execution events stream after resume - -##### Description - -GET /api/workflow//events - -Returns Server-Sent Events stream. - -##### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| task_id | path | | Yes | string | - -##### Responses - -| Code | Description | -| ---- | ----------- | -| 200 | Success | - ---- -### Models - -#### AccessModeResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| accessMode | string | | Yes | - -#### AccessTokenData - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| access_token | string | | Yes | - -#### AccessTokenResultResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| data | [AccessTokenData](#accesstokendata) | | Yes | -| result | string | | Yes | - -#### AppAccessModeQuery - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| appCode | string | Application code | No | -| appId | string | Application ID | No | - -#### BooleanResultResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| result | boolean | | Yes | - -#### BrandingModel - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| application_title | string | | Yes | -| enabled | boolean | | Yes | -| favicon | string | | Yes | -| login_page_logo | string | | Yes | -| workspace_logo | string | | Yes | - -#### ChatMessagePayload - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| conversation_id | string | Conversation ID | No | -| files | [ object ] | Files to be processed | No | -| inputs | object | Input variables for the chat | Yes | -| parent_message_id | string | Parent message ID | No | -| query | string | User query/message | Yes | -| response_mode | string | Response mode: blocking or streaming
*Enum:* `"blocking"`, `"streaming"` | No | -| retriever_from | string | Source of retriever | No | - -#### CompletionMessagePayload - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| files | [ object ] | Files to be processed | No | -| inputs | object | Input variables for the completion | Yes | -| query | string | Query text for completion | No | -| response_mode | string | Response mode: blocking or streaming
*Enum:* `"blocking"`, `"streaming"` | No | -| retriever_from | string | Source of retriever | No | - -#### ConversationListQuery - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| last_id | string | | No | -| limit | integer | | No | -| pinned | boolean | | No | -| sort_by | string | *Enum:* `"-created_at"`, `"-updated_at"`, `"created_at"`, `"updated_at"` | No | - -#### ConversationRenamePayload - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| auto_generate | boolean | | No | -| name | string | | No | - -#### EmailCodeLoginSendPayload - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| email | string | | Yes | -| language | string | | No | - -#### EmailCodeLoginVerifyPayload - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| code | string | | Yes | -| email | string | | Yes | -| token | string | | Yes | - -#### FileResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| conversation_id | string | | No | -| created_at | integer | | No | -| created_by | string | | No | -| extension | string | | No | -| file_key | string | | No | -| id | string | | Yes | -| mime_type | string | | No | -| name | string | | Yes | -| original_url | string | | No | -| preview_url | string | | No | -| reference | string | | No | -| size | integer | | Yes | -| source_url | string | | No | -| tenant_id | string | | No | -| user_id | string | | No | - -#### FileWithSignedUrl - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| created_at | integer | | Yes | -| created_by | string | | Yes | -| extension | string | | Yes | -| id | string | | Yes | -| mime_type | string | | Yes | -| name | string | | Yes | -| size | integer | | Yes | -| url | string | | Yes | - -#### ForgotPasswordCheckPayload - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| code | string | | Yes | -| email | string | | Yes | -| token | string | | Yes | - -#### ForgotPasswordResetPayload - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| new_password | string | | Yes | -| password_confirm | string | | Yes | -| token | string | | Yes | - -#### ForgotPasswordSendPayload - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| email | string | | Yes | -| language | string | | No | - -#### HumanInputFileUploadFormPayload - -Parsed multipart form fields for HITL uploads. - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| url | string (uri) | Remote file URL | No | - -#### HumanInputUploadTokenResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| expires_at | integer | | Yes | -| upload_token | string | | Yes | - -#### LicenseLimitationModel - -- enabled: whether this limit is enforced -- size: current usage count -- limit: maximum allowed count; 0 means unlimited - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| enabled | boolean | Whether this limit is currently active | Yes | -| limit | integer | Maximum number of resources allowed; 0 means no limit | Yes | -| size | integer | Number of resources already consumed | Yes | - -#### LicenseModel - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| expired_at | string | | Yes | -| status | [LicenseStatus](#licensestatus) | | Yes | -| workspaces | [LicenseLimitationModel](#licenselimitationmodel) | | Yes | - -#### LicenseStatus - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| LicenseStatus | string | | | - -#### LoginPayload - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| email | string | | Yes | -| password | string | | Yes | - -#### LoginStatusResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| app_logged_in | boolean | | Yes | -| logged_in | boolean | | Yes | - -#### MessageFeedbackPayload - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| content | string | | No | -| rating | string | *Enum:* `"dislike"`, `"like"` | No | - -#### MessageListQuery - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| conversation_id | string | Conversation UUID | Yes | -| first_id | string | First message ID for pagination | No | -| limit | integer | Number of messages to return (1-100) | No | - -#### MessageMoreLikeThisQuery - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| response_mode | string | Response mode
*Enum:* `"blocking"`, `"streaming"` | Yes | - -#### PluginInstallationPermissionModel - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| plugin_installation_scope | [PluginInstallationScope](#plugininstallationscope) | | Yes | -| restrict_to_marketplace_only | boolean | | Yes | - -#### PluginInstallationScope - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| PluginInstallationScope | string | | | - -#### PluginManagerModel - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| enabled | boolean | | Yes | - -#### RemoteFileInfo - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| file_length | integer | | Yes | -| file_type | string | | Yes | - -#### RemoteFileUploadPayload - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| url | string (uri) | Remote file URL | Yes | - -#### ResultResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| result | string | | Yes | - -#### SavedMessageCreatePayload - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| message_id | string | | Yes | - -#### SavedMessageListQuery - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| last_id | string | | No | -| limit | integer | | No | - -#### SimpleResultDataResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| data | string | | Yes | -| result | string | | Yes | - -#### SimpleResultResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| result | string | | Yes | - -#### SuggestedQuestionsResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| data | [ string ] | | Yes | - -#### SystemFeatureModel - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| branding | [BrandingModel](#brandingmodel) | | Yes | -| enable_change_email | boolean | | Yes | -| enable_collaboration_mode | boolean | | Yes | -| enable_creators_platform | boolean | | Yes | -| enable_email_code_login | boolean | | Yes | -| enable_email_password_login | boolean | | Yes | -| enable_explore_banner | boolean | | Yes | -| enable_marketplace | boolean | | Yes | -| enable_social_oauth_login | boolean | | Yes | -| enable_trial_app | boolean | | Yes | -| is_allow_create_workspace | boolean | | Yes | -| is_allow_register | boolean | | Yes | -| is_email_setup | boolean | | Yes | -| license | [LicenseModel](#licensemodel) | | Yes | -| max_plugin_package_size | integer | | Yes | -| plugin_installation_permission | [PluginInstallationPermissionModel](#plugininstallationpermissionmodel) | | Yes | -| plugin_manager | [PluginManagerModel](#pluginmanagermodel) | | Yes | -| sso_enforced_for_signin | boolean | | Yes | -| sso_enforced_for_signin_protocol | string | | Yes | -| webapp_auth | [WebAppAuthModel](#webappauthmodel) | | Yes | - -#### TextToAudioPayload - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| message_id | string | Message ID | No | -| streaming | boolean | Enable streaming response | No | -| text | string | Text to convert to audio | No | -| voice | string | Voice to use for TTS | No | - -#### VerificationTokenResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| email | string | | Yes | -| is_valid | boolean | | Yes | -| token | string | | Yes | - -#### WebAppAuthModel - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| allow_email_code_login | boolean | | Yes | -| allow_email_password_login | boolean | | Yes | -| allow_sso | boolean | | Yes | -| enabled | boolean | | Yes | -| sso_config | [WebAppAuthSSOModel](#webappauthssomodel) | | Yes | - -#### WebAppAuthSSOModel - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| protocol | string | | Yes | - -#### WorkflowRunPayload - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| files | [ object ] | | No | -| inputs | object | | Yes | diff --git a/api/providers/trace/trace-mlflow/src/dify_trace_mlflow/mlflow_trace.py b/api/providers/trace/trace-mlflow/src/dify_trace_mlflow/mlflow_trace.py index 9b9b4f2c15f..8598c579b3c 100644 --- a/api/providers/trace/trace-mlflow/src/dify_trace_mlflow/mlflow_trace.py +++ b/api/providers/trace/trace-mlflow/src/dify_trace_mlflow/mlflow_trace.py @@ -4,7 +4,7 @@ from datetime import datetime, timedelta from typing import Any, cast, override import mlflow -from mlflow.entities import Document, Span, SpanEvent, SpanStatusCode, SpanType +from mlflow.entities import Document, LiveSpan, Span, SpanEvent, SpanStatusCode, SpanType from mlflow.tracing.constant import SpanAttributeKey, TokenUsageKey, TraceMetadataKey from mlflow.tracing.fluent import start_span_no_context, update_current_trace from mlflow.tracing.provider import detach_span_from_context, set_span_in_context @@ -31,6 +31,8 @@ from models.workflow import WorkflowNodeExecutionModel logger = logging.getLogger(__name__) +type SpanAttributes = dict[str, object] + def datetime_to_nanoseconds(dt: datetime | None) -> int | None: """Convert datetime to nanosecond timestamp for MLflow API""" @@ -39,6 +41,32 @@ def datetime_to_nanoseconds(dt: datetime | None) -> int | None: return int(dt.timestamp() * 1_000_000_000) +def _start_span_no_context( + *, + name: str, + span_type: str, + parent_span: LiveSpan | None = None, + inputs: object | None = None, + attributes: SpanAttributes | None = None, + start_time_ns: int | None = None, +) -> LiveSpan: + """Start an MLflow span while preserving structured Dify attributes. + + MLflow 3.11 annotates `start_span_no_context(..., attributes=...)` as `dict[str, str]`, + but the implementation immediately calls `LiveSpan.set_attributes(dict[str, Any])`. + `LiveSpan` JSON-serializes arbitrary values before storing them in OpenTelemetry, and + reserved attributes like `mlflow.chat.tokenUsage` are expected to round-trip as dicts. + """ + return start_span_no_context( + name=name, + span_type=span_type, + parent_span=parent_span, + inputs=inputs, + attributes=cast(dict[str, str] | None, attributes), + start_time_ns=start_time_ns, + ) + + class MLflowDataTrace(BaseTraceInstance): def __init__(self, config: MLflowConfig | DatabricksConfig): super().__init__(config) @@ -119,7 +147,7 @@ class MLflowDataTrace(BaseTraceInstance): if trace_info.query: workflow_inputs["query"] = trace_info.query - workflow_span = start_span_no_context( + workflow_span = _start_span_no_context( name=TraceTaskName.WORKFLOW_TRACE.value, span_type=SpanType.CHAIN, inputs=workflow_inputs, @@ -139,7 +167,7 @@ class MLflowDataTrace(BaseTraceInstance): # Create child spans for workflow nodes for node in self._get_workflow_nodes(trace_info.workflow_run_id): inputs = None - attributes = { + attributes: SpanAttributes = { "node_id": node.id, "node_type": node.node_type, "status": node.status, @@ -157,7 +185,7 @@ class MLflowDataTrace(BaseTraceInstance): if not inputs: inputs = JSON_DICT_ADAPTER.validate_json(node.inputs) if node.inputs else {} - node_span = start_span_no_context( + node_span = _start_span_no_context( name=node.title, span_type=self._get_node_span_type(node.node_type), parent_span=workflow_span, @@ -212,7 +240,7 @@ class MLflowDataTrace(BaseTraceInstance): end_time_ns=datetime_to_nanoseconds(trace_info.end_time), ) - def _parse_llm_inputs_and_attributes(self, node: WorkflowNodeExecutionModel) -> tuple[Any, dict]: + def _parse_llm_inputs_and_attributes(self, node: WorkflowNodeExecutionModel) -> tuple[object, SpanAttributes]: """Parse LLM inputs and attributes from LLM workflow node""" if node.process_data is None: return {}, {} @@ -266,16 +294,16 @@ class MLflowDataTrace(BaseTraceInstance): base_url = os.getenv("FILES_URL", "http://127.0.0.1:5001") file_list.append(f"{base_url}/{message_file_data.url}") - span = start_span_no_context( + span = _start_span_no_context( name=TraceTaskName.MESSAGE_TRACE.value, span_type=SpanType.LLM, - inputs=self._parse_prompts(trace_info.inputs), # type: ignore[arg-type] + inputs=self._parse_prompts(trace_info.inputs), attributes={ - "message_id": trace_info.message_id, # type: ignore[dict-item] + "message_id": trace_info.message_id, "model_provider": trace_info.message_data.model_provider, "model_id": trace_info.message_data.model_id, "conversation_mode": trace_info.conversation_mode, - "file_list": file_list, # type: ignore[dict-item] + "file_list": file_list, "total_price": trace_info.message_data.total_price, **trace_info.metadata, }, @@ -330,15 +358,15 @@ class MLflowDataTrace(BaseTraceInstance): return metadata.get("from_account_id") # type: ignore[return-value] def tool_trace(self, trace_info: ToolTraceInfo): - span = start_span_no_context( + span = _start_span_no_context( name=trace_info.tool_name, span_type=SpanType.TOOL, - inputs=trace_info.tool_inputs, # type: ignore[arg-type] + inputs=trace_info.tool_inputs, attributes={ - "message_id": trace_info.message_id, # type: ignore[dict-item] - "metadata": trace_info.metadata, # type: ignore[dict-item] - "tool_config": trace_info.tool_config, # type: ignore[dict-item] - "tool_parameters": trace_info.tool_parameters, # type: ignore[dict-item] + "message_id": trace_info.message_id, + "metadata": trace_info.metadata, + "tool_config": trace_info.tool_config, + "tool_parameters": trace_info.tool_parameters, }, start_time_ns=datetime_to_nanoseconds(trace_info.start_time), ) @@ -367,13 +395,13 @@ class MLflowDataTrace(BaseTraceInstance): return start_time = trace_info.start_time or trace_info.message_data.created_at - span = start_span_no_context( + span = _start_span_no_context( name=TraceTaskName.MODERATION_TRACE.value, span_type=SpanType.TOOL, inputs=trace_info.inputs or {}, attributes={ - "message_id": trace_info.message_id, # type: ignore[dict-item] - "metadata": trace_info.metadata, # type: ignore[dict-item] + "message_id": trace_info.message_id, + "metadata": trace_info.metadata, }, start_time_ns=datetime_to_nanoseconds(start_time), ) @@ -391,13 +419,13 @@ class MLflowDataTrace(BaseTraceInstance): if trace_info.message_data is None: return - span = start_span_no_context( + span = _start_span_no_context( name=TraceTaskName.DATASET_RETRIEVAL_TRACE.value, span_type=SpanType.RETRIEVER, inputs=trace_info.inputs, attributes={ - "message_id": trace_info.message_id, # type: ignore[dict-item] - "metadata": trace_info.metadata, # type: ignore[dict-item] + "message_id": trace_info.message_id, + "metadata": trace_info.metadata, }, start_time_ns=datetime_to_nanoseconds(trace_info.start_time), ) @@ -410,15 +438,15 @@ class MLflowDataTrace(BaseTraceInstance): start_time = trace_info.start_time or trace_info.message_data.created_at end_time = trace_info.end_time or trace_info.message_data.updated_at - span = start_span_no_context( + span = _start_span_no_context( name=TraceTaskName.SUGGESTED_QUESTION_TRACE.value, span_type=SpanType.TOOL, inputs=trace_info.inputs, attributes={ - "message_id": trace_info.message_id, # type: ignore[dict-item] - "model_provider": trace_info.model_provider, # type: ignore[dict-item] - "model_id": trace_info.model_id, # type: ignore[dict-item] - "total_tokens": trace_info.total_tokens or 0, # type: ignore[dict-item] + "message_id": trace_info.message_id, + "model_provider": trace_info.model_provider, + "model_id": trace_info.model_id, + "total_tokens": trace_info.total_tokens or 0, }, start_time_ns=datetime_to_nanoseconds(start_time), ) @@ -439,11 +467,11 @@ class MLflowDataTrace(BaseTraceInstance): span.end(outputs=trace_info.suggested_question, end_time_ns=datetime_to_nanoseconds(end_time)) def generate_name_trace(self, trace_info: GenerateNameTraceInfo): - span = start_span_no_context( + span = _start_span_no_context( name=TraceTaskName.GENERATE_NAME_TRACE.value, span_type=SpanType.CHAIN, inputs=trace_info.inputs, - attributes={"message_id": trace_info.message_id}, # type: ignore[dict-item] + attributes={"message_id": trace_info.message_id}, start_time_ns=datetime_to_nanoseconds(trace_info.start_time), ) span.end(outputs=trace_info.outputs, end_time_ns=datetime_to_nanoseconds(trace_info.end_time)) diff --git a/api/providers/trace/trace-mlflow/tests/unit_tests/mlflow_trace/test_mlflow_trace.py b/api/providers/trace/trace-mlflow/tests/unit_tests/mlflow_trace/test_mlflow_trace.py index 324f894b252..08c1b52c88f 100644 --- a/api/providers/trace/trace-mlflow/tests/unit_tests/mlflow_trace/test_mlflow_trace.py +++ b/api/providers/trace/trace-mlflow/tests/unit_tests/mlflow_trace/test_mlflow_trace.py @@ -11,6 +11,7 @@ from unittest.mock import MagicMock, patch import pytest from dify_trace_mlflow.config import DatabricksConfig, MLflowConfig from dify_trace_mlflow.mlflow_trace import MLflowDataTrace, datetime_to_nanoseconds +from mlflow.tracing.constant import SpanAttributeKey, TokenUsageKey from core.ops.entities.trace_entity import ( DatasetRetrievalTraceInfo, @@ -361,6 +362,7 @@ class TestWorkflowTrace: assert inputs["query"] == "hello" def test_workflow_with_llm_node(self, trace_instance, mock_tracing, mock_db): + usage = {"prompt_tokens": 5, "completion_tokens": 10, "total_tokens": 15} llm_node = _make_node( node_type=BuiltinNodeTypes.LLM, process_data=json.dumps( @@ -369,7 +371,7 @@ class TestWorkflowTrace: "model_name": "gpt-4", "model_provider": "openai", "finish_reason": "stop", - "usage": {"prompt_tokens": 5, "completion_tokens": 10, "total_tokens": 15}, + "usage": usage, } ), outputs='{"text": "hello world"}', @@ -383,6 +385,14 @@ class TestWorkflowTrace: trace_instance.workflow_trace(_make_workflow_trace_info()) assert mock_tracing["start"].call_count == 2 + node_start_call = mock_tracing["start"].call_args_list[1] + attrs = node_start_call.kwargs["attributes"] + assert attrs[SpanAttributeKey.CHAT_USAGE] == { + TokenUsageKey.INPUT_TOKENS: 5, + TokenUsageKey.OUTPUT_TOKENS: 10, + TokenUsageKey.TOTAL_TOKENS: 15, + } + assert attrs["usage"] == usage node_span.end.assert_called_once() workflow_span.end.assert_called_once() @@ -631,6 +641,27 @@ class TestMessageTrace: assert "http://files.test/path/to/file.png" in attrs["file_list"] assert "existing_file.txt" in attrs["file_list"] + def test_message_trace_preserves_structured_span_attributes(self, trace_instance, mock_tracing, mock_db): + span = MagicMock() + mock_tracing["start"].return_value = span + mock_tracing["set"].return_value = "token" + + trace_info = _make_message_trace_info( + metadata={ + "conversation_id": "c1", + "from_account_id": "a1", + "routing": {"node": "answer", "score": 0.7}, + }, + file_list=["existing_file.txt"], + ) + trace_instance.message_trace(trace_info) + + attrs = mock_tracing["start"].call_args.kwargs["attributes"] + assert attrs["message_id"] == "msg-1" + assert attrs["total_price"] == 0.01 + assert attrs["routing"] == {"node": "answer", "score": 0.7} + assert attrs["file_list"] == ["existing_file.txt"] + def test_message_trace_file_list_none(self, trace_instance, mock_tracing, mock_db): span = MagicMock() mock_tracing["start"].return_value = span diff --git a/api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_service.py b/api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_service.py index 6283dbb986b..ca9494e53fb 100644 --- a/api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_service.py +++ b/api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_service.py @@ -1,7 +1,8 @@ import logging import time import uuid -from collections.abc import Sequence +from collections.abc import Mapping, Sequence +from typing import Any import httpx from httpx import DigestAuth @@ -24,7 +25,7 @@ _tidb_http_client: httpx.Client = get_pooled_http_client( class TidbService: @staticmethod - def extract_qdrant_endpoint(cluster_response: dict) -> str | None: + def extract_qdrant_endpoint(cluster_response: Mapping[str, Any]) -> str | None: """Extract the qdrant endpoint URL from a Get Cluster API response. Reads ``endpoints.public.host`` (e.g. ``gateway01.xx.tidbcloud.com``), diff --git a/api/pyproject.toml b/api/pyproject.toml index 380ad55ee99..8e4ebe4112e 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "resend>=2.27.0,<3.0.0", # Emerging: newer and fast-moving, use compatible pins "fastopenapi[flask]==0.7.0", - "graphon==0.4.0", + "graphon==0.5.1", "httpx-sse==0.4.3", "json-repair==0.59.4", ] @@ -60,6 +60,7 @@ exclude = ["providers/vdb/__pycache__", "providers/trace/__pycache__"] [tool.uv.sources] dify-agent = { path = "../dify-agent", editable = true } +flask-restx = { git = "https://github.com/asukaminato0721/flask-restx", rev = "27758e26f8f740d7525d5039c51a9e524b6e2b68" } dify-vdb-alibabacloud-mysql = { workspace = true } dify-vdb-analyticdb = { workspace = true } dify-vdb-baidu = { workspace = true } diff --git a/api/pyrefly-local-excludes.txt b/api/pyrefly-local-excludes.txt index c97014ebcfe..c9637136d41 100644 --- a/api/pyrefly-local-excludes.txt +++ b/api/pyrefly-local-excludes.txt @@ -1,9 +1,3 @@ -core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py -core/llm_generator/llm_generator.py -providers/trace/trace-mlflow/src/dify_trace_mlflow/mlflow_trace.py -core/prompt/utils/prompt_message_util.py -core/rag/retrieval/dataset_retrieval.py -extensions/ext_celery.py providers/vdb/vdb-alibabacloud-mysql/tests/unit_tests/test_alibabacloud_mysql_factory.py providers/vdb/vdb-alibabacloud-mysql/tests/unit_tests/test_alibabacloud_mysql_vector.py providers/vdb/vdb-analyticdb/src/dify_vdb_analyticdb/analyticdb_vector_openapi.py @@ -59,16 +53,3 @@ providers/vdb/vdb-vikingdb/src/dify_vdb_vikingdb/vikingdb_vector.py providers/vdb/vdb-vikingdb/tests/unit_tests/test_vikingdb_vector.py providers/vdb/vdb-weaviate/src/dify_vdb_weaviate/weaviate_vector.py providers/vdb/vdb-weaviate/tests/unit_tests/test_weaviate_vector.py -core/rag/extractor/watercrawl/provider.py -core/rag/extractor/word_extractor.py -core/rag/index_processor/processor/paragraph_index_processor.py -core/rag/index_processor/processor/parent_child_index_processor.py -core/rag/index_processor/processor/qa_index_processor.py -core/tools/mcp_tool/provider.py -core/tools/plugin_tool/provider.py -core/tools/workflow_as_tool/provider.py -extensions/storage/huawei_obs_storage.py -libs/gmpy2_pkcs10aep_cipher.py -services/audio_service.py -services/document_indexing_proxy/document_indexing_task_proxy.py -services/document_indexing_proxy/duplicate_document_indexing_task_proxy.py diff --git a/api/repositories/api_workflow_run_repository.py b/api/repositories/api_workflow_run_repository.py index 72b38e79068..2659e550552 100644 --- a/api/repositories/api_workflow_run_repository.py +++ b/api/repositories/api_workflow_run_repository.py @@ -35,6 +35,7 @@ Example: """ from collections.abc import Callable, Sequence +from dataclasses import dataclass from datetime import datetime from typing import Protocol, TypedDict @@ -65,6 +66,21 @@ class RunsWithRelatedCountsDict(TypedDict): pause_reasons: int +@dataclass(frozen=True) +class WorkflowRunCleanupRef: + """ + Lightweight workflow run reference for retention cleanup scans. + + Cleanup jobs use this DTO when they only need cursor, tenant eligibility, and run-id deletion data. Keeping the + query shape explicit prevents free-plan cleanup from hydrating full WorkflowRun models for rows that may be skipped + after billing checks. + """ + + id: str + tenant_id: str + created_at: datetime + + class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol): """ Protocol for service-layer WorkflowRun repository operations. @@ -286,6 +302,36 @@ class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol): """ ... + def get_cleanup_refs_batch_by_time_range( + self, + start_from: datetime | None, + end_before: datetime, + last_seen: tuple[datetime, str] | None, + batch_size: int, + run_types: Sequence[WorkflowType] | None = None, + tenant_ids: Sequence[str] | None = None, + workflow_ids: Sequence[str] | None = None, + upper_bound: tuple[datetime, str] | None = None, + ) -> Sequence[WorkflowRunCleanupRef]: + """ + Fetch lightweight ended workflow run refs in a time window for cleanup batching. + + Args: + start_from: Optional inclusive lower time boundary. + end_before: Exclusive upper time boundary. + last_seen: Optional exclusive `(created_at, id)` cursor lower bound. + batch_size: Maximum number of refs to return. + run_types: Optional workflow type filter. + tenant_ids: Optional tenant filter. + workflow_ids: Optional workflow ID filter. + upper_bound: Optional inclusive `(created_at, id)` cursor upper bound. Cleanup uses this for a second, + tenant-filtered target query that must stay within the candidate page high-water cursor. + + Returns: + Ordered lightweight cleanup refs containing only id, tenant_id, and created_at. + """ + ... + def get_archived_run_ids( self, session: Session, @@ -370,6 +416,19 @@ class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol): """ ... + def delete_runs_with_related_by_ids( + self, + run_ids: Sequence[str], + delete_node_executions: Callable[[Session, Sequence[str]], tuple[int, int]] | None = None, + delete_trigger_logs: Callable[[Session, Sequence[str]], int] | None = None, + ) -> RunsWithRelatedCountsDict: + """ + Delete workflow runs and cleanup-owned related records by workflow run IDs. + + This mirrors delete_runs_with_related() for cleanup callers that do not need full WorkflowRun models. + """ + ... + def get_app_logs_by_run_id( self, session: Session, @@ -417,6 +476,19 @@ class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol): """ ... + def count_runs_with_related_by_ids( + self, + run_ids: Sequence[str], + count_node_executions: Callable[[Session, Sequence[str]], tuple[int, int]] | None = None, + count_trigger_logs: Callable[[Session, Sequence[str]], int] | None = None, + ) -> RunsWithRelatedCountsDict: + """ + Count workflow runs and cleanup-owned related records by workflow run IDs. + + This mirrors count_runs_with_related() for dry-run cleanup callers that do not need full WorkflowRun models. + """ + ... + def create_workflow_pause( self, workflow_run_id: str, diff --git a/api/repositories/sqlalchemy_api_workflow_run_repository.py b/api/repositories/sqlalchemy_api_workflow_run_repository.py index cbc9d03e5eb..98c605f0a17 100644 --- a/api/repositories/sqlalchemy_api_workflow_run_repository.py +++ b/api/repositories/sqlalchemy_api_workflow_run_repository.py @@ -44,7 +44,11 @@ from libs.time_parser import get_time_threshold from models.enums import WorkflowRunTriggeredFrom from models.human_input import HumanInputForm, HumanInputFormRecipient from models.workflow import WorkflowAppLog, WorkflowArchiveLog, WorkflowPause, WorkflowPauseReason, WorkflowRun -from repositories.api_workflow_run_repository import APIWorkflowRunRepository, RunsWithRelatedCountsDict +from repositories.api_workflow_run_repository import ( + APIWorkflowRunRepository, + RunsWithRelatedCountsDict, + WorkflowRunCleanupRef, +) from repositories.entities.workflow_pause import WorkflowPauseEntity from repositories.types import ( AverageInteractionStats, @@ -420,6 +424,71 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): return session.scalars(stmt).all() + @override + def get_cleanup_refs_batch_by_time_range( + self, + start_from: datetime | None, + end_before: datetime, + last_seen: tuple[datetime, str] | None, + batch_size: int, + run_types: Sequence[WorkflowType] | None = None, + tenant_ids: Sequence[str] | None = None, + workflow_ids: Sequence[str] | None = None, + upper_bound: tuple[datetime, str] | None = None, + ) -> Sequence[WorkflowRunCleanupRef]: + """ + Fetch lightweight ended workflow run refs in a time window for cleanup batching. + + The optional upper_bound is inclusive and is paired with last_seen by free-plan cleanup so a second, + tenant-filtered target query stays within the candidate page already checked against billing. + """ + with self._session_maker() as session: + stmt = ( + select(WorkflowRun.id, WorkflowRun.tenant_id, WorkflowRun.created_at) + .where( + WorkflowRun.created_at < end_before, + WorkflowRun.status.in_(WorkflowExecutionStatus.ended_values()), + ) + .order_by(WorkflowRun.created_at.asc(), WorkflowRun.id.asc()) + .limit(batch_size) + ) + if run_types is not None: + if not run_types: + return [] + stmt = stmt.where(WorkflowRun.type.in_(run_types)) + + if start_from: + stmt = stmt.where(WorkflowRun.created_at >= start_from) + + if tenant_ids: + stmt = stmt.where(WorkflowRun.tenant_id.in_(tenant_ids)) + + if workflow_ids: + stmt = stmt.where(WorkflowRun.workflow_id.in_(workflow_ids)) + + if last_seen: + stmt = stmt.where( + tuple_(WorkflowRun.created_at, WorkflowRun.id) + > tuple_( + sa.literal(last_seen[0], type_=sa.DateTime()), + sa.literal(last_seen[1], type_=WorkflowRun.id.type), + ) + ) + + if upper_bound: + stmt = stmt.where( + tuple_(WorkflowRun.created_at, WorkflowRun.id) + <= tuple_( + sa.literal(upper_bound[0], type_=sa.DateTime()), + sa.literal(upper_bound[1], type_=WorkflowRun.id.type), + ) + ) + + return [ + WorkflowRunCleanupRef(id=run_id, tenant_id=tenant_id, created_at=created_at) + for run_id, tenant_id, created_at in session.execute(stmt).all() + ] + @override def get_archived_run_ids( self, @@ -530,6 +599,56 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): "pause_reasons": pause_reasons_deleted, } + @override + def delete_runs_with_related_by_ids( + self, + run_ids: Sequence[str], + delete_node_executions: Callable[[Session, Sequence[str]], tuple[int, int]] | None = None, + delete_trigger_logs: Callable[[Session, Sequence[str]], int] | None = None, + ) -> RunsWithRelatedCountsDict: + if not run_ids: + return self._empty_runs_with_related_counts() + + run_ids = list(run_ids) + with self._session_maker() as session: + if delete_node_executions: + node_executions_deleted, offloads_deleted = delete_node_executions(session, run_ids) + else: + node_executions_deleted, offloads_deleted = 0, 0 + + app_logs_result = session.execute(delete(WorkflowAppLog).where(WorkflowAppLog.workflow_run_id.in_(run_ids))) + app_logs_deleted = cast(CursorResult, app_logs_result).rowcount or 0 + + pause_stmt = select(WorkflowPause.id).where(WorkflowPause.workflow_run_id.in_(run_ids)) + pause_ids = session.scalars(pause_stmt).all() + pause_reasons_deleted = 0 + pauses_deleted = 0 + + if pause_ids: + pause_reasons_result = session.execute( + delete(WorkflowPauseReason).where(WorkflowPauseReason.pause_id.in_(pause_ids)) + ) + pause_reasons_deleted = cast(CursorResult, pause_reasons_result).rowcount or 0 + pauses_result = session.execute(delete(WorkflowPause).where(WorkflowPause.id.in_(pause_ids))) + pauses_deleted = cast(CursorResult, pauses_result).rowcount or 0 + + trigger_logs_deleted = delete_trigger_logs(session, run_ids) if delete_trigger_logs else 0 + + runs_result = session.execute(delete(WorkflowRun).where(WorkflowRun.id.in_(run_ids))) + runs_deleted = cast(CursorResult, runs_result).rowcount or 0 + + session.commit() + + return { + "runs": runs_deleted, + "node_executions": node_executions_deleted, + "offloads": offloads_deleted, + "app_logs": app_logs_deleted, + "trigger_logs": trigger_logs_deleted, + "pauses": pauses_deleted, + "pause_reasons": pause_reasons_deleted, + } + @override def get_app_logs_by_run_id( self, @@ -711,6 +830,72 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): "pause_reasons": int(pause_reasons_count), } + @override + def count_runs_with_related_by_ids( + self, + run_ids: Sequence[str], + count_node_executions: Callable[[Session, Sequence[str]], tuple[int, int]] | None = None, + count_trigger_logs: Callable[[Session, Sequence[str]], int] | None = None, + ) -> RunsWithRelatedCountsDict: + if not run_ids: + return self._empty_runs_with_related_counts() + + run_ids = list(run_ids) + with self._session_maker() as session: + if count_node_executions: + node_executions_count, offloads_count = count_node_executions(session, run_ids) + else: + node_executions_count, offloads_count = 0, 0 + + runs_count = ( + session.scalar(select(func.count()).select_from(WorkflowRun).where(WorkflowRun.id.in_(run_ids))) or 0 + ) + app_logs_count = ( + session.scalar( + select(func.count()).select_from(WorkflowAppLog).where(WorkflowAppLog.workflow_run_id.in_(run_ids)) + ) + or 0 + ) + + pause_ids = session.scalars( + select(WorkflowPause.id).where(WorkflowPause.workflow_run_id.in_(run_ids)) + ).all() + pauses_count = len(pause_ids) + pause_reasons_count = 0 + if pause_ids: + pause_reasons_count = ( + session.scalar( + select(func.count()) + .select_from(WorkflowPauseReason) + .where(WorkflowPauseReason.pause_id.in_(pause_ids)) + ) + or 0 + ) + + trigger_logs_count = count_trigger_logs(session, run_ids) if count_trigger_logs else 0 + + return { + "runs": int(runs_count), + "node_executions": node_executions_count, + "offloads": offloads_count, + "app_logs": int(app_logs_count), + "trigger_logs": trigger_logs_count, + "pauses": pauses_count, + "pause_reasons": int(pause_reasons_count), + } + + @staticmethod + def _empty_runs_with_related_counts() -> RunsWithRelatedCountsDict: + return { + "runs": 0, + "node_executions": 0, + "offloads": 0, + "app_logs": 0, + "trigger_logs": 0, + "pauses": 0, + "pause_reasons": 0, + } + @override def create_workflow_pause( self, diff --git a/api/schedule/check_upgradable_plugin_task.py b/api/schedule/check_upgradable_plugin_task.py index cf223f6e9e9..f19a2bf18d8 100644 --- a/api/schedule/check_upgradable_plugin_task.py +++ b/api/schedule/check_upgradable_plugin_task.py @@ -73,6 +73,7 @@ def check_upgradable_plugin_task(): strategy.upgrade_mode, strategy.exclude_plugins, strategy.include_plugins, + strategy.category, ) # Only sleep if batch_interval_time > 0.0001 AND current batch is not the last one diff --git a/api/services/account_service.py b/api/services/account_service.py index 1ab5fbd4507..e39d13a3929 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -70,6 +70,7 @@ from services.errors.account import ( ) from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError from services.feature_service import FeatureService +from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService from tasks.delete_account_task import delete_account_task from tasks.mail_account_deletion_task import send_account_deletion_verification_code from tasks.mail_change_mail_task import ( @@ -263,6 +264,7 @@ class AccountService: account.set_tenant_id(available_ta.tenant_id) available_ta.current = True + available_ta.last_opened_at = naive_utc_now() db.session.commit() AccountService._refresh_account_last_active(account) @@ -1167,15 +1169,17 @@ class TenantService: db.session.add(tenant) db.session.commit() - plugin_upgrade_strategy = TenantPluginAutoUpgradeStrategy( - tenant_id=tenant.id, - strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY, - upgrade_time_of_day=0, - upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE, - exclude_plugins=[], - include_plugins=[], - ) - db.session.add(plugin_upgrade_strategy) + for category in TenantPluginAutoUpgradeStrategy.PluginCategory: + plugin_upgrade_strategy = TenantPluginAutoUpgradeStrategy( + tenant_id=tenant.id, + category=category, + strategy_setting=PluginAutoUpgradeService.default_strategy_setting_for_category(category), + upgrade_time_of_day=PluginAutoUpgradeService.default_upgrade_time_of_day(tenant.id), + upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE, + exclude_plugins=[], + include_plugins=[], + ) + db.session.add(plugin_upgrade_strategy) db.session.commit() tenant.encrypt_public_key = generate_key_pair(tenant.id) @@ -1447,6 +1451,7 @@ class TenantService: .values(current=False) ) tenant_account_join.current = True + tenant_account_join.last_opened_at = naive_utc_now() # Set the current tenant for the account account.set_tenant_id(tenant_account_join.tenant_id) db.session.commit() diff --git a/api/services/agent/agent_soul_state.py b/api/services/agent/agent_soul_state.py new file mode 100644 index 00000000000..dfc0a0335e4 --- /dev/null +++ b/api/services/agent/agent_soul_state.py @@ -0,0 +1,6 @@ +from models.agent_config_entities import AgentSoulConfig + + +def agent_soul_has_model(agent_soul: AgentSoulConfig) -> bool: + """Return whether the Agent Soul has the minimum model config required for runtime.""" + return agent_soul.model is not None diff --git a/api/services/agent/composer_service.py b/api/services/agent/composer_service.py index 628c93ec389..16ab3627929 100644 --- a/api/services/agent/composer_service.py +++ b/api/services/agent/composer_service.py @@ -1,7 +1,8 @@ import logging +import uuid from typing import Any -from sqlalchemy import func, select +from sqlalchemy import func, or_, select from sqlalchemy.exc import IntegrityError from extensions.ext_database import db @@ -11,6 +12,7 @@ from models.agent import ( AgentConfigRevision, AgentConfigRevisionOperation, AgentConfigSnapshot, + AgentDriveFile, AgentKind, AgentScope, AgentSource, @@ -19,14 +21,21 @@ from models.agent import ( WorkflowAgentNodeBinding, ) from models.agent_config_entities import ( + AgentFileRefConfig, DeclaredOutputConfig, ) from models.agent_config_entities import ( effective_declared_outputs as _effective_declared_outputs, ) from models.workflow import Workflow +from services.agent.agent_soul_state import agent_soul_has_model from services.agent.composer_validator import ComposerConfigValidator -from services.agent.errors import AgentNameConflictError, AgentNotFoundError, AgentVersionNotFoundError +from services.agent.errors import ( + AgentNameConflictError, + AgentNotFoundError, + AgentVersionNotFoundError, + InvalidComposerConfigError, +) from services.entities.agent_entities import ( AgentSoulConfig, ComposerCandidatesResponse, @@ -43,6 +52,27 @@ _DRAFT_WORKFLOW_VERSION = "draft" logger = logging.getLogger(__name__) +def _backfill_cli_tool_ids(agent_soul: AgentSoulConfig | None) -> None: + """Mint stable ids for CLI tools that predate the id field (ENG-616). + + `[§cli_tool:§]` mentions resolve by id so renames never break references; + the frontend mints ids for new entries, and save backfills legacy ones. Runs + before validation so duplicate-id checks see the final state. Save-only — the + validate endpoint must not mutate the payload. + """ + if agent_soul is None: + return + seen_ids = {cli_tool.id for cli_tool in agent_soul.tools.cli_tools if cli_tool.id} + for cli_tool in agent_soul.tools.cli_tools: + if cli_tool.id: + continue + minted = uuid.uuid4().hex[:12] + while minted in seen_ids: + minted = uuid.uuid4().hex[:12] + cli_tool.id = minted + seen_ids.add(minted) + + class AgentComposerService: @classmethod def load_workflow_composer(cls, *, tenant_id: str, app_id: str, node_id: str) -> dict[str, Any]: @@ -52,10 +82,15 @@ class AgentComposerService: return cls._empty_workflow_state(app_id=app_id, workflow_id=workflow.id, node_id=node_id) agent = cls._get_agent_if_present(tenant_id=tenant_id, agent_id=binding.agent_id) + version_id = ( + agent.active_config_snapshot_id + if agent and binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT + else binding.current_snapshot_id + ) version = cls._get_version_if_present( tenant_id=tenant_id, agent_id=agent.id if agent else None, - version_id=binding.current_snapshot_id, + version_id=version_id, ) return cls._serialize_workflow_state(binding=binding, agent=agent, version=version) @@ -66,10 +101,34 @@ class AgentComposerService: if payload.variant != ComposerVariant.WORKFLOW: raise ValueError("Workflow composer endpoint only accepts workflow variant") + _backfill_cli_tool_ids(payload.agent_soul) ComposerConfigValidator.validate_save_payload(payload) workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id) binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id) + # ENG-623 §4.4: drive-backed refs must point at real drive rows before the + # soul is persisted. Only strategies that write the soul onto an *existing* + # agent are checked — new-agent strategies create a fresh (empty) drive, so + # any carried drive key would be flagged on the next save instead. + if ( + payload.agent_soul is not None + and binding is not None + and binding.agent_id + and payload.save_strategy + in ( + ComposerSaveStrategy.NODE_JOB_ONLY, + ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION, + ComposerSaveStrategy.SAVE_AS_NEW_VERSION, + ) + and ( + payload.save_strategy != ComposerSaveStrategy.NODE_JOB_ONLY + or binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT + ) + ): + cls._require_drive_refs_resolved( + tenant_id=tenant_id, agent_id=binding.agent_id, agent_soul=payload.agent_soul + ) + match payload.save_strategy: case ComposerSaveStrategy.NODE_JOB_ONLY: binding = cls._save_node_job_only( @@ -106,10 +165,15 @@ class AgentComposerService: db.session.commit() agent = cls._get_agent_if_present(tenant_id=tenant_id, agent_id=binding.agent_id) + version_id = ( + agent.active_config_snapshot_id + if agent and binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT + else binding.current_snapshot_id + ) version = cls._get_version_if_present( tenant_id=tenant_id, agent_id=agent.id if agent else None, - version_id=binding.current_snapshot_id, + version_id=version_id, ) state = cls._serialize_workflow_state(binding=binding, agent=agent, version=version) state["validation"] = cls.collect_validation_findings(tenant_id=tenant_id, payload=payload) @@ -150,6 +214,7 @@ class AgentComposerService: ) -> dict[str, Any]: if payload.variant != ComposerVariant.AGENT_APP: raise ValueError("Agent App composer endpoint only accepts agent_app variant") + _backfill_cli_tool_ids(payload.agent_soul) ComposerConfigValidator.validate_save_payload(payload) if payload.agent_soul is None: raise ValueError("agent_soul is required") @@ -185,6 +250,9 @@ class AgentComposerService: db.session.rollback() raise AgentNameConflictError() from exc + # ENG-623 §4.4: dangling drive-backed refs are rejected before persisting. + cls._require_drive_refs_resolved(tenant_id=tenant_id, agent_id=agent.id, agent_soul=payload.agent_soul) + if payload.save_strategy == ComposerSaveStrategy.SAVE_AS_NEW_VERSION or not agent.active_config_snapshot_id: version = cls._create_config_version( tenant_id=tenant_id, @@ -195,6 +263,7 @@ class AgentComposerService: version_note=payload.version_note, ) agent.active_config_snapshot_id = version.id + agent.active_config_has_model = agent_soul_has_model(payload.agent_soul) else: current_snapshot = cls._require_version( tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id @@ -207,6 +276,7 @@ class AgentComposerService: version_note=payload.version_note, ) agent.active_config_snapshot_id = version.id + agent.active_config_has_model = agent_soul_has_model(payload.agent_soul) agent.updated_by = account_id db.session.commit() @@ -215,8 +285,18 @@ class AgentComposerService: return state @classmethod - def collect_validation_findings(cls, *, tenant_id: str, payload: ComposerSavePayload) -> dict[str, Any]: - """ENG-617 soft findings, with DB-backed dataset existence for placeholders.""" + def collect_validation_findings( + cls, + *, + tenant_id: str, + payload: ComposerSavePayload, + agent_id: str | None = None, + ) -> dict[str, Any]: + """ENG-617 soft findings, with DB-backed dataset existence for placeholders. + + With ``agent_id`` the drive-backed skill/file refs are also checked against + the agent drive (ENG-623 §4.4) and dangling ones surface as warnings. + """ from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions mentioned_ids: set[str] = set() @@ -229,7 +309,242 @@ class AgentComposerService: existing_dataset_ids: set[str] | None = None if mentioned_ids: existing_dataset_ids = set(cls._dataset_rows(tenant_id=tenant_id, dataset_ids=sorted(mentioned_ids))) - return ComposerConfigValidator.collect_soft_findings(payload, existing_dataset_ids=existing_dataset_ids) + findings = ComposerConfigValidator.collect_soft_findings(payload, existing_dataset_ids=existing_dataset_ids) + if agent_id and payload.agent_soul is not None: + findings["warnings"].extend( + cls._drive_ref_findings(tenant_id=tenant_id, agent_id=agent_id, agent_soul=payload.agent_soul) + ) + return findings + + @classmethod + def remove_drive_refs( + cls, + *, + tenant_id: str, + agent_id: str, + account_id: str, + skill_slug: str | None = None, + file_key: str | None = None, + app_id: str | None = None, + node_id: str | None = None, + ) -> str | None: + """Drop the soul refs backed by a drive skill/file before the drive rows go. + + Soul-first ordering (ENG-625 D5): a mid-failure leaves harmless orphan KV + rows that an idempotent DELETE retry cleans, instead of a soul ref that + keeps failing dangling-ref validation. Returns the new config version id, + or ``None`` when the soul held no matching ref (idempotent re-delete). + """ + if (skill_slug is None) == (file_key is None): + raise ValueError("remove_drive_refs requires exactly one of skill_slug or file_key") + agent = db.session.scalar(select(Agent).where(Agent.tenant_id == tenant_id, Agent.id == agent_id).limit(1)) + if agent is None or not agent.active_config_snapshot_id: + return None + current_snapshot = cls._require_version( + tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id + ) + agent_soul = AgentSoulConfig.model_validate(current_snapshot.config_snapshot_dict) + + removed_display: str | None = None + if skill_slug is not None: + kept_skills = [] + for skill in agent_soul.skills_files.skills: + slug = (skill.skill_md_key or "").split("/", 1)[0] or (skill.path or "").strip("/") + if slug == skill_slug: + removed_display = skill.name or skill.id or skill_slug + continue + kept_skills.append(skill) + if removed_display is None: + return None + agent_soul.skills_files.skills = kept_skills + note = f"Removed skill '{removed_display}' from the drive." + else: + kept_files = [] + for file in agent_soul.skills_files.files: + if file.drive_key == file_key: + removed_display = file.name or file.drive_key + continue + kept_files.append(file) + if removed_display is None: + return None + agent_soul.skills_files.files = kept_files + note = f"Removed file '{removed_display}' from the drive." + + version = cls._update_current_version( + current_snapshot=current_snapshot, + account_id=account_id, + agent_soul=agent_soul, + operation=AgentConfigRevisionOperation.SAVE_CURRENT_VERSION, + version_note=note, + ) + agent.active_config_snapshot_id = version.id + agent.updated_by = account_id + cls._sync_draft_binding_snapshot( + tenant_id=tenant_id, + app_id=app_id, + node_id=node_id, + agent_id=agent_id, + snapshot_id=version.id, + account_id=account_id, + ) + db.session.commit() + return version.id + + @classmethod + def add_drive_file_ref( + cls, + *, + tenant_id: str, + agent_id: str, + account_id: str, + file_ref: AgentFileRefConfig, + app_id: str | None = None, + node_id: str | None = None, + ) -> str | None: + """Add or replace one drive-backed file ref in the active Agent Soul. + + ``POST /agent/files`` is an ADD FILE user action, not just a low-level + drive commit. The committed file must be present in ``skills_files.files`` + because runtime ``dify.drive`` is built from the active Agent Soul. + """ + if not file_ref.drive_key: + raise ValueError("file_ref.drive_key is required") + agent = db.session.scalar(select(Agent).where(Agent.tenant_id == tenant_id, Agent.id == agent_id).limit(1)) + if agent is None or not agent.active_config_snapshot_id: + return None + current_snapshot = cls._require_version( + tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id + ) + agent_soul = AgentSoulConfig.model_validate(current_snapshot.config_snapshot_dict) + kept_files = [item for item in agent_soul.skills_files.files if item.drive_key != file_ref.drive_key] + kept_files.append(file_ref) + agent_soul.skills_files.files = kept_files + + display = file_ref.name or file_ref.drive_key + version = cls._update_current_version( + current_snapshot=current_snapshot, + account_id=account_id, + agent_soul=agent_soul, + operation=AgentConfigRevisionOperation.SAVE_CURRENT_VERSION, + version_note=f"Added file '{display}' to the drive.", + ) + agent.active_config_snapshot_id = version.id + agent.active_config_has_model = agent_soul_has_model(agent_soul) + agent.updated_by = account_id + cls._sync_draft_binding_snapshot( + tenant_id=tenant_id, + app_id=app_id, + node_id=node_id, + agent_id=agent_id, + snapshot_id=version.id, + account_id=account_id, + ) + db.session.commit() + return version.id + + @classmethod + def resolve_bound_agent_id(cls, *, tenant_id: str, app_id: str) -> str | None: + """The Agent App's bound roster agent id, if any (validate-endpoint context).""" + return db.session.scalar( + select(Agent.id) + .where( + Agent.tenant_id == tenant_id, + Agent.app_id == app_id, + Agent.scope == AgentScope.ROSTER, + Agent.status == AgentStatus.ACTIVE, + ) + .order_by(Agent.created_at.desc()) + .limit(1) + ) + + @classmethod + def resolve_workflow_node_agent_id(cls, *, tenant_id: str, app_id: str, node_id: str) -> str | None: + """The draft workflow node binding's agent id, if any (validate-endpoint context).""" + try: + workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id) + except ValueError: + return None + binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id) + return binding.agent_id if binding else None + + @classmethod + def _sync_draft_binding_snapshot( + cls, + *, + tenant_id: str, + app_id: str | None, + node_id: str | None, + agent_id: str, + snapshot_id: str, + account_id: str, + ) -> None: + """Keep workflow node bindings on the new active snapshot after direct drive edits.""" + if not app_id or not node_id: + return + try: + workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id) + except ValueError: + return + binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id) + if binding is None or binding.agent_id != agent_id: + return + binding.current_snapshot_id = snapshot_id + binding.updated_by = account_id + + @classmethod + def _drive_ref_findings( + cls, + *, + tenant_id: str, + agent_id: str, + agent_soul: AgentSoulConfig, + ) -> list[dict[str, str | None]]: + """Drive-backed refs whose keys have no row in the agent drive (ENG-623 §4.4). + + Each finding message starts with its stable code token + (``skill_ref_dangling`` / ``file_ref_dangling``) in the ENG-616/617 style. + """ + wanted_keys: dict[str, tuple[str, str]] = {} + for skill in agent_soul.skills_files.skills: + if skill.skill_md_key: + wanted_keys[skill.skill_md_key] = ("skill_ref_dangling", skill.name or skill.id or "unknown") + for file in agent_soul.skills_files.files: + if file.drive_key: + wanted_keys[file.drive_key] = ("file_ref_dangling", file.name or file.id or "unknown") + if not wanted_keys: + return [] + + existing_keys = set( + db.session.scalars( + select(AgentDriveFile.key).where( + AgentDriveFile.tenant_id == tenant_id, + AgentDriveFile.agent_id == agent_id, + AgentDriveFile.key.in_(sorted(wanted_keys)), + ) + ) + ) + findings: list[dict[str, str | None]] = [] + for key, (code, display) in wanted_keys.items(): + if key in existing_keys: + continue + kind = "skill" if code == "skill_ref_dangling" else "file" + findings.append( + { + "code": code, + "surface": "agent_soul", + "kind": kind, + "id": key, + "message": f"{code}: {kind} '{display}' has no drive entry for key '{key}'.", + } + ) + return findings + + @classmethod + def _require_drive_refs_resolved(cls, *, tenant_id: str, agent_id: str, agent_soul: AgentSoulConfig) -> None: + """Hard save-time guard: dangling drive-backed refs are rejected (400).""" + findings = cls._drive_ref_findings(tenant_id=tenant_id, agent_id=agent_id, agent_soul=agent_soul) + if findings: + raise InvalidComposerConfigError("; ".join(str(finding["message"]) for finding in findings)) @classmethod def get_workflow_candidates(cls, *, tenant_id: str, app_id: str, node_id: str, user_id: str) -> dict[str, Any]: @@ -433,10 +748,28 @@ class AgentComposerService: return [] tools: list[dict[str, Any]] = [] for provider in providers: - for tool in provider.tools or []: + provider_tools = provider.tools or [] + # Provider-level entry first: selecting it means "all tools of this + # provider" (a provider hosts many tools, like an MCP server). Its + # ``id`` is also the mention id (``[§tool:/*§]``); the + # write-back is one ``tools.dify_tools`` entry with ``tool_name`` + # omitted. + tools.append( + { + "id": f"{provider.name}/*", + "granularity": "provider", + "name": provider.label.en_US if provider.label else provider.name, + "description": provider.description.en_US if provider.description else None, + "provider": provider.name, + "plugin_id": provider.plugin_id or None, + "tools_count": len(provider_tools), + } + ) + for tool in provider_tools: tools.append( { "id": f"{provider.name}/{tool.name}", + "granularity": "tool", "name": tool.name, "description": tool.label.en_US if tool.label else tool.name, "provider": provider.name, @@ -447,11 +780,26 @@ class AgentComposerService: @classmethod def calculate_impact(cls, *, tenant_id: str, current_snapshot_id: str) -> dict[str, Any]: + snapshot = db.session.scalar( + select(AgentConfigSnapshot) + .where( + AgentConfigSnapshot.tenant_id == tenant_id, + AgentConfigSnapshot.id == current_snapshot_id, + ) + .limit(1) + ) + agent_id = snapshot.agent_id if snapshot else None + predicates = [WorkflowAgentNodeBinding.current_snapshot_id == current_snapshot_id] + if agent_id: + predicates.append( + (WorkflowAgentNodeBinding.agent_id == agent_id) + & (WorkflowAgentNodeBinding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT) + ) bindings = list( db.session.scalars( select(WorkflowAgentNodeBinding).where( WorkflowAgentNodeBinding.tenant_id == tenant_id, - WorkflowAgentNodeBinding.current_snapshot_id == current_snapshot_id, + or_(*predicates), ) ).all() ) @@ -483,6 +831,26 @@ class AgentComposerService: node_job = payload.node_job or WorkflowNodeJobConfig() if binding: binding.node_job_config = node_job + if payload.agent_soul is not None and binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT: + current_snapshot = cls._require_version( + tenant_id=tenant_id, + agent_id=binding.agent_id, + version_id=binding.current_snapshot_id, + ) + version = cls._update_current_version( + current_snapshot=current_snapshot, + account_id=account_id, + agent_soul=payload.agent_soul, + operation=AgentConfigRevisionOperation.SAVE_CURRENT_VERSION, + version_note=payload.version_note, + ) + agent = cls._require_agent(tenant_id=tenant_id, agent_id=binding.agent_id) + if agent.scope != AgentScope.WORKFLOW_ONLY: + raise ValueError("Inline workflow agent binding must point to a workflow-only agent") + agent.active_config_snapshot_id = version.id + agent.active_config_has_model = agent_soul_has_model(payload.agent_soul) + agent.updated_by = account_id + binding.current_snapshot_id = version.id binding.updated_by = account_id return binding @@ -538,6 +906,7 @@ class AgentComposerService: ) agent = cls._require_agent(tenant_id=tenant_id, agent_id=binding.agent_id) agent.active_config_snapshot_id = version.id + agent.active_config_has_model = agent_soul_has_model(payload.agent_soul) agent.updated_by = account_id binding.current_snapshot_id = version.id if payload.node_job is not None: @@ -567,6 +936,7 @@ class AgentComposerService: ) agent = cls._require_agent(tenant_id=tenant_id, agent_id=binding.agent_id) agent.active_config_snapshot_id = version.id + agent.active_config_has_model = agent_soul_has_model(payload.agent_soul) agent.updated_by = account_id binding.current_snapshot_id = version.id binding.updated_by = account_id @@ -686,6 +1056,7 @@ class AgentComposerService: version_note=None, ) agent.active_config_snapshot_id = version.id + agent.active_config_has_model = agent_soul_has_model(agent_soul) return agent @classmethod @@ -725,6 +1096,7 @@ class AgentComposerService: version_note=version_note, ) agent.active_config_snapshot_id = version.id + agent.active_config_has_model = agent_soul_has_model(agent_soul) return agent @classmethod @@ -961,7 +1333,7 @@ class AgentComposerService: "id": binding.id, "binding_type": binding.binding_type.value, "agent_id": binding.agent_id, - "current_snapshot_id": binding.current_snapshot_id, + "current_snapshot_id": version.id if version else binding.current_snapshot_id, "workflow_id": binding.workflow_id, "node_id": binding.node_id, }, @@ -980,10 +1352,8 @@ class AgentComposerService: # this is the same list (so callers don't need to special-case). "effective_declared_outputs": cls._serialize_effective_outputs(cls._declared_outputs_from_binding(binding)), "save_options": save_options, - "impact_summary": cls.calculate_impact( - tenant_id=binding.tenant_id, current_snapshot_id=binding.current_snapshot_id - ) - if binding.current_snapshot_id + "impact_summary": cls.calculate_impact(tenant_id=binding.tenant_id, current_snapshot_id=version.id) + if version else None, } diff --git a/api/services/agent/composer_validator.py b/api/services/agent/composer_validator.py index d98c37796f2..8554b5c1ab7 100644 --- a/api/services/agent/composer_validator.py +++ b/api/services/agent/composer_validator.py @@ -18,6 +18,7 @@ from services.agent.prompt_mentions import ( from services.entities.agent_entities import ( AgentSoulConfig, ComposerSavePayload, + ComposerSaveStrategy, ComposerVariant, WorkflowNodeJobConfig, ) @@ -50,7 +51,12 @@ _DANGEROUS_ACK_KEYS = ( class ComposerConfigValidator: @classmethod def validate_save_payload(cls, payload: ComposerSavePayload) -> None: - if payload.variant == ComposerVariant.WORKFLOW and payload.soul_lock.locked and payload.agent_soul is not None: + if ( + payload.variant == ComposerVariant.WORKFLOW + and payload.soul_lock.locked + and payload.agent_soul is not None + and payload.save_strategy != ComposerSaveStrategy.NODE_JOB_ONLY + ): raise AgentSoulLockedError() if payload.agent_soul is not None: @@ -241,36 +247,27 @@ class ComposerConfigValidator: @classmethod def _validate_shell_config(cls, soul: dict[str, Any]) -> None: """Fail fast on shell env/secret/CLI config the sandbox would otherwise reject at run time.""" - env = soul.get("env") or {} seen_env_names: set[str] = set() - for section in ("variables", "secret_refs"): - entries = env.get(section) - if not isinstance(entries, list): - continue - for entry in entries: - if not isinstance(entry, dict): - continue - raw_name = entry.get("name") - if not isinstance(raw_name, str) or not raw_name.strip(): - # Unnamed draft rows are tolerated; only named entries are bound to the shell. - continue - name = raw_name.strip() - if not _SHELL_ENV_NAME_PATTERN.fullmatch(name): - raise InvalidComposerConfigError( - f"env/secret name '{name}' must be a valid shell identifier (^[A-Za-z_][A-Za-z0-9_]*$)." - ) - if section == "secret_refs" and cls._permission_denied(entry): - raise InvalidComposerConfigError(f"secret reference '{name}' is not authorized for this agent.") - if name in seen_env_names: - raise InvalidComposerConfigError( - f"duplicate env/secret name '{name}': environment variables and secret references " - "share the shell namespace." - ) - seen_env_names.add(name) + env = soul.get("env") or {} + cls._validate_env_config(env, seen_env_names=seen_env_names, label="agent") tools = soul.get("tools") or {} cli_tools = tools.get("cli_tools") if isinstance(cli_tools, list): + # Mention references resolve `[§cli_tool:§]` by id, so ids must be + # unique across the whole list — disabled entries included, since they + # stay in config and would make resolution ambiguous. + seen_cli_tool_ids: set[str] = set() + for entry in cli_tools: + if not isinstance(entry, dict): + continue + raw_id = entry.get("id") + if isinstance(raw_id, str) and raw_id.strip(): + if raw_id in seen_cli_tool_ids: + raise InvalidComposerConfigError( + f"duplicate CLI tool id '{raw_id}': cli_tool mention references require unique ids." + ) + seen_cli_tool_ids.add(raw_id) for entry in cli_tools: if not isinstance(entry, dict) or entry.get("enabled") is False: continue @@ -284,6 +281,56 @@ class ComposerConfigValidator: raise InvalidComposerConfigError( "a dangerous CLI tool command must be explicitly acknowledged before save." ) + tool_name = cls._cli_tool_name(entry) or "" + cls._validate_env_config( + entry.get("env") or {}, + seen_env_names=seen_env_names, + label=f"CLI tool '{tool_name}'", + ) + + @classmethod + def _validate_env_config(cls, env: Any, *, seen_env_names: set[str], label: str) -> None: + if not isinstance(env, dict): + return + for section in ("variables", "secret_refs"): + entries = env.get(section) + if not isinstance(entries, list): + continue + for entry in entries: + if not isinstance(entry, dict): + continue + name = cls._env_name(entry) + if name is None: + # Unnamed draft rows are tolerated; only named entries are bound to the shell. + continue + if not _SHELL_ENV_NAME_PATTERN.fullmatch(name): + raise InvalidComposerConfigError( + f"env/secret name '{name}' must be a valid shell identifier (^[A-Za-z_][A-Za-z0-9_]*$)." + ) + if section == "secret_refs" and cls._permission_denied(entry): + raise InvalidComposerConfigError(f"secret reference '{name}' is not authorized for {label}.") + if name in seen_env_names: + raise InvalidComposerConfigError( + f"duplicate env/secret name '{name}': environment variables and secret references " + "share the shell namespace." + ) + seen_env_names.add(name) + + @staticmethod + def _env_name(entry: dict[str, Any]) -> str | None: + for key in ("name", "key", "env_name", "variable"): + value = entry.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + @staticmethod + def _cli_tool_name(entry: dict[str, Any]) -> str | None: + for key in _CLI_TOOL_NAME_KEYS: + value = entry.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return None @classmethod def _reject_plaintext_secrets(cls, value: Any, *, path: str) -> None: diff --git a/api/services/agent/observability_service.py b/api/services/agent/observability_service.py new file mode 100644 index 00000000000..a150f70d7f4 --- /dev/null +++ b/api/services/agent/observability_service.py @@ -0,0 +1,300 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from decimal import Decimal +from typing import Any + +import sqlalchemy as sa +from sqlalchemy import func, or_, select + +from core.app.entities.app_invoke_entities import InvokeFrom +from libs.helper import convert_datetime_to_date, escape_like_pattern, to_timestamp +from models.enums import MessageStatus +from models.model import App, Conversation, Message + + +@dataclass(frozen=True) +class AgentLogQueryParams: + page: int = 1 + limit: int = 20 + keyword: str | None = None + status: str | None = None + source: str | None = None + start: datetime | None = None + end: datetime | None = None + + +@dataclass(frozen=True) +class AgentStatisticsQueryParams: + source: str | None = None + start: datetime | None = None + end: datetime | None = None + timezone: str = "UTC" + + +class AgentObservabilityService: + _SOURCE_ALIASES: dict[str, InvokeFrom] = { + "api": InvokeFrom.SERVICE_API, + "service-api": InvokeFrom.SERVICE_API, + "service_api": InvokeFrom.SERVICE_API, + "console": InvokeFrom.EXPLORE, + "explore": InvokeFrom.EXPLORE, + "explore-app": InvokeFrom.EXPLORE, + "explore_app": InvokeFrom.EXPLORE, + "web": InvokeFrom.WEB_APP, + "web-app": InvokeFrom.WEB_APP, + "web_app": InvokeFrom.WEB_APP, + "debugger": InvokeFrom.DEBUGGER, + "dev": InvokeFrom.DEBUGGER, + "openapi": InvokeFrom.OPENAPI, + "trigger": InvokeFrom.TRIGGER, + } + + def __init__(self, session: Any): + self._session = session + + @classmethod + def resolve_source(cls, source: str | None) -> InvokeFrom | None: + if not source or source == "all": + return None + normalized = source.strip().lower() + if not normalized or normalized == "all": + return None + try: + return cls._SOURCE_ALIASES[normalized] + except KeyError as exc: + raise ValueError(f"Unsupported source: {source}") from exc + + @staticmethod + def _message_status(message: Message) -> str: + if message.error or message.status == MessageStatus.ERROR: + return "failed" + if message.status == MessageStatus.PAUSED: + return "paused" + return "success" + + @staticmethod + def _total_tokens(message: Message) -> int: + return int(message.message_tokens or 0) + int(message.answer_tokens or 0) + + @classmethod + def serialize_log_message(cls, message: Message, conversation: Conversation | None = None) -> dict[str, Any]: + invoke_from = message.invoke_from.value if message.invoke_from else None + return { + "id": message.id, + "message_id": message.id, + "conversation_id": message.conversation_id, + "conversation_name": conversation.name if conversation else None, + "query": message.query, + "answer": message.answer, + "status": cls._message_status(message), + "error": message.error, + "source": invoke_from, + "from_source": message.from_source.value if message.from_source else None, + "from_end_user_id": message.from_end_user_id, + "from_account_id": message.from_account_id, + "message_tokens": int(message.message_tokens or 0), + "answer_tokens": int(message.answer_tokens or 0), + "total_tokens": cls._total_tokens(message), + "total_price": str(message.total_price or Decimal(0)), + "currency": message.currency, + "latency": float(message.provider_response_latency or 0), + "created_at": to_timestamp(message.created_at), + "updated_at": to_timestamp(message.updated_at), + } + + def list_logs(self, *, app: App, params: AgentLogQueryParams) -> dict[str, Any]: + source = self.resolve_source(params.source) + stmt = ( + select(Message, Conversation) + .join(Conversation, Conversation.id == Message.conversation_id) + .where(Message.app_id == app.id, Conversation.app_id == app.id) + ) + stmt = self._apply_source_filter(stmt, source) + + if params.start: + stmt = stmt.where(Message.created_at >= params.start) + if params.end: + stmt = stmt.where(Message.created_at < params.end) + if params.keyword: + escaped_keyword = escape_like_pattern(params.keyword) + pattern = f"%{escaped_keyword}%" + stmt = stmt.where( + or_( + Message.query.ilike(pattern, escape="\\"), + Message.answer.ilike(pattern, escape="\\"), + Conversation.name.ilike(pattern, escape="\\"), + ) + ) + if params.status: + stmt = self._apply_status_filter(stmt, params.status) + + total = self._session.scalar(select(func.count()).select_from(stmt.subquery())) or 0 + rows = list( + self._session.execute( + stmt.order_by(Message.created_at.desc(), Message.id.desc()) + .offset((params.page - 1) * params.limit) + .limit(params.limit) + ).all() + ) + data = [] + for message, conversation in rows: + data.append(self.serialize_log_message(message, conversation)) + return { + "data": data, + "page": params.page, + "limit": params.limit, + "total": total, + "has_more": params.page * params.limit < total, + } + + @classmethod + def _apply_source_filter(cls, stmt, source: InvokeFrom | None): + if source is None: + return stmt.where(Message.invoke_from != InvokeFrom.DEBUGGER) + return stmt.where(Message.invoke_from == source) + + @staticmethod + def _apply_status_filter(stmt, status: str): + normalized = status.strip().lower() + if normalized in {"success", "normal"}: + return stmt.where(Message.error.is_(None), Message.status == MessageStatus.NORMAL) + if normalized in {"failed", "error"}: + return stmt.where(or_(Message.error.is_not(None), Message.status == MessageStatus.ERROR)) + if normalized == "paused": + return stmt.where(Message.status == MessageStatus.PAUSED) + raise ValueError(f"Unsupported status: {status}") + + def get_statistics_summary(self, *, app: App, params: AgentStatisticsQueryParams) -> dict[str, Any]: + source = self.resolve_source(params.source) + rows = self._load_daily_statistics(app=app, params=params, source=source) + charts = self._build_charts(rows) + summary = self._build_summary(rows) + return { + "source": source.value if source else "all", + "summary": summary, + "charts": charts, + } + + def _load_daily_statistics( + self, *, app: App, params: AgentStatisticsQueryParams, source: InvokeFrom | None + ) -> list[dict[str, Any]]: + converted_created_at = convert_datetime_to_date("m.created_at") + source_condition = "AND m.invoke_from != :debugger" if source is None else "AND m.invoke_from = :source" + sql_query = f"""SELECT + {converted_created_at} AS date, + COUNT(m.id) AS message_count, + COUNT(DISTINCT m.conversation_id) AS conversation_count, + COUNT(DISTINCT m.from_end_user_id) AS end_user_count, + COALESCE(SUM(COALESCE(m.message_tokens, 0) + COALESCE(m.answer_tokens, 0)), 0) AS token_count, + COALESCE(SUM(COALESCE(m.total_price, 0)), 0) AS total_price, + COALESCE(AVG(m.provider_response_latency), 0) AS avg_latency, + COALESCE(SUM(m.provider_response_latency), 0) AS latency_sum, + COALESCE(SUM(m.answer_tokens), 0) AS answer_tokens, + COUNT(mf.id) AS like_count +FROM messages m +LEFT JOIN message_feedbacks mf + ON mf.message_id = m.id AND mf.rating = 'like' +WHERE + m.app_id = :app_id + {source_condition}""" + args: dict[str, Any] = { + "tz": params.timezone, + "app_id": app.id, + "debugger": InvokeFrom.DEBUGGER, + } + if source is not None: + args["source"] = source + if params.start: + sql_query += " AND m.created_at >= :start" + args["start"] = params.start + if params.end: + sql_query += " AND m.created_at < :end" + args["end"] = params.end + sql_query += " GROUP BY date ORDER BY date" + + return [dict(row._mapping) for row in self._session.execute(sa.text(sql_query), args).all()] + + @staticmethod + def _build_charts(rows: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]: + messages = [] + conversations = [] + end_users = [] + token_usage = [] + average_session_interactions = [] + average_response_time = [] + tokens_per_second = [] + user_satisfaction_rate = [] + + for row in rows: + date = str(row["date"]) + message_count = int(row["message_count"] or 0) + conversation_count = int(row["conversation_count"] or 0) + token_count = int(row["token_count"] or 0) + total_price = row["total_price"] or Decimal(0) + avg_latency = float(row["avg_latency"] or 0) + latency_sum = float(row["latency_sum"] or 0) + answer_tokens = int(row["answer_tokens"] or 0) + like_count = int(row["like_count"] or 0) + + messages.append({"date": date, "message_count": message_count}) + conversations.append({"date": date, "conversation_count": conversation_count}) + end_users.append({"date": date, "terminal_count": int(row["end_user_count"] or 0)}) + token_usage.append( + { + "date": date, + "token_count": token_count, + "total_price": str(total_price), + "currency": "USD", + } + ) + average_session_interactions.append( + { + "date": date, + "interactions": round(message_count / conversation_count, 2) if conversation_count else 0, + } + ) + average_response_time.append({"date": date, "latency": round(avg_latency * 1000, 4)}) + tokens_per_second.append({"date": date, "tps": round(answer_tokens / latency_sum, 4) if latency_sum else 0}) + user_satisfaction_rate.append( + {"date": date, "rate": round(like_count * 100 / message_count, 2) if message_count else 0} + ) + + return { + "daily_messages": messages, + "daily_conversations": conversations, + "daily_end_users": end_users, + "token_usage": token_usage, + "average_session_interactions": average_session_interactions, + "average_response_time": average_response_time, + "tokens_per_second": tokens_per_second, + "user_satisfaction_rate": user_satisfaction_rate, + } + + @staticmethod + def _build_summary(rows: list[dict[str, Any]]) -> dict[str, Any]: + total_messages = sum(int(row["message_count"] or 0) for row in rows) + total_conversations = sum(int(row["conversation_count"] or 0) for row in rows) + total_end_users = sum(int(row["end_user_count"] or 0) for row in rows) + total_tokens = sum(int(row["token_count"] or 0) for row in rows) + total_price = sum(Decimal(str(row["total_price"] or 0)) for row in rows) + total_answer_tokens = sum(int(row["answer_tokens"] or 0) for row in rows) + total_latency = sum(float(row["latency_sum"] or 0) for row in rows) + weighted_latency = sum(float(row["avg_latency"] or 0) * int(row["message_count"] or 0) for row in rows) + total_likes = sum(int(row["like_count"] or 0) for row in rows) + + return { + "total_messages": total_messages, + "total_conversations": total_conversations, + "total_end_users": total_end_users, + "total_tokens": total_tokens, + "total_price": str(total_price), + "currency": "USD", + "average_session_interactions": round(total_messages / total_conversations, 2) + if total_conversations + else 0, + "average_response_time": round((weighted_latency / total_messages) * 1000, 4) if total_messages else 0, + "tokens_per_second": round(total_answer_tokens / total_latency, 4) if total_latency else 0, + "user_satisfaction_rate": round(total_likes * 100 / total_messages, 2) if total_messages else 0, + } diff --git a/api/services/agent/prompt_mentions.py b/api/services/agent/prompt_mentions.py index 880e14af9b3..921d6838b26 100644 --- a/api/services/agent/prompt_mentions.py +++ b/api/services/agent/prompt_mentions.py @@ -57,6 +57,14 @@ _RESIDUAL_MENTION_PATTERN = re.compile(r"\[§([A-Za-z_][A-Za-z0-9_]*:[^§]*?)§\ MAX_MENTIONS_PER_PROMPT = 200 MAX_MENTION_FIELD_LENGTH = 255 +# Reserved ``tool`` mention id suffix: ``/*`` means "every tool of this +# provider" (a provider hosts many tools, like an MCP server). Single tools use +# ``/``, so ``*`` can never collide with a real tool name. +# The mention points at a provider-level config entry (``tool_name`` omitted in +# ``tools.dify_tools``); the runtime expands that entry into all of the +# provider's tools. +ALL_PROVIDER_TOOLS_SUFFIX = "*" + # Per-surface allowlists (design §2.4): the soul prompt may only reference # soul-owned entities; the workflow job prompt may only reference run-scoped ones. SOUL_PROMPT_ALLOWED_KINDS = frozenset( @@ -178,17 +186,33 @@ def build_soul_mention_resolver(agent_soul: AgentSoulConfig) -> MentionResolver: return file.name or file.id case MentionKind.TOOL: for tool in agent_soul.tools.dify_tools: - aliases = {tool.tool_name} | { - f"{prefix}/{tool.tool_name}" - for prefix in (tool.provider, tool.provider_id, tool.plugin_id) - if prefix - } + prefixes = {prefix for prefix in (tool.provider, tool.provider_id, tool.plugin_id) if prefix} + if tool.plugin_id and tool.provider: + prefixes.add(f"{tool.plugin_id}/{tool.provider}") + if tool.tool_name is None: + # Provider-level entry = all tools of this provider. + # ``[§tool:/*§]`` names the whole provider; + # ``[§tool:/§]`` names one tool offered + # through it. + display = tool.provider or tool.provider_id or tool.plugin_id + if any(mention.ref_id == f"{prefix}/{ALL_PROVIDER_TOOLS_SUFFIX}" for prefix in prefixes): + return f"all {display} tools" + # longest prefix first — shorter prefixes can be proper + # prefixes of longer ones and would mis-split the ref. + for prefix in sorted(prefixes, key=len, reverse=True): + single = mention.ref_id.removeprefix(f"{prefix}/") + if single != mention.ref_id and single and "/" not in single: + return single + continue + aliases = {tool.tool_name} | {f"{prefix}/{tool.tool_name}" for prefix in prefixes} if mention.ref_id in aliases: return tool.name or tool.tool_name case MentionKind.CLI_TOOL: for cli_tool in agent_soul.tools.cli_tools: - if cli_tool.name and mention.ref_id == cli_tool.name: - return cli_tool.name + # id is the stable reference; name stays as an alias so tokens + # minted before ids existed (or hand-written ones) keep working. + if mention.ref_id in (cli_tool.id, cli_tool.name): + return cli_tool.name or cli_tool.id case MentionKind.KNOWLEDGE: for dataset in agent_soul.knowledge.datasets: if mention.ref_id == dataset.id: @@ -247,6 +271,7 @@ def _selector_from_ref(ref: WorkflowPreviousNodeOutputRef) -> tuple[str, str] | __all__ = [ + "ALL_PROVIDER_TOOLS_SUFFIX", "MAX_MENTIONS_PER_PROMPT", "MAX_MENTION_FIELD_LENGTH", "MENTION_PATTERN", diff --git a/api/services/agent/roster_service.py b/api/services/agent/roster_service.py index c4ec16c0b73..85636a9609b 100644 --- a/api/services/agent/roster_service.py +++ b/api/services/agent/roster_service.py @@ -1,6 +1,6 @@ from typing import Any, TypedDict -from sqlalchemy import func, select +from sqlalchemy import and_, func, or_, select from sqlalchemy.exc import IntegrityError from libs.datetime_utils import naive_utc_now @@ -18,8 +18,10 @@ from models.agent import ( WorkflowAgentNodeBinding, ) from models.agent_config_entities import AgentSoulConfig -from models.model import App +from models.enums import AppStatus +from models.model import App, AppMode from models.workflow import Workflow +from services.agent.agent_soul_state import agent_soul_has_model from services.agent.composer_validator import ComposerConfigValidator from services.agent.errors import ( AgentArchivedError, @@ -35,8 +37,13 @@ class AgentReferencingWorkflow(TypedDict): app_id: str app_name: str + app_icon_type: str | None + app_icon: str | None + app_icon_background: str | None app_mode: str + app_updated_at: int | None workflow_id: str + workflow_version: str node_ids: list[str] @@ -45,11 +52,18 @@ class AgentRosterService: self._session = session @staticmethod - def serialize_agent(agent: Agent, active_version: AgentConfigSnapshot | None = None) -> dict[str, Any]: + def serialize_agent( + agent: Agent, + active_version: AgentConfigSnapshot | None = None, + published_references: list[AgentReferencingWorkflow] | None = None, + active_config_is_published: bool = False, + ) -> dict[str, Any]: + published_references = published_references or [] return { "id": agent.id, "name": agent.name, "description": agent.description, + "role": agent.role or "", "icon_type": agent.icon_type.value if agent.icon_type else None, "icon": agent.icon, "icon_background": agent.icon_background, @@ -61,6 +75,7 @@ class AgentRosterService: "workflow_node_id": agent.workflow_node_id, "active_config_snapshot_id": agent.active_config_snapshot_id, "active_config_snapshot": AgentRosterService.serialize_version(active_version) if active_version else None, + "active_config_is_published": active_config_is_published, "status": agent.status.value, "created_by": agent.created_by, "updated_by": agent.updated_by, @@ -68,6 +83,9 @@ class AgentRosterService: "archived_at": to_timestamp(agent.archived_at), "created_at": to_timestamp(agent.created_at), "updated_at": to_timestamp(agent.updated_at), + "published_reference_count": len(published_references), + "published_node_reference_count": sum(len(item["node_ids"]) for item in published_references), + "published_references": published_references, } @staticmethod @@ -84,9 +102,8 @@ class AgentRosterService: "created_at": to_timestamp(version.created_at), } - def list_roster_agents( - self, *, tenant_id: str, page: int = 1, limit: int = 20, keyword: str | None = None - ) -> dict[str, Any]: + @staticmethod + def _build_roster_agents_stmt(*, tenant_id: str, keyword: str | None = None): stmt = select(Agent).where( Agent.tenant_id == tenant_id, Agent.scope == AgentScope.ROSTER, @@ -97,20 +114,40 @@ class AgentRosterService: escaped_keyword = escape_like_pattern(keyword) stmt = stmt.where(Agent.name.ilike(f"%{escaped_keyword}%", escape="\\")) - stmt = stmt.order_by(Agent.updated_at.desc()) + return stmt.order_by(Agent.updated_at.desc()) + + def list_roster_agents( + self, *, tenant_id: str, page: int = 1, limit: int = 20, keyword: str | None = None + ) -> dict[str, Any]: + stmt = self._build_roster_agents_stmt(tenant_id=tenant_id, keyword=keyword) total = self._session.scalar(select(func.count()).select_from(stmt.subquery())) or 0 agents = list(self._session.scalars(stmt.offset((page - 1) * limit).limit(limit)).all()) versions_by_id = self._load_versions_by_id( [agent.active_config_snapshot_id for agent in agents if agent.active_config_snapshot_id] ) + published_references_by_agent_id = self._load_published_references_by_agent_id( + tenant_id=tenant_id, + agent_ids=[agent.id for agent in agents], + ) + active_config_is_published_by_agent_id = self.load_active_config_is_published_by_agent_id( + tenant_id=tenant_id, + agents=agents, + ) data = [] for agent in agents: active_version = ( versions_by_id.get(agent.active_config_snapshot_id) if agent.active_config_snapshot_id else None ) - data.append(self.serialize_agent(agent, active_version)) + data.append( + self.serialize_agent( + agent, + active_version, + published_references_by_agent_id.get(agent.id, []), + active_config_is_published_by_agent_id.get(agent.id, False), + ) + ) return { "data": data, @@ -123,7 +160,31 @@ class AgentRosterService: def list_invite_options( self, *, tenant_id: str, page: int = 1, limit: int = 20, keyword: str | None = None, app_id: str | None = None ) -> dict[str, Any]: - result = self.list_roster_agents(tenant_id=tenant_id, page=page, limit=limit, keyword=keyword) + stmt = self._build_roster_agents_stmt(tenant_id=tenant_id, keyword=keyword).where( + Agent.active_config_has_model.is_(True) + ) + total = self._session.scalar(select(func.count()).select_from(stmt.subquery())) or 0 + agents = list(self._session.scalars(stmt.offset((page - 1) * limit).limit(limit)).all()) + versions_by_id = self._load_versions_by_id( + [agent.active_config_snapshot_id for agent in agents if agent.active_config_snapshot_id] + ) + published_references_by_agent_id = self._load_published_references_by_agent_id( + tenant_id=tenant_id, + agent_ids=[agent.id for agent in agents], + ) + active_config_is_published_by_agent_id = self.load_active_config_is_published_by_agent_id( + tenant_id=tenant_id, + agents=agents, + ) + data = [ + self.serialize_agent( + agent, + versions_by_id.get(agent.active_config_snapshot_id) if agent.active_config_snapshot_id else None, + published_references_by_agent_id.get(agent.id, []), + active_config_is_published_by_agent_id.get(agent.id, False), + ) + for agent in agents + ] usage_by_agent_id: dict[str, list[str]] = {} if app_id: draft_workflow = self._session.scalar( @@ -136,7 +197,7 @@ class AgentRosterService: .limit(1) ) if draft_workflow: - agent_ids = [item["id"] for item in result["data"]] + agent_ids = [item["id"] for item in data] if agent_ids: bindings = self._session.scalars( select(WorkflowAgentNodeBinding).where( @@ -149,12 +210,18 @@ class AgentRosterService: if binding.agent_id: usage_by_agent_id.setdefault(binding.agent_id, []).append(binding.node_id) - for item in result["data"]: + for item in data: existing_node_ids = usage_by_agent_id.get(item["id"], []) item["is_in_current_workflow"] = bool(existing_node_ids) item["in_current_workflow_count"] = len(existing_node_ids) item["existing_node_ids"] = existing_node_ids - return result + return { + "data": data, + "page": page, + "limit": limit, + "total": total, + "has_more": page * limit < total, + } def create_roster_agent( self, @@ -162,7 +229,7 @@ class AgentRosterService: tenant_id: str, account_id: str, payload: RosterAgentCreatePayload, - source: AgentSource = AgentSource.AGENT_APP, + source: AgentSource = AgentSource.ROSTER, ) -> Agent: ComposerConfigValidator.validate_agent_soul(payload.agent_soul) @@ -170,6 +237,7 @@ class AgentRosterService: tenant_id=tenant_id, name=payload.name, description=payload.description, + role=payload.role, icon_type=payload.icon_type, icon=payload.icon, icon_background=payload.icon_background, @@ -209,6 +277,7 @@ class AgentRosterService: ) self._session.add(revision) agent.active_config_snapshot_id = version.id + agent.active_config_has_model = agent_soul_has_model(payload.agent_soul) try: self._session.commit() @@ -225,6 +294,7 @@ class AgentRosterService: app_id: str, name: str, description: str = "", + role: str = "", icon_type: Any = None, icon: str | None = None, icon_background: str | None = None, @@ -241,6 +311,7 @@ class AgentRosterService: tenant_id=tenant_id, name=name, description=description, + role=role, icon_type=icon_type, icon=icon, icon_background=icon_background, @@ -279,9 +350,25 @@ class AgentRosterService: ) self._session.add(revision) agent.active_config_snapshot_id = version.id + agent.active_config_has_model = agent_soul_has_model(AgentSoulConfig()) self._session.flush() return agent + def load_app_backing_agents_by_app_id(self, *, tenant_id: str, app_ids: list[str]) -> dict[str, Agent]: + """Return active app-backed Agents keyed by Agent App id.""" + if not app_ids: + return {} + agents = self._session.scalars( + select(Agent).where( + Agent.tenant_id == tenant_id, + Agent.app_id.in_(app_ids), + Agent.scope == AgentScope.ROSTER, + Agent.source == AgentSource.AGENT_APP, + Agent.status == AgentStatus.ACTIVE, + ) + ).all() + return {agent.app_id: agent for agent in agents if agent.app_id} + def get_app_backing_agent(self, *, tenant_id: str, app_id: str) -> Agent | None: """Return the roster Agent that backs the given Agent App, if any.""" return self._session.scalar( @@ -294,6 +381,43 @@ class AgentRosterService: ) ) + def get_agent_app_model(self, *, tenant_id: str, agent_id: str) -> App: + """Resolve the Agent App hidden behind an app-backed Agent id. + + The public /agent route uses Agent ids, while the runtime and legacy app + APIs still operate on App ids internally. Only app-backed roster Agents + are accepted here; workflow-only Agents and historical standalone roster + Agents are not Agent App resources. + """ + agent = self._session.scalar( + select(Agent) + .where( + Agent.tenant_id == tenant_id, + Agent.id == agent_id, + Agent.scope == AgentScope.ROSTER, + Agent.source == AgentSource.AGENT_APP, + Agent.app_id.is_not(None), + Agent.status == AgentStatus.ACTIVE, + ) + .limit(1) + ) + if agent is None or agent.app_id is None: + raise AgentNotFoundError() + + app = self._session.scalar( + select(App) + .where( + App.tenant_id == tenant_id, + App.id == agent.app_id, + App.mode == AppMode.AGENT, + App.status == AppStatus.NORMAL, + ) + .limit(1) + ) + if app is None: + raise AgentNotFoundError() + return app + def list_workflows_referencing_app_agent(self, *, tenant_id: str, app_id: str) -> list[AgentReferencingWorkflow]: """List the workflow apps that reference this Agent App's bound Agent. @@ -306,48 +430,33 @@ class AgentRosterService: if agent is None: return [] - bindings = self._session.scalars( - select(WorkflowAgentNodeBinding).where( - WorkflowAgentNodeBinding.tenant_id == tenant_id, - WorkflowAgentNodeBinding.agent_id == agent.id, - WorkflowAgentNodeBinding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT, - ) - ).all() - if not bindings: - return [] + return self._load_published_references_by_agent_id(tenant_id=tenant_id, agent_ids=[agent.id]).get(agent.id, []) - # Collapse the per-version / per-node rows into one entry per workflow app. - node_ids_by_workflow: dict[tuple[str, str], set[str]] = {} - for binding in bindings: - node_ids_by_workflow.setdefault((binding.app_id, binding.workflow_id), set()).add(binding.node_id) - - referenced_app_ids = {workflow_app_id for workflow_app_id, _ in node_ids_by_workflow} - apps = {app.id: app for app in self._session.scalars(select(App).where(App.id.in_(referenced_app_ids))).all()} - - result: list[AgentReferencingWorkflow] = [] - for (workflow_app_id, workflow_id), node_ids in node_ids_by_workflow.items(): - app = apps.get(workflow_app_id) - if app is None: - # Orphaned binding (workflow app deleted): skip rather than 500. - continue - result.append( - AgentReferencingWorkflow( - app_id=workflow_app_id, - app_name=app.name, - app_mode=str(app.mode), - workflow_id=workflow_id, - node_ids=sorted(node_ids), - ) - ) - result.sort(key=lambda item: item["app_name"].lower()) - return result + def load_published_references_by_agent_id( + self, *, tenant_id: str, agent_ids: list[str] + ) -> dict[str, list[AgentReferencingWorkflow]]: + """Return published workflow references grouped by roster Agent id.""" + return self._load_published_references_by_agent_id(tenant_id=tenant_id, agent_ids=agent_ids) def get_roster_agent_detail(self, *, tenant_id: str, agent_id: str) -> dict[str, Any]: agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True) active_version = self._get_version( tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id ) - return self.serialize_agent(agent, active_version) + published_references_by_agent_id = self._load_published_references_by_agent_id( + tenant_id=tenant_id, + agent_ids=[agent.id], + ) + active_config_is_published_by_agent_id = self.load_active_config_is_published_by_agent_id( + tenant_id=tenant_id, + agents=[agent], + ) + return self.serialize_agent( + agent, + active_version, + published_references_by_agent_id.get(agent.id, []), + active_config_is_published_by_agent_id.get(agent.id, False), + ) def update_roster_agent( self, *, tenant_id: str, agent_id: str, account_id: str, payload: RosterAgentUpdatePayload @@ -378,12 +487,48 @@ class AgentRosterService: agent.updated_by = account_id self._session.commit() + @staticmethod + def _visible_version_operations(agent: Agent) -> set[AgentConfigRevisionOperation]: + if agent.source == AgentSource.AGENT_APP: + return {AgentConfigRevisionOperation.SAVE_NEW_VERSION} + return { + AgentConfigRevisionOperation.CREATE_VERSION, + AgentConfigRevisionOperation.SAVE_NEW_VERSION, + AgentConfigRevisionOperation.SAVE_NEW_AGENT, + AgentConfigRevisionOperation.SAVE_TO_ROSTER, + } + + def active_config_is_published(self, *, tenant_id: str, agent: Agent) -> bool: + """Return whether the Agent's current active snapshot is a visible published version.""" + return self.load_active_config_is_published_by_agent_id(tenant_id=tenant_id, agents=[agent]).get( + agent.id, + False, + ) + + def load_active_config_is_published_by_agent_id(self, *, tenant_id: str, agents: list[Agent]) -> dict[str, bool]: + """Return publish-state flags for the active config snapshots of the given Agents.""" + published_agent_ids = self._load_published_active_snapshot_agent_ids(tenant_id=tenant_id, agents=agents) + return {agent.id: agent.id in published_agent_ids for agent in agents} + def list_agent_versions(self, *, tenant_id: str, agent_id: str) -> list[dict[str, Any]]: - self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True) + agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True) + visible_version_ids = ( + select(AgentConfigRevision.current_snapshot_id) + .where( + AgentConfigRevision.tenant_id == tenant_id, + AgentConfigRevision.agent_id == agent_id, + AgentConfigRevision.operation.in_(self._visible_version_operations(agent)), + ) + .subquery() + ) versions = list( self._session.scalars( select(AgentConfigSnapshot) - .where(AgentConfigSnapshot.tenant_id == tenant_id, AgentConfigSnapshot.agent_id == agent_id) + .where( + AgentConfigSnapshot.tenant_id == tenant_id, + AgentConfigSnapshot.agent_id == agent_id, + AgentConfigSnapshot.id.in_(select(visible_version_ids.c.current_snapshot_id)), + ) .order_by(AgentConfigSnapshot.version.desc()) ).all() ) @@ -394,7 +539,19 @@ class AgentRosterService: ] def get_agent_version_detail(self, *, tenant_id: str, agent_id: str, version_id: str) -> dict[str, Any]: - self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True) + agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True) + visible_revision_id = self._session.scalar( + select(AgentConfigRevision.id) + .where( + AgentConfigRevision.tenant_id == tenant_id, + AgentConfigRevision.agent_id == agent_id, + AgentConfigRevision.current_snapshot_id == version_id, + AgentConfigRevision.operation.in_(self._visible_version_operations(agent)), + ) + .limit(1) + ) + if not visible_revision_id: + raise AgentVersionNotFoundError() version = self._get_version(tenant_id=tenant_id, agent_id=agent_id, version_id=version_id) revisions = list( self._session.scalars( @@ -450,6 +607,95 @@ class AgentRosterService: raise AgentVersionNotFoundError() return version + def _load_published_active_snapshot_agent_ids(self, *, tenant_id: str, agents: list[Agent]) -> set[str]: + predicates = [ + and_( + AgentConfigRevision.agent_id == agent.id, + AgentConfigRevision.current_snapshot_id == agent.active_config_snapshot_id, + AgentConfigRevision.operation.in_(self._visible_version_operations(agent)), + ) + for agent in agents + if agent.active_config_snapshot_id + ] + if not predicates: + return set() + + agent_ids = self._session.scalars( + select(AgentConfigRevision.agent_id) + .where( + AgentConfigRevision.tenant_id == tenant_id, + or_(*predicates), + ) + .distinct() + ).all() + return set(agent_ids) + + def _load_published_references_by_agent_id( + self, *, tenant_id: str, agent_ids: list[str] + ) -> dict[str, list[AgentReferencingWorkflow]]: + if not agent_ids: + return {} + + bindings = list( + self._session.scalars( + select(WorkflowAgentNodeBinding).where( + WorkflowAgentNodeBinding.tenant_id == tenant_id, + WorkflowAgentNodeBinding.agent_id.in_(agent_ids), + WorkflowAgentNodeBinding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT, + WorkflowAgentNodeBinding.workflow_version != Workflow.VERSION_DRAFT, + ) + ).all() + ) + if not bindings: + return {} + + app_ids = {binding.app_id for binding in bindings} + apps = { + app.id: app + for app in self._session.scalars( + select(App).where( + App.tenant_id == tenant_id, + App.id.in_(app_ids), + App.status == AppStatus.NORMAL, + ) + ).all() + } + + grouped: dict[str, dict[tuple[str, str], AgentReferencingWorkflow]] = {} + for binding in bindings: + if not binding.agent_id: + continue + app = apps.get(binding.app_id) + if app is None or app.workflow_id != binding.workflow_id: + continue + by_workflow = grouped.setdefault(binding.agent_id, {}) + key = (binding.app_id, binding.workflow_id) + item = by_workflow.setdefault( + key, + AgentReferencingWorkflow( + app_id=binding.app_id, + app_name=app.name, + app_icon_type=(icon_type.value if (icon_type := getattr(app, "icon_type", None)) else None), + app_icon=getattr(app, "icon", None), + app_icon_background=getattr(app, "icon_background", None), + app_mode=str(app.mode), + app_updated_at=to_timestamp(getattr(app, "updated_at", None)), + workflow_id=binding.workflow_id, + workflow_version=binding.workflow_version, + node_ids=[], + ), + ) + item["node_ids"].append(binding.node_id) + + result: dict[str, list[AgentReferencingWorkflow]] = {} + for agent_id, by_workflow in grouped.items(): + references = list(by_workflow.values()) + for reference in references: + reference["node_ids"] = sorted(set(reference["node_ids"])) + references.sort(key=lambda item: (-(item["app_updated_at"] or 0), item["app_name"].lower())) + result[agent_id] = references + return result + def _load_versions_by_id(self, version_ids: list[str]) -> dict[str, AgentConfigSnapshot]: if not version_ids: return {} diff --git a/api/services/agent/skill_package_service.py b/api/services/agent/skill_package_service.py index 61bc6dd0ed2..6452329904d 100644 --- a/api/services/agent/skill_package_service.py +++ b/api/services/agent/skill_package_service.py @@ -70,6 +70,7 @@ class SkillManifest(BaseModel): "size": self.size, "hash": self.hash, "entry_path": self.entry_path, + "manifest_files": self.files, } ) diff --git a/api/services/agent/skill_standardize_service.py b/api/services/agent/skill_standardize_service.py index bd80d031685..71bb8daded9 100644 --- a/api/services/agent/skill_standardize_service.py +++ b/api/services/agent/skill_standardize_service.py @@ -112,6 +112,8 @@ class SkillStandardizeService: "skill_md_key": skill_md_key, "full_archive_file_id": archive_tool_file.id, "full_archive_key": archive_key, + # ENG-371: zip member listing — strong signals (scripts/*.sh) for infer-tools. + "manifest_files": manifest.files, } ) return { diff --git a/api/services/agent/skill_tool_inference_service.py b/api/services/agent/skill_tool_inference_service.py new file mode 100644 index 00000000000..343ab370f81 --- /dev/null +++ b/api/services/agent/skill_tool_inference_service.py @@ -0,0 +1,215 @@ +"""Infer CLI tool + ENV suggestions from a standardized skill (ENG-371). + +Reads the skill's SKILL.md from the agent drive, asks the tenant's default +reasoning model once (a plain LLM call, never an agent run), and returns +*draft* suggestions only — nothing is persisted here. The frontend prefills +the TOOLS box (``inferred from `` badge) and the Pre-Authorize ENV +panel, and saving still goes through the composer's full shell/env/secret/ +dangerous-command validation, so inference opens no bypass. + +ENV suggestions carry only ``key`` + ``reason`` — the model never produces a +value; users fill those in themselves and the runtime injects ``$VAR`` only. +""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +import json_repair +from pydantic import BaseModel, Field, ValidationError +from sqlalchemy import select + +from core.errors.error import ProviderTokenNotInitError +from core.model_manager import ModelManager +from extensions.ext_database import db +from graphon.model_runtime.entities.message_entities import SystemPromptMessage, UserPromptMessage +from graphon.model_runtime.entities.model_entities import ModelType +from models.agent import Agent +from models.agent_config_entities import AgentSoulConfig +from services.agent_drive_service import AgentDriveError, AgentDriveService + +logger = logging.getLogger(__name__) + + +class SkillToolInferenceError(Exception): + """Stable-code error for the infer-tools endpoint.""" + + def __init__(self, code: str, message: str, *, status_code: int = 400) -> None: + self.code = code + self.message = message + self.status_code = status_code + super().__init__(message) + + +class EnvSuggestion(BaseModel): + key: str + reason: str = "" + secret_likely: bool = False + + +class CliToolSuggestion(BaseModel): + name: str + description: str = "" + command: str = "" + install_commands: list[str] = Field(default_factory=list) + env_suggestions: list[EnvSuggestion] = Field(default_factory=list) + inferred_from: str = "" + + +class SkillToolInferenceResult(BaseModel): + inferable: bool + cli_tools: list[CliToolSuggestion] = Field(default_factory=list) + reason: str | None = None + + +_SYSTEM_PROMPT = """\ +You analyze an agent skill document (SKILL.md) and infer which command-line \ +tools the skill depends on at runtime, so a user can pre-install them in the \ +agent's sandbox. + +Rules: +- Only suggest tools the document explicitly uses or clearly requires; never guess. +- For each tool give: name, a one-line reason-style description referencing the \ +document, the base command, and install commands for a Debian-based sandbox \ +(apt-get / pip / npm). +- If a step needs an environment variable (an API key, token, endpoint), add it \ +to env_suggestions with the variable key and the reason. NEVER produce a value. \ +Mark secret_likely=true for credentials. +- If the document describes no external command-line dependency, return \ +{"inferable": false, "cli_tools": [], "reason": ""}. + +Respond with JSON only, matching exactly: +{"inferable": bool, + "cli_tools": [{"name": str, "description": str, "command": str, + "install_commands": [str], "env_suggestions": + [{"key": str, "reason": str, "secret_likely": bool}]}], + "reason": str | null} +""" + + +class SkillToolInferenceService: + """Single-shot LLM inference over a drive-stored SKILL.md.""" + + def __init__(self, *, drive_service: AgentDriveService | None = None) -> None: + self._drive = drive_service or AgentDriveService() + + def infer(self, *, tenant_id: str, agent_id: str, slug: str) -> dict[str, Any]: + skill_md = self._load_skill_md(tenant_id=tenant_id, agent_id=agent_id, slug=slug) + manifest_files = self._manifest_files_from_soul(tenant_id=tenant_id, agent_id=agent_id, slug=slug) + + user_prompt = f"SKILL.md of skill '{slug}':\n\n{skill_md}" + if manifest_files: + listing = "\n".join(manifest_files[:200]) + user_prompt += f"\n\nFiles inside the skill package:\n{listing}" + + raw = self._invoke(tenant_id=tenant_id, user_prompt=user_prompt) + try: + result = self._parse(raw) + except (ValidationError, ValueError): + logger.warning("skill tool inference output unparsable, retrying once") + raw = self._invoke(tenant_id=tenant_id, user_prompt=user_prompt) + try: + result = self._parse(raw) + except (ValidationError, ValueError) as exc: + raise SkillToolInferenceError( + "inference_failed", + "inference_failed: the model output could not be parsed into tool suggestions.", + status_code=422, + ) from exc + + for tool in result.cli_tools: + tool.inferred_from = slug + return result.model_dump(mode="json") + + def _load_skill_md(self, *, tenant_id: str, agent_id: str, slug: str) -> str: + try: + preview = self._drive.preview(tenant_id=tenant_id, agent_id=agent_id, key=f"{slug}/SKILL.md") + except AgentDriveError as exc: + if exc.code == "drive_key_not_found": + raise SkillToolInferenceError( + "skill_not_found", f"skill_not_found: no drive entry for skill '{slug}'.", status_code=404 + ) from exc + raise SkillToolInferenceError(exc.code, exc.message, status_code=exc.status_code) from exc + if preview["binary"] or not preview["text"]: + raise SkillToolInferenceError( + "skill_not_found", f"skill_not_found: SKILL.md of '{slug}' is not readable text.", status_code=404 + ) + return str(preview["text"]) + + @staticmethod + def _manifest_files_from_soul(*, tenant_id: str, agent_id: str, slug: str) -> list[str]: + """The zip path listing standardize persisted onto the ref, if present. + + Degrades to an empty list (SKILL.md-only inference) for refs that + predate ``manifest_files``. + """ + agent = db.session.scalar(select(Agent).where(Agent.tenant_id == tenant_id, Agent.id == agent_id).limit(1)) + if agent is None or not agent.active_config_snapshot_id: + return [] + from models.agent import AgentConfigSnapshot + + snapshot = db.session.scalar( + select(AgentConfigSnapshot).where( + AgentConfigSnapshot.tenant_id == tenant_id, + AgentConfigSnapshot.agent_id == agent_id, + AgentConfigSnapshot.id == agent.active_config_snapshot_id, + ) + ) + if snapshot is None: + return [] + soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict) + for skill in soul.skills_files.skills: + ref_slug = (skill.skill_md_key or "").split("/", 1)[0] or (skill.path or "").strip("/") + if ref_slug != slug: + continue + files = skill.get("manifest_files") + if isinstance(files, list): + return [str(item) for item in files] + return [] + + @staticmethod + def _invoke(*, tenant_id: str, user_prompt: str) -> str: + try: + model_manager = ModelManager.for_tenant(tenant_id=tenant_id) + model_instance = model_manager.get_default_model_instance(tenant_id=tenant_id, model_type=ModelType.LLM) + except ProviderTokenNotInitError as exc: + raise SkillToolInferenceError( + "default_model_not_configured", + "default_model_not_configured: the workspace has no default reasoning model.", + status_code=400, + ) from exc + try: + response = model_instance.invoke_llm( + prompt_messages=[ + SystemPromptMessage(content=_SYSTEM_PROMPT), + UserPromptMessage(content=user_prompt), + ], + model_parameters={"temperature": 0.1}, + stream=False, + ) + except Exception as exc: + raise SkillToolInferenceError( + "inference_failed", f"inference_failed: model invocation failed: {exc}", status_code=422 + ) from exc + return response.message.get_text_content() + + @staticmethod + def _parse(raw: str) -> SkillToolInferenceResult: + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + parsed = json_repair.loads(raw) + if not isinstance(parsed, dict): + raise ValueError("model output is not a JSON object") + return SkillToolInferenceResult.model_validate(parsed) + + +__all__ = [ + "CliToolSuggestion", + "EnvSuggestion", + "SkillToolInferenceError", + "SkillToolInferenceResult", + "SkillToolInferenceService", +] diff --git a/api/services/agent/workflow_publish_service.py b/api/services/agent/workflow_publish_service.py index af3e5112290..3d39419d794 100644 --- a/api/services/agent/workflow_publish_service.py +++ b/api/services/agent/workflow_publish_service.py @@ -1,17 +1,69 @@ from __future__ import annotations +import copy +from collections.abc import Mapping +from typing import Any, cast + +from pydantic import ValidationError from sqlalchemy import select from sqlalchemy.orm import Session from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidator -from models.agent import WorkflowAgentNodeBinding -from models.agent_config_entities import WorkflowNodeJobConfig +from models.agent import ( + Agent, + AgentConfigSnapshot, + AgentScope, + AgentStatus, + WorkflowAgentBindingType, + WorkflowAgentNodeBinding, +) +from models.agent_config_entities import DeclaredOutputConfig, WorkflowNodeJobConfig from models.workflow import Workflow class WorkflowAgentPublishService: """Validate and freeze Workflow Agent v2 bindings during workflow publish.""" + _DRAFT_WORKFLOW_VERSION = Workflow.VERSION_DRAFT + _AGENT_BINDING_KEY = "agent_binding" + _AGENT_TASK_KEY = "agent_task" + _AGENT_DECLARED_OUTPUTS_KEY = "agent_declared_outputs" + + @classmethod + def project_draft_bindings_to_graph(cls, *, session: Session, draft_workflow: Workflow) -> dict[str, Any]: + """Return draft graph with persisted Agent node job config projected into node data. + + Workflow draft graph is the front-end's editing source of truth, while + runtime/publish reads WorkflowAgentNodeBinding.node_job_config. This + response-only projection keeps reads aligned without writing binding + details back into the stored graph JSON. + """ + graph = cast(dict[str, Any], copy.deepcopy(draft_workflow.graph_dict)) + agent_nodes = dict(WorkflowAgentNodeValidator.iter_agent_v2_nodes(graph)) + if not agent_nodes: + return graph + + bindings = session.scalars( + select(WorkflowAgentNodeBinding).where( + WorkflowAgentNodeBinding.tenant_id == draft_workflow.tenant_id, + WorkflowAgentNodeBinding.app_id == draft_workflow.app_id, + WorkflowAgentNodeBinding.workflow_id == draft_workflow.id, + WorkflowAgentNodeBinding.workflow_version == cls._DRAFT_WORKFLOW_VERSION, + WorkflowAgentNodeBinding.node_id.in_(list(agent_nodes.keys())), + ) + ).all() + for binding in bindings: + node_data = agent_nodes.get(binding.node_id) + if not isinstance(node_data, dict): + continue + node_job = WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict) + if node_job.workflow_prompt is not None: + node_data[cls._AGENT_TASK_KEY] = node_job.workflow_prompt + node_data[cls._AGENT_DECLARED_OUTPUTS_KEY] = [ + output.model_dump(mode="json") for output in node_job.declared_outputs + ] + return graph + @classmethod def validate_agent_nodes_for_publish(cls, *, session: Session, draft_workflow: Workflow) -> None: WorkflowAgentNodeValidator.validate_published_workflow(session=session, workflow=draft_workflow) @@ -20,6 +72,229 @@ class WorkflowAgentPublishService: def validate_agent_nodes_for_draft_sync(cls, *, session: Session, draft_workflow: Workflow) -> None: WorkflowAgentNodeValidator.validate_draft_workflow(session=session, workflow=draft_workflow) + @classmethod + def sync_agent_bindings_for_draft( + cls, + *, + session: Session, + draft_workflow: Workflow, + account_id: str, + ) -> None: + agent_nodes = dict(WorkflowAgentNodeValidator.iter_agent_v2_nodes(draft_workflow.graph_dict)) + existing_bindings = list( + session.scalars( + select(WorkflowAgentNodeBinding).where( + WorkflowAgentNodeBinding.tenant_id == draft_workflow.tenant_id, + WorkflowAgentNodeBinding.app_id == draft_workflow.app_id, + WorkflowAgentNodeBinding.workflow_id == draft_workflow.id, + WorkflowAgentNodeBinding.workflow_version == cls._DRAFT_WORKFLOW_VERSION, + ) + ).all() + ) + existing_by_node_id = {binding.node_id: binding for binding in existing_bindings} + + for binding in existing_bindings: + if binding.node_id not in agent_nodes: + session.delete(binding) + + for node_id, node_data in agent_nodes.items(): + binding_payload = node_data.get(cls._AGENT_BINDING_KEY) + if binding_payload is None: + continue + if not isinstance(binding_payload, Mapping): + raise ValueError(f"Workflow Agent node {node_id} has invalid agent_binding.") + cls._sync_agent_binding_for_node( + session=session, + draft_workflow=draft_workflow, + node_id=node_id, + node_data=node_data, + node_binding=binding_payload, + existing_binding=existing_by_node_id.get(node_id), + account_id=account_id, + ) + session.flush() + + @classmethod + def sync_roster_agent_bindings_for_draft( + cls, + *, + session: Session, + draft_workflow: Workflow, + account_id: str, + ) -> None: + cls.sync_agent_bindings_for_draft( + session=session, + draft_workflow=draft_workflow, + account_id=account_id, + ) + + @classmethod + def _sync_agent_binding_for_node( + cls, + *, + session: Session, + draft_workflow: Workflow, + node_id: str, + node_data: Mapping[str, Any], + node_binding: Mapping[str, Any], + existing_binding: WorkflowAgentNodeBinding | None, + account_id: str, + ) -> None: + binding_type = node_binding.get("binding_type") + agent_id = node_binding.get("agent_id") + if not isinstance(agent_id, str) or not agent_id: + raise ValueError(f"Workflow Agent node {node_id} agent binding requires agent_id.") + + if binding_type == WorkflowAgentBindingType.ROSTER_AGENT.value: + agent, current_snapshot_id = cls._resolve_roster_agent_graph_binding( + session=session, + draft_workflow=draft_workflow, + node_id=node_id, + agent_id=agent_id, + ) + resolved_binding_type = WorkflowAgentBindingType.ROSTER_AGENT + elif binding_type == WorkflowAgentBindingType.INLINE_AGENT.value: + raw_current_snapshot_id = node_binding.get("current_snapshot_id") + if not isinstance(raw_current_snapshot_id, str) or not raw_current_snapshot_id: + raise ValueError(f"Workflow Agent node {node_id} inline_agent binding requires current_snapshot_id.") + current_snapshot_id = raw_current_snapshot_id + agent = cls._resolve_inline_agent_graph_binding( + session=session, + draft_workflow=draft_workflow, + node_id=node_id, + agent_id=agent_id, + current_snapshot_id=current_snapshot_id, + ) + resolved_binding_type = WorkflowAgentBindingType.INLINE_AGENT + else: + raise ValueError(f"Workflow Agent node {node_id} has unsupported agent_binding type.") + + binding = existing_binding + node_job_config = cls._node_job_config_from_node_data( + existing_binding=existing_binding, + node_data=node_data, + ) + if binding is None: + binding = WorkflowAgentNodeBinding( + tenant_id=draft_workflow.tenant_id, + app_id=draft_workflow.app_id, + workflow_id=draft_workflow.id, + workflow_version=cls._DRAFT_WORKFLOW_VERSION, + node_id=node_id, + node_job_config=node_job_config, + created_by=account_id, + ) + session.add(binding) + else: + binding.node_job_config = node_job_config + + binding.binding_type = resolved_binding_type + binding.agent_id = agent.id + binding.current_snapshot_id = current_snapshot_id + binding.updated_by = account_id + + @classmethod + def _resolve_roster_agent_graph_binding( + cls, + *, + session: Session, + draft_workflow: Workflow, + node_id: str, + agent_id: str, + ) -> tuple[Agent, str]: + agent = session.scalar( + select(Agent) + .where( + Agent.tenant_id == draft_workflow.tenant_id, + Agent.id == agent_id, + Agent.scope == AgentScope.ROSTER, + Agent.status == AgentStatus.ACTIVE, + ) + .limit(1) + ) + if agent is None: + raise ValueError(f"Workflow Agent node {node_id} references an unavailable roster agent.") + if agent.scope != AgentScope.ROSTER: + raise ValueError(f"Workflow Agent node {node_id} roster_agent binding must reference a roster agent.") + if not agent.active_config_snapshot_id: + raise ValueError(f"Workflow Agent node {node_id} roster agent has no active config snapshot.") + return agent, agent.active_config_snapshot_id + + @classmethod + def _resolve_inline_agent_graph_binding( + cls, + *, + session: Session, + draft_workflow: Workflow, + node_id: str, + agent_id: str, + current_snapshot_id: str, + ) -> Agent: + agent = session.scalar( + select(Agent) + .where( + Agent.tenant_id == draft_workflow.tenant_id, + Agent.id == agent_id, + Agent.scope == AgentScope.WORKFLOW_ONLY, + Agent.app_id == draft_workflow.app_id, + Agent.workflow_id == draft_workflow.id, + Agent.workflow_node_id == node_id, + Agent.status == AgentStatus.ACTIVE, + ) + .limit(1) + ) + if agent is None: + raise ValueError(f"Workflow Agent node {node_id} references an unavailable inline agent.") + if ( + agent.scope != AgentScope.WORKFLOW_ONLY + or agent.app_id != draft_workflow.app_id + or agent.workflow_id != draft_workflow.id + or agent.workflow_node_id != node_id + ): + raise ValueError(f"Workflow Agent node {node_id} inline_agent binding does not belong to this node.") + + snapshot = session.scalar( + select(AgentConfigSnapshot) + .where( + AgentConfigSnapshot.tenant_id == draft_workflow.tenant_id, + AgentConfigSnapshot.agent_id == agent.id, + AgentConfigSnapshot.id == current_snapshot_id, + ) + .limit(1) + ) + if snapshot is None or snapshot.agent_id != agent.id: + raise ValueError(f"Workflow Agent node {node_id} references a missing inline agent config snapshot.") + return agent + + @classmethod + def _node_job_config_from_node_data( + cls, + *, + existing_binding: WorkflowAgentNodeBinding | None, + node_data: Mapping[str, Any], + ) -> WorkflowNodeJobConfig: + if existing_binding and existing_binding.node_job_config: + node_job = WorkflowNodeJobConfig.model_validate(existing_binding.node_job_config_dict) + else: + node_job = WorkflowNodeJobConfig() + + agent_task = node_data.get(cls._AGENT_TASK_KEY) + if isinstance(agent_task, str): + node_job.workflow_prompt = agent_task + + declared_outputs_payload = node_data.get(cls._AGENT_DECLARED_OUTPUTS_KEY) + if declared_outputs_payload is not None: + if not isinstance(declared_outputs_payload, list): + raise ValueError("Workflow Agent node agent_declared_outputs must be a list.") + try: + node_job.declared_outputs = [ + DeclaredOutputConfig.model_validate(output) for output in declared_outputs_payload + ] + except ValidationError as exc: + raise ValueError("Workflow Agent node has invalid agent_declared_outputs.") from exc + + return node_job + @classmethod def copy_agent_node_bindings_to_published( cls, @@ -43,8 +318,26 @@ class WorkflowAgentPublishService: WorkflowAgentNodeBinding.node_id.in_(node_ids), ) ).all() + if not bindings: + return + + agents_by_id = { + agent.id: agent + for agent in session.scalars( + select(Agent).where( + Agent.tenant_id == draft_workflow.tenant_id, + Agent.id.in_({binding.agent_id for binding in bindings if binding.agent_id}), + ) + ).all() + } for binding in bindings: + agent = agents_by_id.get(binding.agent_id) if binding.agent_id else None + current_snapshot_id = ( + agent.active_config_snapshot_id + if agent is not None and binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT + else binding.current_snapshot_id + ) copied = WorkflowAgentNodeBinding( tenant_id=binding.tenant_id, app_id=binding.app_id, @@ -53,7 +346,7 @@ class WorkflowAgentPublishService: node_id=binding.node_id, binding_type=binding.binding_type, agent_id=binding.agent_id, - current_snapshot_id=binding.current_snapshot_id, + current_snapshot_id=current_snapshot_id, node_job_config=WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict), created_by=binding.created_by, updated_by=binding.updated_by, diff --git a/api/services/agent_app_sandbox_service.py b/api/services/agent_app_sandbox_service.py new file mode 100644 index 00000000000..8836c02291c --- /dev/null +++ b/api/services/agent_app_sandbox_service.py @@ -0,0 +1,225 @@ +"""Resolve and proxy sandbox file access for Agent App and workflow Agent sessions. + +These services keep product-facing locators (conversation, workflow run, node) +on the API boundary and translate them into the agent backend's +``SandboxLocator`` using persisted non-sensitive runtime layer specs plus the +saved Agenton session snapshot. +""" + +from __future__ import annotations + +from collections.abc import Callable + +from agenton.compositor import CompositorSessionSnapshot +from dify_agent.client import Client +from dify_agent.protocol import RuntimeLayerSpec, SandboxLocator, build_sandbox_locator_from_layer_specs +from pydantic import TypeAdapter +from sqlalchemy import select + +from configs import dify_config +from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore +from core.db.session_factory import session_factory +from models.agent import AgentRuntimeSessionOwnerType, WorkflowAgentRuntimeSession, WorkflowAgentRuntimeSessionStatus + +_RUNTIME_LAYER_SPECS_ADAPTER: TypeAdapter[list[RuntimeLayerSpec]] = TypeAdapter(list[RuntimeLayerSpec]) + + +class AgentSandboxInspectorError(Exception): + """A sandbox inspection failure mapped to an HTTP status by the controller.""" + + code: str + message: str + status_code: int + + def __init__(self, code: str, message: str, *, status_code: int = 400) -> None: + super().__init__(message) + self.code = code + self.message = message + self.status_code = status_code + + +class AgentAppSandboxService: + """List/read/upload files in an Agent App conversation sandbox.""" + + def __init__( + self, + *, + session_store: AgentAppRuntimeSessionStore | None = None, + client_factory: Callable[[], Client] | None = None, + ) -> None: + self._session_store = session_store or AgentAppRuntimeSessionStore() + self._client_factory = client_factory or _default_client_factory + + def list_files(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str): + locator = self._resolve_locator(tenant_id=tenant_id, app_id=app_id, conversation_id=conversation_id) + return self._client_factory().list_sandbox_files_sync(locator, path) + + def read_file(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str): + locator = self._resolve_locator(tenant_id=tenant_id, app_id=app_id, conversation_id=conversation_id) + return self._client_factory().read_sandbox_file_sync(locator, path) + + def upload_file(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str): + locator = self._resolve_locator(tenant_id=tenant_id, app_id=app_id, conversation_id=conversation_id) + return self._client_factory().upload_sandbox_file_sync(locator, path) + + def _resolve_locator(self, *, tenant_id: str, app_id: str, conversation_id: str) -> SandboxLocator: + stored = self._session_store.load_active_session_for_conversation( + tenant_id=tenant_id, + app_id=app_id, + conversation_id=conversation_id, + ) + if stored is None: + raise AgentSandboxInspectorError( + "no_active_session", + "this conversation has no active sandbox session yet", + status_code=404, + ) + return _build_locator_or_raise( + snapshot=stored.session_snapshot, + runtime_layer_specs=stored.runtime_layer_specs, + not_found_message="this conversation's agent has no sandbox workspace", + ) + + +class WorkflowAgentSandboxService: + """List/read/upload files in a workflow Agent node sandbox.""" + + def __init__(self, *, client_factory: Callable[[], Client] | None = None) -> None: + self._client_factory = client_factory or _default_client_factory + + def list_files( + self, + *, + tenant_id: str, + app_id: str, + workflow_run_id: str, + node_id: str, + node_execution_id: str | None, + path: str, + ): + locator = self._resolve_locator( + tenant_id=tenant_id, + app_id=app_id, + workflow_run_id=workflow_run_id, + node_id=node_id, + node_execution_id=node_execution_id, + ) + return self._client_factory().list_sandbox_files_sync(locator, path) + + def read_file( + self, + *, + tenant_id: str, + app_id: str, + workflow_run_id: str, + node_id: str, + node_execution_id: str | None, + path: str, + ): + locator = self._resolve_locator( + tenant_id=tenant_id, + app_id=app_id, + workflow_run_id=workflow_run_id, + node_id=node_id, + node_execution_id=node_execution_id, + ) + return self._client_factory().read_sandbox_file_sync(locator, path) + + def upload_file( + self, + *, + tenant_id: str, + app_id: str, + workflow_run_id: str, + node_id: str, + node_execution_id: str | None, + path: str, + ): + locator = self._resolve_locator( + tenant_id=tenant_id, + app_id=app_id, + workflow_run_id=workflow_run_id, + node_id=node_id, + node_execution_id=node_execution_id, + ) + return self._client_factory().upload_sandbox_file_sync(locator, path) + + def _resolve_locator( + self, + *, + tenant_id: str, + app_id: str, + workflow_run_id: str, + node_id: str, + node_execution_id: str | None, + ) -> SandboxLocator: + """Resolve one workflow Agent sandbox from product-facing identifiers. + + Callers may target either a specific node execution or the current node + as a whole. When ``node_execution_id`` is provided, lookup narrows to + that execution's ACTIVE runtime-session row. When it is omitted, the + service falls back to the most recently updated ACTIVE session for the + same ``workflow_run_id + node_id`` pair so console sandbox inspection can + still work from the broader workflow/node locator. + """ + stmt = select(WorkflowAgentRuntimeSession).where( + WorkflowAgentRuntimeSession.owner_type == AgentRuntimeSessionOwnerType.WORKFLOW_RUN, + WorkflowAgentRuntimeSession.tenant_id == tenant_id, + WorkflowAgentRuntimeSession.app_id == app_id, + WorkflowAgentRuntimeSession.workflow_run_id == workflow_run_id, + WorkflowAgentRuntimeSession.node_id == node_id, + WorkflowAgentRuntimeSession.status == WorkflowAgentRuntimeSessionStatus.ACTIVE, + ) + if node_execution_id: + stmt = stmt.where(WorkflowAgentRuntimeSession.node_execution_id == node_execution_id) + stmt = stmt.order_by(WorkflowAgentRuntimeSession.updated_at.desc()).limit(1) + + with session_factory.create_session() as session: + row = session.scalar(stmt) + + if row is None: + raise AgentSandboxInspectorError( + "no_active_session", + "this workflow Agent node has no active sandbox session yet", + status_code=404, + ) + return _build_locator_or_raise( + snapshot=CompositorSessionSnapshot.model_validate_json(row.session_snapshot), + runtime_layer_specs=_deserialize_runtime_layer_specs(row.composition_layer_specs), + not_found_message="this workflow Agent node has no sandbox workspace", + ) + + +def _build_locator_or_raise( + *, + snapshot: CompositorSessionSnapshot, + runtime_layer_specs: list[RuntimeLayerSpec], + not_found_message: str, +) -> SandboxLocator: + try: + return build_sandbox_locator_from_layer_specs( + layer_specs=runtime_layer_specs, + session_snapshot=snapshot, + ) + except ValueError as exc: + raise AgentSandboxInspectorError("no_sandbox", not_found_message, status_code=404) from exc + + +def _deserialize_runtime_layer_specs(value: str | None) -> list[RuntimeLayerSpec]: + if not value: + return [] + return _RUNTIME_LAYER_SPECS_ADAPTER.validate_json(value) + + +def _default_client_factory() -> Client: + base_url = dify_config.AGENT_BACKEND_BASE_URL + if not base_url: + raise AgentSandboxInspectorError( + "inspector_unavailable", + "the sandbox file inspector is not available (agent backend not configured)", + status_code=503, + ) + return Client(base_url=base_url) + + +__all__ = ["AgentAppSandboxService", "AgentSandboxInspectorError", "WorkflowAgentSandboxService"] diff --git a/api/services/agent_app_workspace_service.py b/api/services/agent_app_workspace_service.py deleted file mode 100644 index d16d5e4d45d..00000000000 --- a/api/services/agent_app_workspace_service.py +++ /dev/null @@ -1,220 +0,0 @@ -"""Resolve and proxy read-only access to an Agent App conversation's sandbox. - -The Agent App's shell layer runs bash in a per-conversation sandbox workspace on -the agent backend. The workspace identity (``session_id``) is generated inside -the shell layer and rides the conversation's ``session_snapshot``. This service -extracts that id and proxies list/preview/download to the agent backend's -read-only workspace endpoints, so the console can show a "sandbox file system" -inspector without the API ever touching shellctl directly. -""" - -from __future__ import annotations - -from collections.abc import Callable - -from agenton.compositor import CompositorSessionSnapshot -from sqlalchemy import select - -from clients.agent_backend.request_builder import DIFY_SHELL_LAYER_ID -from clients.agent_backend.workspace_files_client import ( - WorkspaceDownloadResult, - WorkspaceFilesBackendClient, - WorkspaceListResult, - WorkspacePreviewResult, -) -from configs import dify_config -from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore -from core.db.session_factory import session_factory -from models.agent import ( - AgentRuntimeSessionOwnerType, - WorkflowAgentRuntimeSession, - WorkflowAgentRuntimeSessionStatus, -) - - -class AgentWorkspaceInspectorError(Exception): - """A workspace inspection failure mapped to an HTTP status by the controller.""" - - code: str - message: str - status_code: int - - def __init__(self, code: str, message: str, *, status_code: int = 400) -> None: - super().__init__(message) - self.code = code - self.message = message - self.status_code = status_code - - -class AgentAppWorkspaceService: - """List/preview/download files in an Agent App conversation's sandbox workspace.""" - - def __init__( - self, - *, - session_store: AgentAppRuntimeSessionStore | None = None, - client_factory: Callable[[], WorkspaceFilesBackendClient] | None = None, - ) -> None: - self._session_store = session_store or AgentAppRuntimeSessionStore() - self._client_factory = client_factory or _default_client_factory - - def list_files(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str) -> WorkspaceListResult: - session_id = self._resolve_session_id(tenant_id=tenant_id, app_id=app_id, conversation_id=conversation_id) - return self._client_factory().list_files(session_id, path) - - def preview(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str) -> WorkspacePreviewResult: - session_id = self._resolve_session_id(tenant_id=tenant_id, app_id=app_id, conversation_id=conversation_id) - return self._client_factory().preview(session_id, path) - - def download(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str) -> WorkspaceDownloadResult: - session_id = self._resolve_session_id(tenant_id=tenant_id, app_id=app_id, conversation_id=conversation_id) - return self._client_factory().download(session_id, path) - - def _resolve_session_id(self, *, tenant_id: str, app_id: str, conversation_id: str) -> str: - snapshot = self._session_store.load_active_snapshot_for_conversation( - tenant_id=tenant_id, app_id=app_id, conversation_id=conversation_id - ) - if snapshot is None: - raise AgentWorkspaceInspectorError( - "no_active_session", - "this conversation has no active sandbox session yet", - status_code=404, - ) - session_id = _shell_session_id(snapshot) - if not session_id: - raise AgentWorkspaceInspectorError( - "no_sandbox", - "this conversation's agent has no sandbox workspace", - status_code=404, - ) - return session_id - - -class WorkflowAgentWorkspaceService: - """List/preview/download files in a Workflow Agent node sandbox workspace.""" - - def __init__( - self, - *, - client_factory: Callable[[], WorkspaceFilesBackendClient] | None = None, - ) -> None: - self._client_factory = client_factory or _default_client_factory - - def list_files( - self, - *, - tenant_id: str, - app_id: str, - workflow_run_id: str, - node_id: str, - node_execution_id: str | None, - path: str, - ) -> WorkspaceListResult: - session_id = self._resolve_session_id( - tenant_id=tenant_id, - app_id=app_id, - workflow_run_id=workflow_run_id, - node_id=node_id, - node_execution_id=node_execution_id, - ) - return self._client_factory().list_files(session_id, path) - - def preview( - self, - *, - tenant_id: str, - app_id: str, - workflow_run_id: str, - node_id: str, - node_execution_id: str | None, - path: str, - ) -> WorkspacePreviewResult: - session_id = self._resolve_session_id( - tenant_id=tenant_id, - app_id=app_id, - workflow_run_id=workflow_run_id, - node_id=node_id, - node_execution_id=node_execution_id, - ) - return self._client_factory().preview(session_id, path) - - def download( - self, - *, - tenant_id: str, - app_id: str, - workflow_run_id: str, - node_id: str, - node_execution_id: str | None, - path: str, - ) -> WorkspaceDownloadResult: - session_id = self._resolve_session_id( - tenant_id=tenant_id, - app_id=app_id, - workflow_run_id=workflow_run_id, - node_id=node_id, - node_execution_id=node_execution_id, - ) - return self._client_factory().download(session_id, path) - - def _resolve_session_id( - self, - *, - tenant_id: str, - app_id: str, - workflow_run_id: str, - node_id: str, - node_execution_id: str | None, - ) -> str: - stmt = select(WorkflowAgentRuntimeSession).where( - WorkflowAgentRuntimeSession.owner_type == AgentRuntimeSessionOwnerType.WORKFLOW_RUN, - WorkflowAgentRuntimeSession.tenant_id == tenant_id, - WorkflowAgentRuntimeSession.app_id == app_id, - WorkflowAgentRuntimeSession.workflow_run_id == workflow_run_id, - WorkflowAgentRuntimeSession.node_id == node_id, - WorkflowAgentRuntimeSession.status == WorkflowAgentRuntimeSessionStatus.ACTIVE, - ) - if node_execution_id: - stmt = stmt.where(WorkflowAgentRuntimeSession.node_execution_id == node_execution_id) - stmt = stmt.order_by(WorkflowAgentRuntimeSession.updated_at.desc()).limit(1) - - with session_factory.create_session() as session: - row = session.scalar(stmt) - - if row is None: - raise AgentWorkspaceInspectorError( - "no_active_session", - "this workflow Agent node has no active sandbox session yet", - status_code=404, - ) - snapshot = CompositorSessionSnapshot.model_validate_json(row.session_snapshot) - session_id = _shell_session_id(snapshot) - if not session_id: - raise AgentWorkspaceInspectorError( - "no_sandbox", - "this workflow Agent node has no sandbox workspace", - status_code=404, - ) - return session_id - - -def _shell_session_id(snapshot: CompositorSessionSnapshot) -> str | None: - for layer in snapshot.layers: - if layer.name == DIFY_SHELL_LAYER_ID: - session_id = layer.runtime_state.get("session_id") - return session_id if isinstance(session_id, str) and session_id else None - return None - - -def _default_client_factory() -> WorkspaceFilesBackendClient: - base_url = dify_config.AGENT_BACKEND_BASE_URL - if not base_url: - raise AgentWorkspaceInspectorError( - "inspector_unavailable", - "the sandbox file inspector is not available (agent backend not configured)", - status_code=503, - ) - return WorkspaceFilesBackendClient(base_url) - - -__all__ = ["AgentAppWorkspaceService", "AgentWorkspaceInspectorError", "WorkflowAgentWorkspaceService"] diff --git a/api/services/agent_drive_service.py b/api/services/agent_drive_service.py index 74eb4e8751f..276f6339b8f 100644 --- a/api/services/agent_drive_service.py +++ b/api/services/agent_drive_service.py @@ -131,6 +131,7 @@ class AgentDriveService: "mime_type": row.mime_type, "file_kind": row.file_kind.value, "file_id": row.file_id, + "created_at": int(row.created_at.timestamp()) if row.created_at else None, } if include_download_url: item["download_url"] = self._resolve_download_url( @@ -169,6 +170,52 @@ class AgentDriveService: self._delete_storage(storage_key) return committed + def delete( + self, + *, + tenant_id: str, + agent_id: str, + prefix: str | None = None, + key: str | None = None, + ) -> list[str]: + """Delete drive entries by exact ``key`` or by ``prefix`` (ENG-625 D5). + + Drive-owned values get their backing record + storage object cleaned via + the same ``_cleanup_value`` path commit-overwrite uses; shared values only + lose the KV row. Idempotent: deleting nothing returns ``[]``. + """ + if (prefix is None) == (key is None): + raise AgentDriveError("invalid_delete_scope", "delete requires exactly one of prefix or key") + removed_keys: list[str] = [] + pending_storage_deletes: list[str] = [] + with session_factory.create_session() as session: + self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id) + stmt = select(AgentDriveFile).where( + AgentDriveFile.tenant_id == tenant_id, + AgentDriveFile.agent_id == agent_id, + ) + if key is not None: + stmt = stmt.where(AgentDriveFile.key == normalize_drive_key(key)) + else: + stmt = stmt.where(AgentDriveFile.key.startswith(normalize_drive_key(prefix or ""))) + rows = list(session.scalars(stmt)) + for row in rows: + if row.value_owned_by_drive: + self._cleanup_value( + session, + tenant_id=tenant_id, + file_kind=row.file_kind, + file_id=row.file_id, + exclude_row_id=row.id, + pending_storage_deletes=pending_storage_deletes, + ) + removed_keys.append(row.key) + session.delete(row) + session.commit() + for storage_key in pending_storage_deletes: + self._delete_storage(storage_key) + return removed_keys + def _commit_one( self, session: Session, @@ -338,7 +385,12 @@ class AgentDriveService: logger.warning("failed to delete drive storage object %s", storage_key, exc_info=True) @staticmethod - def _resolve_download_url(*, tenant_id: str, file_kind: AgentDriveFileKind, file_id: str) -> str | None: + def _resolve_download_url( + *, tenant_id: str, file_kind: AgentDriveFileKind, file_id: str, for_external: bool = False + ) -> str | None: + """Signed URL for a drive value. ``for_external`` selects the audience: + the inner manifest hands agents *internal* URLs, while the console + inspector must hand browsers *external* ones — never mix the two.""" if file_kind == AgentDriveFileKind.TOOL_FILE: mapping: dict[str, Any] = {"transfer_method": "tool_file", "tool_file_id": file_id} else: @@ -349,10 +401,86 @@ class AgentDriveService: # No FileAccessScope bound -> drive-owned: the builders still filter by # tenant_id, so resolution is tenant-scoped without user-level checks. file = file_factory.build_from_mapping(mapping=mapping, tenant_id=tenant_id, access_controller=controller) - return runtime.resolve_file_url(file=file, for_external=False) + return runtime.resolve_file_url(file=file, for_external=for_external) except ValueError: return None + # ── console drive inspector (ENG-624) ──────────────────────────────────── + + # SKILL.md is the primary preview use case; 64 KiB covers it with headroom + # while keeping the console payload bounded. + PREVIEW_MAX_BYTES = 64 * 1024 + + def _require_row(self, session: Session, *, tenant_id: str, agent_id: str, key: str) -> AgentDriveFile: + row = session.scalar( + select(AgentDriveFile).where( + AgentDriveFile.tenant_id == tenant_id, + AgentDriveFile.agent_id == agent_id, + AgentDriveFile.key == normalize_drive_key(key), + ) + ) + if row is None: + raise AgentDriveError("drive_key_not_found", "no drive entry for this key", status_code=404) + return row + + def _storage_key_for_row(self, session: Session, *, tenant_id: str, row: AgentDriveFile) -> str: + if row.file_kind == AgentDriveFileKind.TOOL_FILE: + tool_file = session.scalar( + select(ToolFile).where(ToolFile.id == row.file_id, ToolFile.tenant_id == tenant_id) + ) + if tool_file is None: + raise AgentDriveError("drive_key_not_found", "drive value record is missing", status_code=404) + return tool_file.file_key + upload_file = session.scalar( + select(UploadFile).where(UploadFile.id == row.file_id, UploadFile.tenant_id == tenant_id) + ) + if upload_file is None: + raise AgentDriveError("drive_key_not_found", "drive value record is missing", status_code=404) + return upload_file.key + + def preview(self, *, tenant_id: str, agent_id: str, key: str) -> dict[str, Any]: + """Truncated text preview of one drive value (binary-safe, never 500s on size).""" + with session_factory.create_session() as session: + self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id) + row = self._require_row(session, tenant_id=tenant_id, agent_id=agent_id, key=key) + storage_key = self._storage_key_for_row(session, tenant_id=tenant_id, row=row) + size = row.size + + data = bytearray() + for chunk in storage.load_stream(storage_key): + data.extend(chunk) + if len(data) > self.PREVIEW_MAX_BYTES: + break + truncated = len(data) > self.PREVIEW_MAX_BYTES + sample = bytes(data[: self.PREVIEW_MAX_BYTES]) + # Same semantics as the sandbox read endpoint: NUL or undecodable -> binary. + if b"\x00" in sample: + return {"key": row.key, "size": size, "truncated": truncated, "binary": True, "text": None} + try: + text = sample.decode("utf-8") + except UnicodeDecodeError: + if truncated: + # A multi-byte char may sit on the cut point; retry without the tail. + try: + text = sample[:-3].decode("utf-8", errors="strict") + except UnicodeDecodeError: + return {"key": row.key, "size": size, "truncated": truncated, "binary": True, "text": None} + else: + return {"key": row.key, "size": size, "truncated": truncated, "binary": True, "text": None} + return {"key": row.key, "size": size, "truncated": truncated, "binary": False, "text": text} + + def download_url(self, *, tenant_id: str, agent_id: str, key: str) -> str: + """External signed URL for a browser download of one drive value.""" + with session_factory.create_session() as session: + self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id) + row = self._require_row(session, tenant_id=tenant_id, agent_id=agent_id, key=key) + url = self._resolve_download_url( + tenant_id=tenant_id, file_kind=row.file_kind, file_id=row.file_id, for_external=True + ) + if url is None: + raise AgentDriveError("drive_key_not_found", "drive value cannot be resolved", status_code=404) + return url + __all__ = [ "AgentDriveError", diff --git a/api/services/app_service.py b/api/services/app_service.py index e9741b8e238..c435a672520 100644 --- a/api/services/app_service.py +++ b/api/services/app_service.py @@ -1,7 +1,8 @@ import json import logging from collections.abc import Sequence -from typing import Any, Literal, TypedDict, cast, override +from datetime import datetime +from typing import Any, Literal, NotRequired, TypedDict, cast, override import sqlalchemy as sa from flask_sqlalchemy.pagination import Pagination @@ -22,8 +23,8 @@ from graphon.model_runtime.entities.model_entities import ModelPropertyKey, Mode from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel from libs.datetime_utils import naive_utc_now from libs.login import current_user -from models import Account -from models.agent import AgentIconType +from models import Account, AppStar +from models.agent import Agent, AgentIconType, AgentScope, AgentSource, AgentStatus from models.model import App, AppMode, AppModelConfig, IconType, Site from models.tools import ApiToolProvider from services.billing_service import BillingService @@ -35,23 +36,34 @@ from tasks.remove_app_and_related_data_task import remove_app_and_related_data_t logger = logging.getLogger(__name__) +AppListSortBy = Literal["last_modified", "recently_created", "earliest_created"] -class AppListParams(BaseModel): + +class AppListBaseParams(BaseModel): page: int = Field(default=1, ge=1) limit: int = Field(default=20, ge=1, le=100) mode: Literal["completion", "chat", "advanced-chat", "workflow", "agent-chat", "agent", "channel", "all"] = "all" + sort_by: AppListSortBy = "last_modified" name: str | None = None tag_ids: list[str] | None = None creator_ids: list[str] | None = None is_created_by_me: bool | None = None + + +class AppListParams(AppListBaseParams): status: str | None = None openapi_visible: bool = False +class StarredAppListParams(AppListBaseParams): + pass + + class CreateAppParams(BaseModel): name: str = Field(min_length=1) description: str | None = None mode: Literal["chat", "agent-chat", "agent", "advanced-chat", "workflow", "completion"] + agent_role: str = Field(default="", max_length=255) icon_type: str | None = None icon: str | None = None icon_background: str | None = None @@ -61,6 +73,85 @@ class CreateAppParams(BaseModel): class AppService: + @staticmethod + def _build_app_list_filters( + user_id: str, tenant_id: str, params: AppListBaseParams + ) -> list[sa.ColumnElement[bool]]: + filters = [App.tenant_id == tenant_id, App.is_universal == False] + + if params.mode == "workflow": + filters.append(App.mode == AppMode.WORKFLOW) + elif params.mode == "completion": + filters.append(App.mode == AppMode.COMPLETION) + elif params.mode == "chat": + filters.append(App.mode == AppMode.CHAT) + elif params.mode == "advanced-chat": + filters.append(App.mode == AppMode.ADVANCED_CHAT) + elif params.mode == "agent-chat": + filters.append(App.mode == AppMode.AGENT_CHAT) + elif params.mode == "agent": + filters.append(App.mode == AppMode.AGENT) + elif params.mode == "all": + filters.append(App.mode != AppMode.AGENT) + + if isinstance(params, AppListParams): + if params.status: + filters.append(App.status == params.status) + # OpenAPI surface visibility gate. Pushed into the query so + # `pagination.total` reflects only apps the openapi caller can + # actually reach; post-filtering by enable_api after the page + # arrives would make `total` page-dependent. + if params.openapi_visible: + filters.append(App.enable_api.is_(True)) + + if params.is_created_by_me: + filters.append(App.created_by == user_id) + if params.creator_ids: + filters.append(App.created_by.in_(params.creator_ids)) + if params.name: + from libs.helper import escape_like_pattern + + name = params.name[:30] + escaped_name = escape_like_pattern(name) + filters.append(App.name.ilike(f"%{escaped_name}%", escape="\\")) + if params.tag_ids and len(params.tag_ids) > 0: + target_ids = TagService.get_target_ids_by_tag_ids("app", tenant_id, params.tag_ids, match_all=True) + if target_ids and len(target_ids) > 0: + filters.append(App.id.in_(target_ids)) + else: + return [] + + return filters + + @staticmethod + def _build_app_list_order_by(sort_by: AppListSortBy) -> sa.ColumnElement[Any]: + return { + "last_modified": App.updated_at.desc(), + "recently_created": App.created_at.desc(), + "earliest_created": App.created_at.asc(), + }[sort_by] + + @staticmethod + def get_starred_app_ids( + session: Session | scoped_session, + *, + tenant_id: str, + account_id: str, + app_ids: Sequence[str], + ) -> set[str]: + """Return app IDs starred by this account within the tenant.""" + if not app_ids: + return set() + + starred_app_ids = session.scalars( + select(AppStar.app_id).where( + AppStar.tenant_id == tenant_id, + AppStar.account_id == account_id, + AppStar.app_id.in_(list(app_ids)), + ) + ).all() + return set(starred_app_ids) + @staticmethod def get_app_by_id( session: Session | scoped_session, @@ -108,61 +199,104 @@ class AppService: def get_paginate_apps(self, user_id: str, tenant_id: str, params: AppListParams) -> Pagination | None: """ - Get app list with pagination + Get app list with pagination, filters, and explicit sort order. :param user_id: user id :param tenant_id: tenant id :param params: query parameters :return: """ - filters = [App.tenant_id == tenant_id, App.is_universal == False] + filters = self._build_app_list_filters(user_id, tenant_id, params) + if not filters: + return None - if params.mode == "workflow": - filters.append(App.mode == AppMode.WORKFLOW) - elif params.mode == "completion": - filters.append(App.mode == AppMode.COMPLETION) - elif params.mode == "chat": - filters.append(App.mode == AppMode.CHAT) - elif params.mode == "advanced-chat": - filters.append(App.mode == AppMode.ADVANCED_CHAT) - elif params.mode == "agent-chat": - filters.append(App.mode == AppMode.AGENT_CHAT) - elif params.mode == "agent": - filters.append(App.mode == AppMode.AGENT) - - if params.status: - filters.append(App.status == params.status) - # OpenAPI surface visibility gate. Pushed into the query so - # `pagination.total` reflects only apps the openapi caller can - # actually reach — post-filtering by enable_api after the page - # arrives would make `total` page-dependent. - if params.openapi_visible: - filters.append(App.enable_api.is_(True)) - if params.is_created_by_me: - filters.append(App.created_by == user_id) - if params.creator_ids: - filters.append(App.created_by.in_(params.creator_ids)) - if params.name: - from libs.helper import escape_like_pattern - - name = params.name[:30] - escaped_name = escape_like_pattern(name) - filters.append(App.name.ilike(f"%{escaped_name}%", escape="\\")) - if params.tag_ids and len(params.tag_ids) > 0: - target_ids = TagService.get_target_ids_by_tag_ids("app", tenant_id, params.tag_ids, match_all=True) - if target_ids and len(target_ids) > 0: - filters.append(App.id.in_(target_ids)) - else: - return None + order_by = self._build_app_list_order_by(params.sort_by) app_models = db.paginate( - sa.select(App).where(*filters).order_by(App.created_at.desc()), + sa.select(App).where(*filters).order_by(order_by), page=params.page, per_page=params.limit, error_out=False, ) + app_ids = [str(app.id) for app in app_models.items] + starred_app_ids = self.get_starred_app_ids( + db.session, + tenant_id=tenant_id, + account_id=user_id, + app_ids=app_ids, + ) + for app in app_models.items: + app.is_starred = str(app.id) in starred_app_ids + return app_models + def get_paginate_starred_apps( + self, user_id: str, tenant_id: str, params: StarredAppListParams + ) -> Pagination | None: + """ + Get apps starred by the current account with pagination, filters, and explicit sort order. + """ + filters = self._build_app_list_filters(user_id, tenant_id, params) + if not filters: + return None + + order_by = self._build_app_list_order_by(params.sort_by) + app_models = db.paginate( + sa.select(App) + .join( + AppStar, + sa.and_( + AppStar.tenant_id == App.tenant_id, + AppStar.app_id == App.id, + AppStar.account_id == user_id, + ), + ) + .where(AppStar.tenant_id == tenant_id, *filters) + .order_by(order_by), + page=params.page, + per_page=params.limit, + error_out=False, + ) + + for app in app_models.items: + app.is_starred = True + + return app_models + + @staticmethod + def star_app(session: Session, *, app: App, account_id: str) -> None: + """Create the account's app star if it does not already exist.""" + existing_star = session.scalar( + select(AppStar) + .where( + AppStar.tenant_id == app.tenant_id, + AppStar.app_id == app.id, + AppStar.account_id == account_id, + ) + .limit(1) + ) + if existing_star: + return + + session.add(AppStar(tenant_id=app.tenant_id, app_id=app.id, account_id=account_id)) + + @staticmethod + def unstar_app(session: Session, *, app: App, account_id: str) -> None: + """Remove the account's app star if present.""" + existing_star = session.scalar( + select(AppStar) + .where( + AppStar.tenant_id == app.tenant_id, + AppStar.app_id == app.id, + AppStar.account_id == account_id, + ) + .limit(1) + ) + if not existing_star: + return + + session.delete(existing_star) + def create_app(self, tenant_id: str, params: CreateAppParams, account: Account) -> App: """ Create app @@ -281,6 +415,7 @@ class AppService: app_id=app.id, name=params.name, description=params.description or "", + role=params.agent_role, icon_type=icon_type, icon=params.icon, icon_background=params.icon_background, @@ -376,6 +511,66 @@ class AppService: icon_background: str use_icon_as_answer_icon: bool max_active_requests: int + role: NotRequired[str | None] + + @staticmethod + def _get_backing_agent_for_update(app: App) -> Agent | None: + if app.mode != AppMode.AGENT: + return None + return db.session.scalar( + select(Agent).where( + Agent.tenant_id == app.tenant_id, + Agent.app_id == app.id, + Agent.scope == AgentScope.ROSTER, + Agent.source == AgentSource.AGENT_APP, + Agent.status == AgentStatus.ACTIVE, + ) + ) + + @staticmethod + def _to_agent_icon_type(icon_type: IconType | str | None) -> AgentIconType | None: + if icon_type is None: + return None + value = icon_type.value if isinstance(icon_type, IconType) else icon_type + return AgentIconType(value) + + def _sync_backing_agent_identity( + self, + app: App, + *, + name: str | None = None, + description: str | None = None, + icon_type: IconType | str | None = None, + icon: str | None = None, + icon_background: str | None = None, + role: str | None = None, + account_id: str | None = None, + updated_at: datetime | None = None, + ) -> None: + """Keep the Roster identity aligned with its Agent App shell. + + Agent Soul remains versioned through Composer. This helper only mirrors + user-facing identity fields so Roster and Agent Console do not drift. + """ + agent = self._get_backing_agent_for_update(app) + if agent is None: + return + + if name is not None: + agent.name = name + if description is not None: + agent.description = description + if icon_type is not None: + agent.icon_type = self._to_agent_icon_type(icon_type) + if icon is not None: + agent.icon = icon + if icon_background is not None: + agent.icon_background = icon_background + if role is not None: + agent.role = role + agent.updated_by = account_id + if updated_at is not None: + agent.updated_at = updated_at def update_app(self, app: App, args: ArgsDict) -> App: """ @@ -400,6 +595,17 @@ class AppService: app.max_active_requests = args.get("max_active_requests") app.updated_by = current_user.id app.updated_at = naive_utc_now() + self._sync_backing_agent_identity( + app, + name=app.name, + description=app.description, + icon_type=app.icon_type, + icon=app.icon, + icon_background=app.icon_background, + role=args.get("role"), + account_id=current_user.id, + updated_at=app.updated_at, + ) db.session.commit() app_was_updated.send(app) @@ -417,6 +623,12 @@ class AppService: app.name = name app.updated_by = current_user.id app.updated_at = naive_utc_now() + self._sync_backing_agent_identity( + app, + name=app.name, + account_id=current_user.id, + updated_at=app.updated_at, + ) db.session.commit() app_was_updated.send(app) @@ -441,6 +653,14 @@ class AppService: app.icon_type = icon_type if isinstance(icon_type, IconType) else IconType(icon_type) app.updated_by = current_user.id app.updated_at = naive_utc_now() + self._sync_backing_agent_identity( + app, + icon_type=app.icon_type, + icon=app.icon, + icon_background=app.icon_background, + account_id=current_user.id, + updated_at=app.updated_at, + ) db.session.commit() app_was_updated.send(app) @@ -493,6 +713,16 @@ class AppService: """ app_was_deleted.send(app) + backing_agent = self._get_backing_agent_for_update(app) + if backing_agent is not None: + now = naive_utc_now() + account_id = getattr(current_user, "id", None) + backing_agent.status = AgentStatus.ARCHIVED + backing_agent.archived_by = account_id + backing_agent.archived_at = now + backing_agent.updated_by = account_id + backing_agent.updated_at = now + db.session.delete(app) db.session.commit() diff --git a/api/services/audio_service.py b/api/services/audio_service.py index c80b2f43fd4..a9024eb3bdd 100644 --- a/api/services/audio_service.py +++ b/api/services/audio_service.py @@ -30,7 +30,7 @@ logger = logging.getLogger(__name__) class AudioService: @classmethod - def transcript_asr(cls, app_model: App, file: FileStorage, end_user: str | None = None): + def transcript_asr(cls, app_model: App, file: FileStorage | None, end_user: str | None = None): if app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}: workflow = app_model.workflow if workflow is None: @@ -141,14 +141,14 @@ class AudioService: else: response = invoke_tts(text_content=message.answer, app_model=app_model, voice=voice, is_draft=is_draft) if isinstance(response, Generator): - return Response(stream_with_context(response), content_type="audio/mpeg") + return Response(stream_with_context(response), content_type="audio/mpeg") # type: ignore return response else: if text is None: raise ValueError("Text is required") response = invoke_tts(text_content=text, app_model=app_model, voice=voice, is_draft=is_draft) if isinstance(response, Generator): - return Response(stream_with_context(response), content_type="audio/mpeg") + return Response(stream_with_context(response), content_type="audio/mpeg") # type: ignore return response @classmethod diff --git a/api/services/auth/jina.py b/api/services/auth/jina.py index 0b49b88e987..c5e9ac2d1c5 100644 --- a/api/services/auth/jina.py +++ b/api/services/auth/jina.py @@ -43,10 +43,16 @@ class JinaAuth(ApiKeyAuthBase): def _handle_error(self, response): if response.status_code in {402, 409, 500}: - error_message = response.json().get("error", "Unknown error occurred") + try: + error_message = response.json().get("error", "Unknown error occurred") + except ValueError: + error_message = response.text or "Unknown error occurred" raise Exception(f"Failed to authorize. Status code: {response.status_code}. Error: {error_message}") else: if response.text: - error_message = json.loads(response.text).get("error", "Unknown error occurred") + try: + error_message = json.loads(response.text).get("error", "Unknown error occurred") + except ValueError: + error_message = response.text raise Exception(f"Failed to authorize. Status code: {response.status_code}. Error: {error_message}") raise Exception(f"Unexpected error occurred while trying to authorize. Status code: {response.status_code}") diff --git a/api/services/auth/jina/jina.py b/api/services/auth/jina/jina.py index d8d2fd51c0c..6260b6b48ad 100644 --- a/api/services/auth/jina/jina.py +++ b/api/services/auth/jina/jina.py @@ -43,10 +43,16 @@ class JinaAuth(ApiKeyAuthBase): def _handle_error(self, response): if response.status_code in {402, 409, 500}: - error_message = response.json().get("error", "Unknown error occurred") + try: + error_message = response.json().get("error", "Unknown error occurred") + except ValueError: + error_message = response.text or "Unknown error occurred" raise Exception(f"Failed to authorize. Status code: {response.status_code}. Error: {error_message}") else: if response.text: - error_message = json.loads(response.text).get("error", "Unknown error occurred") + try: + error_message = json.loads(response.text).get("error", "Unknown error occurred") + except ValueError: + error_message = response.text raise Exception(f"Failed to authorize. Status code: {response.status_code}. Error: {error_message}") raise Exception(f"Unexpected error occurred while trying to authorize. Status code: {response.status_code}") diff --git a/api/services/auth/watercrawl/watercrawl.py b/api/services/auth/watercrawl/watercrawl.py index d07c2cc3189..ac8ec24d893 100644 --- a/api/services/auth/watercrawl/watercrawl.py +++ b/api/services/auth/watercrawl/watercrawl.py @@ -37,10 +37,16 @@ class WatercrawlAuth(ApiKeyAuthBase): def _handle_error(self, response): if response.status_code in {402, 409, 500}: - error_message = response.json().get("error", "Unknown error occurred") + try: + error_message = response.json().get("error", "Unknown error occurred") + except ValueError: + error_message = response.text or "Unknown error occurred" raise Exception(f"Failed to authorize. Status code: {response.status_code}. Error: {error_message}") else: if response.text: - error_message = json.loads(response.text).get("error", "Unknown error occurred") + try: + error_message = json.loads(response.text).get("error", "Unknown error occurred") + except ValueError: + error_message = response.text raise Exception(f"Failed to authorize. Status code: {response.status_code}. Error: {error_message}") raise Exception(f"Unexpected error occurred while trying to authorize. Status code: {response.status_code}") diff --git a/api/services/document_indexing_proxy/document_indexing_task_proxy.py b/api/services/document_indexing_proxy/document_indexing_task_proxy.py index d9295899cb8..23aab28f814 100644 --- a/api/services/document_indexing_proxy/document_indexing_task_proxy.py +++ b/api/services/document_indexing_proxy/document_indexing_task_proxy.py @@ -1,4 +1,5 @@ -from typing import ClassVar +from collections.abc import Callable +from typing import Any, ClassVar from services.document_indexing_proxy.batch_indexing_base import BatchDocumentIndexingProxy from tasks.document_indexing_task import normal_document_indexing_task, priority_document_indexing_task @@ -8,5 +9,5 @@ class DocumentIndexingTaskProxy(BatchDocumentIndexingProxy): """Proxy for document indexing tasks.""" QUEUE_NAME: ClassVar[str] = "document_indexing" - NORMAL_TASK_FUNC = normal_document_indexing_task # pyrefly: ignore[missing-override-decorator] - PRIORITY_TASK_FUNC = priority_document_indexing_task # pyrefly: ignore[missing-override-decorator] + NORMAL_TASK_FUNC: ClassVar[Callable[..., Any]] = normal_document_indexing_task # pyrefly: ignore + PRIORITY_TASK_FUNC: ClassVar[Callable[..., Any]] = priority_document_indexing_task # pyrefly: ignore diff --git a/api/services/document_indexing_proxy/duplicate_document_indexing_task_proxy.py b/api/services/document_indexing_proxy/duplicate_document_indexing_task_proxy.py index 224cab1e143..88b25aff4f3 100644 --- a/api/services/document_indexing_proxy/duplicate_document_indexing_task_proxy.py +++ b/api/services/document_indexing_proxy/duplicate_document_indexing_task_proxy.py @@ -1,4 +1,5 @@ -from typing import ClassVar +from collections.abc import Callable +from typing import Any, ClassVar from services.document_indexing_proxy.batch_indexing_base import BatchDocumentIndexingProxy from tasks.duplicate_document_indexing_task import ( @@ -6,10 +7,12 @@ from tasks.duplicate_document_indexing_task import ( priority_duplicate_document_indexing_task, ) +TaskFunc = Callable[..., Any] + class DuplicateDocumentIndexingTaskProxy(BatchDocumentIndexingProxy): """Proxy for duplicate document indexing tasks.""" QUEUE_NAME: ClassVar[str] = "duplicate_document_indexing" - NORMAL_TASK_FUNC = normal_duplicate_document_indexing_task # pyrefly: ignore[missing-override-decorator] - PRIORITY_TASK_FUNC = priority_duplicate_document_indexing_task # pyrefly: ignore[missing-override-decorator] + NORMAL_TASK_FUNC: ClassVar[TaskFunc] = normal_duplicate_document_indexing_task # pyrefly: ignore + PRIORITY_TASK_FUNC: ClassVar[TaskFunc] = priority_duplicate_document_indexing_task # pyrefly: ignore diff --git a/api/services/enterprise/enterprise_service.py b/api/services/enterprise/enterprise_service.py index 50a39ef9270..d7dbd12973d 100644 --- a/api/services/enterprise/enterprise_service.py +++ b/api/services/enterprise/enterprise_service.py @@ -136,12 +136,13 @@ class EnterpriseService: tenant_id: str, app_id: str | None, audience: str, + user_type: str = "account", ) -> tuple[str, int]: """Mint a short-lived SSO id_token (or OAuth2 access_token) representing the calling Dify user, audience-scoped to the given MCP server identifier. Used by MCPTool.invoke_remote_mcp_tool to stamp the - X-Dify-SSO-Access-Token header on outbound MCP requests when the + X-Dify-SSO-Token header on outbound MCP requests when the provider's identity_mode is set to "idp_token". Returns: @@ -163,6 +164,7 @@ class EnterpriseService: "tenant_id": tenant_id, "app_id": app_id or "", "audience": audience, + "user_type": user_type, }, ) except EnterpriseServiceError as e: diff --git a/api/services/entities/agent_entities.py b/api/services/entities/agent_entities.py index e2730d1357a..63ae101533d 100644 --- a/api/services/entities/agent_entities.py +++ b/api/services/entities/agent_entities.py @@ -61,6 +61,7 @@ class ComposerSavePayload(BaseModel): class RosterAgentCreatePayload(BaseModel): name: str = Field(min_length=1, max_length=255) description: str = "" + role: str = Field(default="", max_length=255) icon_type: AgentIconType | None = None icon: str | None = Field(default=None, max_length=255) icon_background: str | None = Field(default=None, max_length=255) @@ -71,6 +72,7 @@ class RosterAgentCreatePayload(BaseModel): class RosterAgentUpdatePayload(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=255) description: str | None = None + role: str | None = Field(default=None, max_length=255) icon_type: AgentIconType | None = None icon: str | None = Field(default=None, max_length=255) icon_background: str | None = Field(default=None, max_length=255) diff --git a/api/services/entities/knowledge_entities/knowledge_entities.py b/api/services/entities/knowledge_entities/knowledge_entities.py index 4040b03cc49..1b233076927 100644 --- a/api/services/entities/knowledge_entities/knowledge_entities.py +++ b/api/services/entities/knowledge_entities/knowledge_entities.py @@ -1,6 +1,6 @@ from typing import Any, Literal -from pydantic import BaseModel, field_validator +from pydantic import BaseModel, Field, field_validator from core.rag.entities import Rule from core.rag.entities.metadata_entities import MetadataFilteringCondition @@ -100,7 +100,7 @@ class KnowledgeConfig(BaseModel): data_source: DataSource | None = None process_rule: ProcessRule | None = None retrieval_model: RetrievalModel | None = None - summary_index_setting: dict[str, Any] | None = None + summary_index_setting: dict[str, Any] | None = Field(default=None) doc_form: str = "text_model" doc_language: str = "English" embedding_model: str | None = None diff --git a/api/services/entities/model_provider_entities.py b/api/services/entities/model_provider_entities.py index 6679c08ebd7..020dc4a2ea9 100644 --- a/api/services/entities/model_provider_entities.py +++ b/api/services/entities/model_provider_entities.py @@ -1,7 +1,9 @@ from collections.abc import Sequence +from decimal import Decimal from enum import StrEnum +from typing import Annotated, Any -from pydantic import BaseModel, ConfigDict, model_validator +from pydantic import BaseModel, ConfigDict, Field, model_validator from configs import dify_config from core.entities.model_entities import ( @@ -16,16 +18,24 @@ from core.entities.provider_entities import ( UnaddedModelConfiguration, ) from graphon.model_runtime.entities.common_entities import I18nObject -from graphon.model_runtime.entities.model_entities import ModelType +from graphon.model_runtime.entities.model_entities import ( + FetchFrom, + ModelFeature, + ModelPropertyKey, + ModelType, + ParameterRule, +) from graphon.model_runtime.entities.provider_entities import ( ConfigurateMethod, ModelCredentialSchema, ProviderCredentialSchema, ProviderHelpEntity, - SimpleProviderEntity, ) from models.provider import ProviderType +_DECIMAL_STRING_PATTERN = r"^(?![-+.]*$)[+-]?0*\d*\.?\d*$" +CodegenSafeDecimal = Annotated[Decimal, Field(json_schema_extra={"pattern": _DECIMAL_STRING_PATTERN})] + class CustomConfigurationStatus(StrEnum): """ @@ -130,12 +140,40 @@ class ProviderWithModelsResponse(BaseModel): return self -class SimpleProviderEntityResponse(SimpleProviderEntity): +class PriceConfigResponse(BaseModel): + """Serialized pricing info with codegen-safe decimal string patterns.""" + + input: CodegenSafeDecimal + output: CodegenSafeDecimal | None = None + unit: CodegenSafeDecimal + currency: str + + +class AIModelEntityResponse(BaseModel): + model: str + label: I18nObject + model_type: ModelType + features: list[ModelFeature] | None = None + fetch_from: FetchFrom + model_properties: dict[ModelPropertyKey, Any] + deprecated: bool = False + parameter_rules: list[ParameterRule] = [] + pricing: PriceConfigResponse | None = None + + +class SimpleProviderEntityResponse(BaseModel): """ Simple provider entity response. """ + provider: str + provider_name: str = "" + label: I18nObject + icon_small: I18nObject | None = None + icon_small_dark: I18nObject | None = None + supported_model_types: Sequence[ModelType] tenant_id: str + models: list[AIModelEntityResponse] = [] @model_validator(mode="after") def _(self): @@ -154,6 +192,27 @@ class SimpleProviderEntityResponse(SimpleProviderEntity): return self +class ProviderEntityResponse(BaseModel): + """Runtime provider response with codegen-safe model pricing schemas.""" + + provider: str + provider_name: str = "" + label: I18nObject + description: I18nObject | None = None + icon_small: I18nObject | None = None + icon_small_dark: I18nObject | None = None + background: str | None = None + help: ProviderHelpEntity | None = None + supported_model_types: Sequence[ModelType] + configurate_methods: list[ConfigurateMethod] + models: list[AIModelEntityResponse] = [] + provider_credential_schema: ProviderCredentialSchema | None = None + model_credential_schema: ModelCredentialSchema | None = None + position: dict[str, list[str]] | None = {} + + model_config = ConfigDict(from_attributes=True, protected_namespaces=()) + + class DefaultModelResponse(BaseModel): """ Default model entity. diff --git a/api/services/human_input_service.py b/api/services/human_input_service.py index dcddfe0f2c6..cf6645d9358 100644 --- a/api/services/human_input_service.py +++ b/api/services/human_input_service.py @@ -223,9 +223,12 @@ class HumanInputService: if result.form_kind != HumanInputFormKind.RUNTIME: return - if result.workflow_run_id is None: - return - self.enqueue_resume(result.workflow_run_id) + # A RUNTIME form is owned by a workflow run (workflow Agent node) or a + # conversation (ENG-635: Agent v2 chat). Route the resume accordingly. + if result.workflow_run_id is not None: + self.enqueue_resume(result.workflow_run_id) + elif result.conversation_id is not None: + self.enqueue_agent_app_resume(conversation_id=result.conversation_id, form_id=result.form_id) def ensure_form_active(self, form: Form) -> None: if form.submitted: @@ -286,6 +289,22 @@ class HumanInputService: logger.warning("App mode %s does not support resume for workflow run %s", app.mode, workflow_run_id) + def enqueue_agent_app_resume(self, *, conversation_id: str, form_id: str) -> None: + """ENG-635: resume an Agent v2 chat after its ask_human form is submitted. + + Enqueues a background turn for the conversation; the Agent App runner + continues the agent run, threading the human's reply into the request as + ``deferred_tool_results``. + """ + from tasks.app_generate.resume_agent_app_task import resume_agent_app_execution + + try: + resume_agent_app_execution.apply_async( + kwargs={"conversation_id": conversation_id, "form_id": form_id}, + ) + except Exception: # pragma: no cover + logger.exception("Failed to enqueue Agent App resume for conversation %s form %s", conversation_id, form_id) + def _load_variable_pool_for_form(self, form: Form) -> ReadOnlyVariablePool | None: workflow_run_id = form.workflow_run_id if workflow_run_id is None: diff --git a/api/services/metadata_service.py b/api/services/metadata_service.py index 672f309bac0..f9dcfd25c7f 100644 --- a/api/services/metadata_service.py +++ b/api/services/metadata_service.py @@ -7,7 +7,8 @@ from core.rag.index_processor.constant.built_in_field import BuiltInField, Metad from extensions.ext_database import db from extensions.ext_redis import redis_client from libs.datetime_utils import naive_utc_now -from libs.login import current_account_with_tenant +from libs.login import resolve_account_fallback +from models import Account from models.dataset import Dataset, DatasetMetadata, DatasetMetadataBinding from models.enums import DatasetMetadataType from services.dataset_service import DocumentService @@ -21,11 +22,16 @@ logger = logging.getLogger(__name__) class MetadataService: @staticmethod - def create_metadata(dataset_id: str, metadata_args: MetadataArgs) -> DatasetMetadata: + def create_metadata( + dataset_id: str, + metadata_args: MetadataArgs, + current_user: Account | None = None, # TODO: the service_api is not migrated yet + current_tenant_id: str | None = None, + ) -> DatasetMetadata: # check if metadata name is too long if len(metadata_args.name) > 255: raise ValueError("Metadata name cannot exceed 255 characters.") - current_user, current_tenant_id = current_account_with_tenant() + current_user, current_tenant_id = resolve_account_fallback(current_user, current_tenant_id) # check if metadata name already exists if db.session.scalar( select(DatasetMetadata) @@ -52,14 +58,20 @@ class MetadataService: return metadata @staticmethod - def update_metadata_name(dataset_id: str, metadata_id: str, name: str) -> DatasetMetadata: # type: ignore + def update_metadata_name( + dataset_id: str, + metadata_id: str, + name: str, + current_user: Account | None = None, + current_tenant_id: str | None = None, # TODO: the service_api is not migrated yet + ) -> DatasetMetadata | None: # check if metadata name is too long if len(name) > 255: raise ValueError("Metadata name cannot exceed 255 characters.") lock_key = f"dataset_metadata_lock_{dataset_id}" # check if metadata name already exists - current_user, current_tenant_id = current_account_with_tenant() + current_user, current_tenant_id = resolve_account_fallback(current_user, current_tenant_id) if db.session.scalar( select(DatasetMetadata) .where( @@ -107,6 +119,7 @@ class MetadataService: return metadata except Exception: logger.exception("Update metadata name failed") + return None finally: redis_client.delete(lock_key) @@ -217,7 +230,15 @@ class MetadataService: redis_client.delete(lock_key) @staticmethod - def update_documents_metadata(dataset: Dataset, metadata_args: MetadataOperationData): + def update_documents_metadata( + dataset: Dataset, + metadata_args: MetadataOperationData, + current_user: Account | None = None, # TODO: the service_api is not migrated yet + current_tenant_id: str | None = None, + ): + current_user, current_tenant_id = resolve_account_fallback( + current_user, current_tenant_id, fallback_tenant_id=dataset.tenant_id + ) for operation in metadata_args.operation_data: lock_key = f"document_metadata_lock_{operation.document_id}" try: @@ -248,7 +269,6 @@ class MetadataService: ) ) - current_user, current_tenant_id = current_account_with_tenant() for metadata_value in operation.metadata_list: # check if binding already exists if operation.partial_update: diff --git a/api/services/model_load_balancing_service.py b/api/services/model_load_balancing_service.py index 15be7d5af3e..46bf24fffbf 100644 --- a/api/services/model_load_balancing_service.py +++ b/api/services/model_load_balancing_service.py @@ -66,7 +66,7 @@ class ModelLoadBalancingService: raise ValueError(f"Provider {provider} does not exist.") # Enable model load balancing - provider_configuration.enable_model_load_balancing(model=model, model_type=ModelType.value_of(model_type)) + provider_configuration.enable_model_load_balancing(model=model, model_type=ModelType(model_type)) def disable_model_load_balancing(self, tenant_id: str, provider: str, model: str, model_type: str): """ @@ -87,7 +87,7 @@ class ModelLoadBalancingService: raise ValueError(f"Provider {provider} does not exist.") # disable model load balancing - provider_configuration.disable_model_load_balancing(model=model, model_type=ModelType.value_of(model_type)) + provider_configuration.disable_model_load_balancing(model=model, model_type=ModelType(model_type)) def get_load_balancing_configs( self, tenant_id: str, provider: str, model: str, model_type: str, config_from: str = "" @@ -109,7 +109,7 @@ class ModelLoadBalancingService: raise ValueError(f"Provider {provider} does not exist.") # Convert model type to ModelType - model_type_enum = ModelType.value_of(model_type) + model_type_enum = ModelType(model_type) # Get provider model setting provider_model_setting = provider_configuration.get_provider_model_setting( @@ -250,7 +250,7 @@ class ModelLoadBalancingService: raise ValueError(f"Provider {provider} does not exist.") # Convert model type to ModelType - model_type_enum = ModelType.value_of(model_type) + model_type_enum = ModelType(model_type) # Get load balancing configurations load_balancing_model_config = db.session.scalar( @@ -338,7 +338,7 @@ class ModelLoadBalancingService: raise ValueError(f"Provider {provider} does not exist.") # Convert model type to ModelType - model_type_enum = ModelType.value_of(model_type) + model_type_enum = ModelType(model_type) if not isinstance(configs, list): raise ValueError("Invalid load balancing configs") @@ -524,7 +524,7 @@ class ModelLoadBalancingService: raise ValueError(f"Provider {provider} does not exist.") # Convert model type to ModelType - model_type_enum = ModelType.value_of(model_type) + model_type_enum = ModelType(model_type) load_balancing_model_config = None if config_id: diff --git a/api/services/model_provider_service.py b/api/services/model_provider_service.py index 362aa6103d9..7c34afd42e1 100644 --- a/api/services/model_provider_service.py +++ b/api/services/model_provider_service.py @@ -70,7 +70,7 @@ class ModelProviderService: provider_responses = [] for provider_configuration in provider_configurations.values(): if model_type: - model_type_entity = ModelType.value_of(model_type) + model_type_entity = ModelType(model_type) if model_type_entity not in provider_configuration.provider.supported_model_types: continue @@ -273,7 +273,7 @@ class ModelProviderService: """ provider_configuration = self._get_provider_configuration(tenant_id, provider) return provider_configuration.get_custom_model_credential( - model_type=ModelType.value_of(model_type), model=model, credential_id=credential_id + model_type=ModelType(model_type), model=model, credential_id=credential_id ) def validate_model_credentials( @@ -291,7 +291,7 @@ class ModelProviderService: """ provider_configuration = self._get_provider_configuration(tenant_id, provider) provider_configuration.validate_custom_model_credentials( - model_type=ModelType.value_of(model_type), model=model, credentials=credentials + model_type=ModelType(model_type), model=model, credentials=credentials ) def create_model_credential( @@ -316,7 +316,7 @@ class ModelProviderService: """ provider_configuration = self._get_provider_configuration(tenant_id, provider) provider_configuration.create_custom_model_credential( - model_type=ModelType.value_of(model_type), + model_type=ModelType(model_type), model=model, credentials=credentials, credential_name=credential_name, @@ -346,7 +346,7 @@ class ModelProviderService: """ provider_configuration = self._get_provider_configuration(tenant_id, provider) provider_configuration.update_custom_model_credential( - model_type=ModelType.value_of(model_type), + model_type=ModelType(model_type), model=model, credentials=credentials, credential_id=credential_id, @@ -366,7 +366,7 @@ class ModelProviderService: """ provider_configuration = self._get_provider_configuration(tenant_id, provider) provider_configuration.delete_custom_model_credential( - model_type=ModelType.value_of(model_type), model=model, credential_id=credential_id + model_type=ModelType(model_type), model=model, credential_id=credential_id ) def switch_active_custom_model_credential( @@ -384,7 +384,7 @@ class ModelProviderService: """ provider_configuration = self._get_provider_configuration(tenant_id, provider) provider_configuration.switch_custom_model_credential( - model_type=ModelType.value_of(model_type), model=model, credential_id=credential_id + model_type=ModelType(model_type), model=model, credential_id=credential_id ) def add_model_credential_to_model_list( @@ -402,7 +402,7 @@ class ModelProviderService: """ provider_configuration = self._get_provider_configuration(tenant_id, provider) provider_configuration.add_model_credential_to_model( - model_type=ModelType.value_of(model_type), model=model, credential_id=credential_id + model_type=ModelType(model_type), model=model, credential_id=credential_id ) def remove_model(self, tenant_id: str, provider: str, model_type: str, model: str): @@ -416,7 +416,7 @@ class ModelProviderService: :return: """ provider_configuration = self._get_provider_configuration(tenant_id, provider) - provider_configuration.delete_custom_model(model_type=ModelType.value_of(model_type), model=model) + provider_configuration.delete_custom_model(model_type=ModelType(model_type), model=model) def get_models_by_model_type(self, tenant_id: str, model_type: str) -> list[ProviderWithModelsResponse]: """ @@ -430,7 +430,7 @@ class ModelProviderService: provider_configurations = self._get_provider_manager(tenant_id).get_configurations(tenant_id) # Get provider available models - models = provider_configurations.get_models(model_type=ModelType.value_of(model_type), only_active=True) + models = provider_configurations.get_models(model_type=ModelType(model_type), only_active=True) # Group models by provider provider_models: dict[str, list[ModelWithProviderEntity]] = {} @@ -509,7 +509,7 @@ class ModelProviderService: :param model_type: model type :return: """ - model_type_enum = ModelType.value_of(model_type) + model_type_enum = ModelType(model_type) try: result = self._get_provider_manager(tenant_id).get_default_model( @@ -544,7 +544,7 @@ class ModelProviderService: :param model: model name :return: """ - model_type_enum = ModelType.value_of(model_type) + model_type_enum = ModelType(model_type) self._get_provider_manager(tenant_id).update_default_model_record( tenant_id=tenant_id, model_type=model_type_enum, provider=provider, model=model ) @@ -594,7 +594,7 @@ class ModelProviderService: :return: """ provider_configuration = self._get_provider_configuration(tenant_id, provider) - provider_configuration.enable_model(model=model, model_type=ModelType.value_of(model_type)) + provider_configuration.enable_model(model=model, model_type=ModelType(model_type)) def disable_model(self, tenant_id: str, provider: str, model: str, model_type: str): """ @@ -607,4 +607,4 @@ class ModelProviderService: :return: """ provider_configuration = self._get_provider_configuration(tenant_id, provider) - provider_configuration.disable_model(model=model, model_type=ModelType.value_of(model_type)) + provider_configuration.disable_model(model=model, model_type=ModelType(model_type)) diff --git a/api/services/oauth_device_flow.py b/api/services/oauth_device_flow.py index d11c5292ad2..9ec5711890b 100644 --- a/api/services/oauth_device_flow.py +++ b/api/services/oauth_device_flow.py @@ -13,7 +13,6 @@ from enum import StrEnum from typing import Any, NotRequired, TypedDict from sqlalchemy import and_, func, select, update -from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.orm import Session, scoped_session from libs.oauth_bearer import TOKEN_CACHE_KEY_FMT, AuthContext, SubjectType @@ -405,6 +404,9 @@ def _upsert( # Snapshot prior live row's hash for Redis invalidation post-rotate. # subject_issuer is always non-null here (account flow uses sentinel, # external-SSO is validated upstream), so equality matches the index. + # FOR UPDATE locks the row so that concurrent logins for the same + # (subject, client, device) serialize here rather than both reading + # the same prior and producing two active tokens (TOCTOU race). prior = session.execute( select(OAuthAccessToken.id, OAuthAccessToken.token_hash) .where( @@ -415,10 +417,18 @@ def _upsert( OAuthAccessToken.revoked_at.is_(None), ) .limit(1) + .with_for_update() ).first() old_hash = prior.token_hash if prior else None - insert_stmt = pg_insert(OAuthAccessToken).values( + # Revoke any existing active token for this (subject, client, device) combination. + # PostgreSQL's ON CONFLICT doesn't support partial unique indexes (those with WHERE clauses), + # so we use a manual revoke-then-insert pattern instead. + if prior: + session.execute(update(OAuthAccessToken).where(OAuthAccessToken.id == prior.id).values(revoked_at=func.now())) + + # Insert the new token. + new_token = OAuthAccessToken( subject_email=subject_email, subject_issuer=subject_issuer, account_id=account_id, @@ -428,26 +438,14 @@ def _upsert( token_hash=new_hash, expires_at=expires_at, ) - upsert_stmt = insert_stmt.on_conflict_do_update( - index_elements=["subject_email", "subject_issuer", "client_id", "device_label"], - index_where=OAuthAccessToken.revoked_at.is_(None), - set_={ - "token_hash": insert_stmt.excluded.token_hash, - "prefix": insert_stmt.excluded.prefix, - "account_id": insert_stmt.excluded.account_id, - "expires_at": insert_stmt.excluded.expires_at, - "created_at": func.now(), - "last_used_at": None, - }, - ).returning(OAuthAccessToken.id) - row = session.execute(upsert_stmt).first() + session.add(new_token) + session.flush() + + token_id = new_token.id session.commit() - if row is None: - raise RuntimeError("oauth_token upsert returned no row") - token_id = uuid.UUID(str(row.id)) return UpsertOutcome( - token_id=token_id, + token_id=uuid.UUID(str(token_id)), rotated=prior is not None, old_hash=old_hash, ) diff --git a/api/services/plugin/plugin_auto_upgrade_service.py b/api/services/plugin/plugin_auto_upgrade_service.py index b96b8140acd..0e13214ee77 100644 --- a/api/services/plugin/plugin_auto_upgrade_service.py +++ b/api/services/plugin/plugin_auto_upgrade_service.py @@ -1,18 +1,295 @@ +"""Manage tenant plugin auto-upgrade strategies. + +The storage is category-scoped: each tenant can have one strategy per plugin +category. Public mutation helpers require an explicit category so callers do +not accidentally overwrite every plugin type with one workspace-level policy. +""" + +import logging +from dataclasses import dataclass +from hashlib import sha256 + from sqlalchemy import select +from sqlalchemy.orm import Session from core.db.session_factory import session_factory +from core.plugin.impl.plugin import PluginInstaller from models.account import TenantPluginAutoUpgradeStrategy +logger = logging.getLogger(__name__) + +PluginCategory = TenantPluginAutoUpgradeStrategy.PluginCategory +PLUGIN_CATEGORIES = tuple(PluginCategory) +SECONDS_PER_DAY = 24 * 60 * 60 +AUTO_UPGRADE_CHECK_SLOT_SECONDS = 15 * 60 +AUTO_UPGRADE_CHECK_SLOT_COUNT = SECONDS_PER_DAY // AUTO_UPGRADE_CHECK_SLOT_SECONDS + + +@dataclass(frozen=True) +class PluginAutoUpgradeBackfillResult: + created_count: int + normalized: bool + class PluginAutoUpgradeService: @staticmethod - def get_strategy(tenant_id: str) -> TenantPluginAutoUpgradeStrategy | None: - with session_factory.create_session() as session: - return session.scalar( - select(TenantPluginAutoUpgradeStrategy) - .where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id) - .limit(1) + def default_strategy_setting_for_category( + category: PluginCategory, + ) -> TenantPluginAutoUpgradeStrategy.StrategySetting: + if category == PluginCategory.MODEL: + return TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST + return TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY + + @staticmethod + def default_upgrade_time_of_day(tenant_id: str) -> int: + """Spread default checks across 15-minute aligned slots by tenant.""" + hash_input = tenant_id.encode() + slot = int.from_bytes(sha256(hash_input).digest()[:8], "big") % AUTO_UPGRADE_CHECK_SLOT_COUNT + return slot * AUTO_UPGRADE_CHECK_SLOT_SECONDS + + @staticmethod + def _coerce_category(category: object) -> PluginCategory | None: + """Accept daemon enum/string categories and ignore unknown values.""" + category_value = getattr(category, "value", category) + if category_value is None: + return None + + try: + return PluginCategory(str(category_value)) + except ValueError: + return None + + @staticmethod + def _get_installed_plugin_categories(tenant_id: str) -> dict[str, PluginCategory]: + """Build a plugin_id -> category map for splitting legacy include/exclude lists.""" + installed_plugins = PluginInstaller().list_plugins(tenant_id) + plugin_categories: dict[str, PluginCategory] = {} + + for plugin in installed_plugins: + plugin_category = PluginAutoUpgradeService._coerce_category(plugin.declaration.category) + if plugin_category is not None: + plugin_categories[plugin.plugin_id] = plugin_category + + return plugin_categories + + @staticmethod + def _filter_plugin_ids_for_category( + plugin_ids: list[str], + category: PluginCategory, + plugin_categories: dict[str, PluginCategory], + ) -> list[str]: + return [plugin_id for plugin_id in plugin_ids if plugin_categories.get(plugin_id) == category] + + @staticmethod + def _log_unknown_plugin_ids( + tenant_id: str, + field_name: str, + plugin_ids: list[str], + plugin_categories: dict[str, PluginCategory], + ) -> None: + unknown_plugin_ids = [plugin_id for plugin_id in plugin_ids if plugin_id not in plugin_categories] + if not unknown_plugin_ids: + return + + logger.warning( + "Skipped unknown plugin IDs while backfilling plugin auto-upgrade strategies: " + "tenant_id=%s, field=%s, plugin_ids=%s", + tenant_id, + field_name, + unknown_plugin_ids, + ) + + @staticmethod + def _has_default_strategy(strategy: TenantPluginAutoUpgradeStrategy) -> bool: + return ( + strategy.strategy_setting == TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY + and strategy.upgrade_time_of_day == 0 + and strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE + and not strategy.exclude_plugins + and not strategy.include_plugins + ) + + @staticmethod + def _strategy_setting_for_category( + source_strategy: TenantPluginAutoUpgradeStrategy, + category: PluginCategory, + source_has_default_strategy: bool, + ) -> TenantPluginAutoUpgradeStrategy.StrategySetting: + # Only pure legacy defaults adopt the new model=latest default. User-edited + # strategies keep their original setting across all categories. + if source_has_default_strategy: + return PluginAutoUpgradeService.default_strategy_setting_for_category(category) + return source_strategy.strategy_setting + + @staticmethod + def _upgrade_time_of_day_for_category( + tenant_id: str, + source_strategy: TenantPluginAutoUpgradeStrategy, + source_has_default_strategy: bool, + ) -> int: + # Pure legacy defaults are spread by tenant so all default rows do not + # concentrate in the same scheduler window. User-edited schedules keep their time. + if source_has_default_strategy: + return PluginAutoUpgradeService.default_upgrade_time_of_day(tenant_id) + return source_strategy.upgrade_time_of_day + + @staticmethod + def backfill_strategy_categories( + tenant_id: str, + ) -> PluginAutoUpgradeBackfillResult: + """Create missing category strategies and split include/exclude lists when needed. + + The historical row is treated as the workspace-level source strategy. + New category rows copy it first, then plugin lists are narrowed by real + plugin category when the source strategy contains include/exclude IDs. + """ + with session_factory.create_session() as session, session.begin(): + strategies = list( + session.scalars( + select(TenantPluginAutoUpgradeStrategy).where( + TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id + ) + ).all() ) + if not strategies: + return PluginAutoUpgradeBackfillResult(created_count=0, normalized=False) + + # Schema migration marks the historical workspace-level row as tool. + source_strategy = next( + (strategy for strategy in strategies if strategy.category == PluginCategory.TOOL), + strategies[0], + ) + source_has_default_strategy = PluginAutoUpgradeService._has_default_strategy(source_strategy) + strategies_by_category = {strategy.category: strategy for strategy in strategies} + exclude_plugins = source_strategy.exclude_plugins + include_plugins = source_strategy.include_plugins + should_split_plugin_lists = bool(exclude_plugins or include_plugins) + # Query daemon only for tenants that actually customized plugin lists. + plugin_categories = ( + PluginAutoUpgradeService._get_installed_plugin_categories(tenant_id) + if should_split_plugin_lists + else {} + ) + if should_split_plugin_lists: + PluginAutoUpgradeService._log_unknown_plugin_ids( + tenant_id, + "exclude_plugins", + exclude_plugins, + plugin_categories, + ) + PluginAutoUpgradeService._log_unknown_plugin_ids( + tenant_id, + "include_plugins", + include_plugins, + plugin_categories, + ) + + created_count = 0 + for category in PLUGIN_CATEGORIES: + strategy = strategies_by_category.get(category) + if strategy is None: + # Start from the legacy workspace-level behavior before narrowing lists. + strategy = TenantPluginAutoUpgradeStrategy( + tenant_id=tenant_id, + category=category, + strategy_setting=PluginAutoUpgradeService._strategy_setting_for_category( + source_strategy, category, source_has_default_strategy + ), + upgrade_time_of_day=PluginAutoUpgradeService._upgrade_time_of_day_for_category( + tenant_id, source_strategy, source_has_default_strategy + ), + upgrade_mode=source_strategy.upgrade_mode, + exclude_plugins=source_strategy.exclude_plugins.copy(), + include_plugins=source_strategy.include_plugins.copy(), + ) + session.add(strategy) + created_count += 1 + elif source_has_default_strategy: + strategy.strategy_setting = PluginAutoUpgradeService.default_strategy_setting_for_category( + strategy.category + ) + strategy.upgrade_time_of_day = PluginAutoUpgradeService.default_upgrade_time_of_day(tenant_id) + + if not should_split_plugin_lists: + continue + + # Narrow include/exclude lists to the current category after all rows exist. + strategy.exclude_plugins = PluginAutoUpgradeService._filter_plugin_ids_for_category( + exclude_plugins, + strategy.category, + plugin_categories, + ) + strategy.include_plugins = PluginAutoUpgradeService._filter_plugin_ids_for_category( + include_plugins, + strategy.category, + plugin_categories, + ) + + return PluginAutoUpgradeBackfillResult(created_count=created_count, normalized=should_split_plugin_lists) + + @staticmethod + def _get_strategy( + session: Session, + tenant_id: str, + category: PluginCategory, + ) -> TenantPluginAutoUpgradeStrategy | None: + return session.scalar( + select(TenantPluginAutoUpgradeStrategy) + .where( + TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id, + TenantPluginAutoUpgradeStrategy.category == category, + ) + .limit(1) + ) + + @staticmethod + def get_strategy( + tenant_id: str, + category: PluginCategory, + ) -> TenantPluginAutoUpgradeStrategy | None: + with session_factory.create_session() as session: + return PluginAutoUpgradeService._get_strategy(session, tenant_id, category) + + @staticmethod + def get_strategies(tenant_id: str) -> list[TenantPluginAutoUpgradeStrategy]: + with session_factory.create_session() as session: + return list( + session.scalars( + select(TenantPluginAutoUpgradeStrategy).where( + TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id + ) + ).all() + ) + + @staticmethod + def _change_strategy( + session: Session, + tenant_id: str, + category: PluginCategory, + strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting, + upgrade_time_of_day: int, + upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode, + exclude_plugins: list[str], + include_plugins: list[str], + ) -> None: + exist_strategy = PluginAutoUpgradeService._get_strategy(session, tenant_id, category) + if not exist_strategy: + strategy = TenantPluginAutoUpgradeStrategy( + tenant_id=tenant_id, + category=category, + strategy_setting=strategy_setting, + upgrade_time_of_day=upgrade_time_of_day, + upgrade_mode=upgrade_mode, + exclude_plugins=exclude_plugins, + include_plugins=include_plugins, + ) + session.add(strategy) + else: + exist_strategy.strategy_setting = strategy_setting + exist_strategy.upgrade_time_of_day = upgrade_time_of_day + exist_strategy.upgrade_mode = upgrade_mode + exist_strategy.exclude_plugins = exclude_plugins + exist_strategy.include_plugins = include_plugins @staticmethod def change_strategy( @@ -22,64 +299,72 @@ class PluginAutoUpgradeService: upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode, exclude_plugins: list[str], include_plugins: list[str], + category: PluginCategory, ) -> bool: with session_factory.create_session() as session, session.begin(): - exist_strategy = session.scalar( - select(TenantPluginAutoUpgradeStrategy) - .where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id) - .limit(1) + PluginAutoUpgradeService._change_strategy( + session, + tenant_id=tenant_id, + category=category, + strategy_setting=strategy_setting, + upgrade_time_of_day=upgrade_time_of_day, + upgrade_mode=upgrade_mode, + exclude_plugins=exclude_plugins, + include_plugins=include_plugins, ) - if not exist_strategy: - strategy = TenantPluginAutoUpgradeStrategy( - tenant_id=tenant_id, - strategy_setting=strategy_setting, - upgrade_time_of_day=upgrade_time_of_day, - upgrade_mode=upgrade_mode, - exclude_plugins=exclude_plugins, - include_plugins=include_plugins, - ) - session.add(strategy) - else: - exist_strategy.strategy_setting = strategy_setting - exist_strategy.upgrade_time_of_day = upgrade_time_of_day - exist_strategy.upgrade_mode = upgrade_mode - exist_strategy.exclude_plugins = exclude_plugins - exist_strategy.include_plugins = include_plugins return True @staticmethod - def exclude_plugin(tenant_id: str, plugin_id: str) -> bool: - with session_factory.create_session() as session, session.begin(): - exist_strategy = session.scalar( - select(TenantPluginAutoUpgradeStrategy) - .where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id) - .limit(1) + def _exclude_plugin( + session: Session, + tenant_id: str, + category: PluginCategory, + plugin_id: str, + ) -> None: + """Remove one plugin from automatic updates for a single category strategy.""" + exist_strategy = PluginAutoUpgradeService._get_strategy(session, tenant_id, category) + if not exist_strategy: + PluginAutoUpgradeService._change_strategy( + session, + tenant_id, + category, + TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY, + 0, + TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE, + [plugin_id], + [], ) - if not exist_strategy: - # create for this tenant - PluginAutoUpgradeService.change_strategy( - tenant_id, - TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY, - 0, - TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE, - [plugin_id], - [], - ) - return True - else: - if exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE: - if plugin_id not in exist_strategy.exclude_plugins: - new_exclude_plugins = exist_strategy.exclude_plugins.copy() - new_exclude_plugins.append(plugin_id) - exist_strategy.exclude_plugins = new_exclude_plugins - elif exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL: - if plugin_id in exist_strategy.include_plugins: - new_include_plugins = exist_strategy.include_plugins.copy() - new_include_plugins.remove(plugin_id) - exist_strategy.include_plugins = new_include_plugins - elif exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL: - exist_strategy.upgrade_mode = TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE - exist_strategy.exclude_plugins = [plugin_id] + else: + if exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE: + # In exclude mode, disabling one plugin means adding it to exclude_plugins. + if plugin_id not in exist_strategy.exclude_plugins: + new_exclude_plugins = exist_strategy.exclude_plugins.copy() + new_exclude_plugins.append(plugin_id) + exist_strategy.exclude_plugins = new_exclude_plugins + elif exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL: + # In partial mode, disabling one plugin means removing it from include_plugins. + if plugin_id in exist_strategy.include_plugins: + new_include_plugins = exist_strategy.include_plugins.copy() + new_include_plugins.remove(plugin_id) + exist_strategy.include_plugins = new_include_plugins + elif exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL: + # In all mode, switch to exclude mode so only this plugin is skipped. + exist_strategy.upgrade_mode = TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE + exist_strategy.exclude_plugins = [plugin_id] - return True + @staticmethod + def exclude_plugin( + tenant_id: str, + plugin_id: str, + category: PluginCategory, + ) -> bool: + with session_factory.create_session() as session, session.begin(): + PluginAutoUpgradeService._exclude_plugin( + session, + tenant_id, + category, + plugin_id, + ) + + return True diff --git a/api/services/rag_pipeline/pipeline_template/built_in/built_in_retrieval.py b/api/services/rag_pipeline/pipeline_template/built_in/built_in_retrieval.py index 3ba7593be53..4e4cf2d19f5 100644 --- a/api/services/rag_pipeline/pipeline_template/built_in/built_in_retrieval.py +++ b/api/services/rag_pipeline/pipeline_template/built_in/built_in_retrieval.py @@ -21,7 +21,8 @@ class BuiltInPipelineTemplateRetrieval(PipelineTemplateRetrievalBase): return PipelineTemplateType.BUILTIN @override - def get_pipeline_templates(self, language: str) -> dict[str, Any]: + def get_pipeline_templates(self, language: str, current_tenant_id: str | None = None) -> dict[str, Any]: + del current_tenant_id result = self.fetch_pipeline_templates_from_builtin(language) return result diff --git a/api/services/rag_pipeline/pipeline_template/customized/customized_retrieval.py b/api/services/rag_pipeline/pipeline_template/customized/customized_retrieval.py index ee73b0328f5..57dfefed2e0 100644 --- a/api/services/rag_pipeline/pipeline_template/customized/customized_retrieval.py +++ b/api/services/rag_pipeline/pipeline_template/customized/customized_retrieval.py @@ -4,7 +4,7 @@ import yaml from sqlalchemy import select from extensions.ext_database import db -from libs.login import current_account_with_tenant +from libs.login import resolve_tenant_id_fallback from models.dataset import PipelineCustomizedTemplate from services.rag_pipeline.pipeline_template.pipeline_template_base import PipelineTemplateRetrievalBase from services.rag_pipeline.pipeline_template.pipeline_template_type import PipelineTemplateType @@ -40,8 +40,8 @@ class CustomizedPipelineTemplateRetrieval(PipelineTemplateRetrievalBase): """ @override - def get_pipeline_templates(self, language: str) -> dict[str, Any]: - _, current_tenant_id = current_account_with_tenant() + def get_pipeline_templates(self, language: str, current_tenant_id: str | None = None) -> dict[str, Any]: + current_tenant_id = resolve_tenant_id_fallback(current_tenant_id) return self.fetch_pipeline_templates_from_customized(tenant_id=current_tenant_id, language=language) @override diff --git a/api/services/rag_pipeline/pipeline_template/database/database_retrieval.py b/api/services/rag_pipeline/pipeline_template/database/database_retrieval.py index 9c94fdee2b0..0f6d0727c76 100644 --- a/api/services/rag_pipeline/pipeline_template/database/database_retrieval.py +++ b/api/services/rag_pipeline/pipeline_template/database/database_retrieval.py @@ -40,7 +40,8 @@ class DatabasePipelineTemplateRetrieval(PipelineTemplateRetrievalBase): """ @override - def get_pipeline_templates(self, language: str) -> dict[str, Any]: + def get_pipeline_templates(self, language: str, current_tenant_id: str | None = None) -> dict[str, Any]: + del current_tenant_id return self.fetch_pipeline_templates_from_db(language) @override diff --git a/api/services/rag_pipeline/pipeline_template/pipeline_template_base.py b/api/services/rag_pipeline/pipeline_template/pipeline_template_base.py index 9cfb8f36aa7..84d8f5674bb 100644 --- a/api/services/rag_pipeline/pipeline_template/pipeline_template_base.py +++ b/api/services/rag_pipeline/pipeline_template/pipeline_template_base.py @@ -4,7 +4,7 @@ from typing import Any, Protocol class PipelineTemplateRetrievalBase(Protocol): """Interface for pipeline template retrieval.""" - def get_pipeline_templates(self, language: str) -> dict[str, Any]: ... + def get_pipeline_templates(self, language: str, current_tenant_id: str | None = None) -> dict[str, Any]: ... def get_pipeline_template_detail(self, template_id: str) -> dict[str, Any] | None: ... diff --git a/api/services/rag_pipeline/pipeline_template/remote/remote_retrieval.py b/api/services/rag_pipeline/pipeline_template/remote/remote_retrieval.py index 1be97c2888d..5cf46915ab0 100644 --- a/api/services/rag_pipeline/pipeline_template/remote/remote_retrieval.py +++ b/api/services/rag_pipeline/pipeline_template/remote/remote_retrieval.py @@ -25,7 +25,8 @@ class RemotePipelineTemplateRetrieval(PipelineTemplateRetrievalBase): return DatabasePipelineTemplateRetrieval.fetch_pipeline_template_detail_from_db(template_id) @override - def get_pipeline_templates(self, language: str) -> dict[str, Any]: + def get_pipeline_templates(self, language: str, current_tenant_id: str | None = None) -> dict[str, Any]: + del current_tenant_id try: return self.fetch_pipeline_templates_from_dify_official(language) except Exception as e: diff --git a/api/services/rag_pipeline/rag_pipeline.py b/api/services/rag_pipeline/rag_pipeline.py index fd02a44995d..abab174b3d9 100644 --- a/api/services/rag_pipeline/rag_pipeline.py +++ b/api/services/rag_pipeline/rag_pipeline.py @@ -8,7 +8,6 @@ from datetime import UTC, datetime from typing import Any, cast from uuid import uuid4 -from flask_login import current_user from sqlalchemy import func, select from sqlalchemy.orm import Session, sessionmaker @@ -54,6 +53,7 @@ from graphon.nodes.http_request import HTTP_REQUEST_CONFIG_FILTER_KEY, build_htt from graphon.runtime import VariablePool from graphon.variables.variables import Variable, VariableBase from libs.infinite_scroll_pagination import InfiniteScrollPagination +from libs.login import resolve_account_fallback, resolve_tenant_id_fallback from models import Account from models.dataset import ( # type: ignore Dataset, @@ -104,11 +104,16 @@ class RagPipelineService: self._workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_maker) @classmethod - def get_pipeline_templates(cls, type: str = "built-in", language: str = "en-US") -> dict[str, Any]: + def get_pipeline_templates( + cls, + type: str = "built-in", + language: str = "en-US", + current_tenant_id: str | None = None, + ) -> dict[str, Any]: if type == "built-in": mode = dify_config.HOSTED_FETCH_PIPELINE_TEMPLATES_MODE retrieval_instance = PipelineTemplateRetrievalFactory.get_pipeline_template_factory(mode)() - result = retrieval_instance.get_pipeline_templates(language) + result = retrieval_instance.get_pipeline_templates(language, current_tenant_id) if not result.get("pipeline_templates") and language != "en-US": template_retrieval = PipelineTemplateRetrievalFactory.get_built_in_pipeline_template_retrieval() result = template_retrieval.fetch_pipeline_templates_from_builtin("en-US") @@ -116,7 +121,7 @@ class RagPipelineService: else: mode = "customized" retrieval_instance = PipelineTemplateRetrievalFactory.get_pipeline_template_factory(mode)() - result = retrieval_instance.get_pipeline_templates(language) + result = retrieval_instance.get_pipeline_templates(language, current_tenant_id) return result @classmethod @@ -146,17 +151,24 @@ class RagPipelineService: return customized_result @classmethod - def update_customized_pipeline_template(cls, template_id: str, template_info: PipelineTemplateInfoEntity): + def update_customized_pipeline_template( + cls, + template_id: str, + template_info: PipelineTemplateInfoEntity, + current_user: Account | None = None, + current_tenant_id: str | None = None, + ): """ Update pipeline template. :param template_id: template id :param template_info: template info """ + current_user, current_tenant_id = resolve_account_fallback(current_user, current_tenant_id) customized_template: PipelineCustomizedTemplate | None = db.session.scalar( select(PipelineCustomizedTemplate) .where( PipelineCustomizedTemplate.id == template_id, - PipelineCustomizedTemplate.tenant_id == current_user.current_tenant_id, + PipelineCustomizedTemplate.tenant_id == current_tenant_id, ) .limit(1) ) @@ -169,7 +181,7 @@ class RagPipelineService: select(PipelineCustomizedTemplate) .where( PipelineCustomizedTemplate.name == template_name, - PipelineCustomizedTemplate.tenant_id == current_user.current_tenant_id, + PipelineCustomizedTemplate.tenant_id == current_tenant_id, PipelineCustomizedTemplate.id != template_id, ) .limit(1) @@ -184,15 +196,16 @@ class RagPipelineService: return customized_template @classmethod - def delete_customized_pipeline_template(cls, template_id: str): + def delete_customized_pipeline_template(cls, template_id: str, current_tenant_id: str | None = None): """ Delete customized pipeline template. """ + current_tenant_id = resolve_tenant_id_fallback(current_tenant_id) customized_template: PipelineCustomizedTemplate | None = db.session.scalar( select(PipelineCustomizedTemplate) .where( PipelineCustomizedTemplate.id == template_id, - PipelineCustomizedTemplate.tenant_id == current_user.current_tenant_id, + PipelineCustomizedTemplate.tenant_id == current_tenant_id, ) .limit(1) ) @@ -1174,10 +1187,17 @@ class RagPipelineService: return list(node_executions) @classmethod - def publish_customized_pipeline_template(cls, pipeline_id: str, args: dict[str, Any]): + def publish_customized_pipeline_template( + cls, + pipeline_id: str, + args: dict[str, Any], + current_user: Account | None = None, + current_tenant_id: str | None = None, + ): """ Publish customized pipeline template """ + current_user, _ = resolve_account_fallback(current_user, current_tenant_id) pipeline = db.session.get(Pipeline, pipeline_id) if not pipeline: raise ValueError("Pipeline not found") @@ -1357,7 +1377,7 @@ class RagPipelineService: return [] return marketplace.batch_fetch_plugin_by_ids(plugin_ids) - def get_recommended_plugins(self, type: str) -> dict[str, Any]: + def get_recommended_plugins(self, type: str, current_user: Account, current_tenant_id: str) -> dict[str, Any]: # Query active recommended plugins stmt = select(PipelineRecommendedPlugin).where(PipelineRecommendedPlugin.active == True) if type and type != "all": @@ -1375,7 +1395,7 @@ class RagPipelineService: plugin_ids = [plugin.plugin_id for plugin in pipeline_recommended_plugins] providers = BuiltinToolManageService.list_builtin_tools( user_id=current_user.id, - tenant_id=current_user.current_tenant_id, + tenant_id=current_tenant_id, ) providers_map = {provider.plugin_id: provider.to_dict() for provider in providers} diff --git a/api/services/rag_pipeline/rag_pipeline_transform_service.py b/api/services/rag_pipeline/rag_pipeline_transform_service.py index ca755d0b91d..dc3eeae201e 100644 --- a/api/services/rag_pipeline/rag_pipeline_transform_service.py +++ b/api/services/rag_pipeline/rag_pipeline_transform_service.py @@ -8,6 +8,7 @@ from uuid import uuid4 import yaml from flask_login import current_user from sqlalchemy import select +from sqlalchemy.orm import scoped_session from configs import dify_config from constants import DOCUMENT_EXTENSIONS @@ -28,8 +29,8 @@ logger = logging.getLogger(__name__) class RagPipelineTransformService: - def transform_dataset(self, dataset_id: str): - dataset = db.session.get(Dataset, dataset_id) + def transform_dataset(self, dataset_id: str, session: scoped_session): + dataset = session.get(Dataset, dataset_id) if not dataset: raise ValueError("Dataset not found") if dataset.pipeline_id and dataset.runtime_mode == DatasetRuntimeMode.RAG_PIPELINE: @@ -94,9 +95,9 @@ class RagPipelineTransformService: dataset.pipeline_id = pipeline.id # deal document data - self._deal_document_data(dataset) + self._deal_document_data(dataset, session) - db.session.commit() + session.commit() return { "pipeline_id": pipeline.id, "dataset_id": dataset_id, @@ -310,13 +311,13 @@ class RagPipelineTransformService: "status": "success", } - def _deal_document_data(self, dataset: Dataset): + def _deal_document_data(self, dataset: Dataset, session: scoped_session): file_node_id = "1752479895761" notion_node_id = "1752489759475" jina_node_id = "1752491761974" firecrawl_node_id = "1752565402678" - documents = db.session.scalars(select(Document).where(Document.dataset_id == dataset.id)).all() + documents = session.scalars(select(Document).where(Document.dataset_id == dataset.id)).all() for document in documents: data_source_info_dict = document.data_source_info_dict @@ -326,7 +327,7 @@ class RagPipelineTransformService: document.data_source_type = DataSourceType.LOCAL_FILE file_id = data_source_info_dict.get("upload_file_id") if file_id: - file = db.session.get(UploadFile, file_id) + file = session.get(UploadFile, file_id) if file: data_source_info = json.dumps( { @@ -350,8 +351,8 @@ class RagPipelineTransformService: datasource_node_id=file_node_id, ) document_pipeline_execution_log.created_at = document.created_at - db.session.add(document) - db.session.add(document_pipeline_execution_log) + session.add(document) + session.add(document_pipeline_execution_log) elif document.data_source_type == DataSourceType.NOTION_IMPORT: document.data_source_type = DataSourceType.ONLINE_DOCUMENT data_source_info = json.dumps( @@ -378,8 +379,8 @@ class RagPipelineTransformService: datasource_node_id=notion_node_id, ) document_pipeline_execution_log.created_at = document.created_at - db.session.add(document) - db.session.add(document_pipeline_execution_log) + session.add(document) + session.add(document_pipeline_execution_log) elif document.data_source_type == DataSourceType.WEBSITE_CRAWL: data_source_info = json.dumps( { @@ -406,5 +407,5 @@ class RagPipelineTransformService: datasource_node_id=datasource_node_id, ) document_pipeline_execution_log.created_at = document.created_at - db.session.add(document) - db.session.add(document_pipeline_execution_log) + session.add(document) + session.add(document_pipeline_execution_log) diff --git a/api/services/recommend_app/database/database_retrieval.py b/api/services/recommend_app/database/database_retrieval.py index d420b33930b..9d6c28c2117 100644 --- a/api/services/recommend_app/database/database_retrieval.py +++ b/api/services/recommend_app/database/database_retrieval.py @@ -1,4 +1,4 @@ -from typing import Any, TypedDict, override +from typing import Any, NotRequired, TypedDict, override from sqlalchemy import select @@ -22,6 +22,7 @@ class RecommendedAppItemDict(TypedDict): categories: list[str] position: int is_listed: bool + can_trial: NotRequired[bool] class RecommendedAppsResultDict(TypedDict): @@ -64,14 +65,47 @@ class DatabaseRecommendAppRetrieval(RecommendAppRetrievalBase): :param language: language :return: """ - recommended_apps = db.session.scalars( - select(RecommendedApp).where(RecommendedApp.is_listed == True, RecommendedApp.language == language) - ).all() + recommended_apps = cls._fetch_listed_recommended_apps(language) if len(recommended_apps) == 0: - recommended_apps = db.session.scalars( - select(RecommendedApp).where(RecommendedApp.is_listed == True, RecommendedApp.language == languages[0]) - ).all() + recommended_apps = cls._fetch_listed_recommended_apps(languages[0]) + + return cls._format_recommended_apps(recommended_apps, language) + + @classmethod + def fetch_learn_dify_apps_from_db(cls, language: str) -> RecommendedAppsResultDict: + """ + Fetch listed recommended apps explicitly marked for the Learn Dify section. + :param language: language + :return: + """ + recommended_apps = cls._fetch_listed_recommended_apps(language, is_learn_dify=True) + + if len(recommended_apps) == 0 and language != languages[0]: + recommended_apps = cls._fetch_listed_recommended_apps(languages[0], is_learn_dify=True) + + return cls._format_recommended_apps(recommended_apps, language) + + @classmethod + def _fetch_listed_recommended_apps( + cls, language: str, *, is_learn_dify: bool | None = None + ) -> list[RecommendedApp]: + filters = [RecommendedApp.is_listed.is_(True), RecommendedApp.language == language] + if is_learn_dify is not None: + filters.append(RecommendedApp.is_learn_dify.is_(is_learn_dify)) + + return list(db.session.scalars(select(RecommendedApp).where(*filters)).all()) + + @classmethod + def _format_recommended_apps( + cls, recommended_apps: list[RecommendedApp], language: str + ) -> RecommendedAppsResultDict: + """ + Serialize DB recommended app rows into the Explore list response shape. + :param recommended_apps: recommended app rows + :param language: language used for category ordering + :return: + """ categories = set() recommended_apps_result: list[RecommendedAppItemDict] = [] diff --git a/api/services/recommended_app_service.py b/api/services/recommended_app_service.py index 4e189e6e7c9..bc8bb58acba 100644 --- a/api/services/recommended_app_service.py +++ b/api/services/recommended_app_service.py @@ -1,17 +1,18 @@ from typing import Any from sqlalchemy import select +from sqlalchemy.orm import scoped_session from configs import dify_config -from extensions.ext_database import db from models.model import AccountTrialAppRecord, TrialApp from services.feature_service import FeatureService +from services.recommend_app.database.database_retrieval import DatabaseRecommendAppRetrieval from services.recommend_app.recommend_app_factory import RecommendAppRetrievalFactory class RecommendedAppService: @classmethod - def get_recommended_apps_and_categories(cls, language: str): + def get_recommended_apps_and_categories(cls, session: scoped_session, language: str): """ Get recommended apps and categories. :param language: language @@ -31,15 +32,26 @@ class RecommendedAppService: apps = result["recommended_apps"] for app in apps: app_id = app["app_id"] - trial_app_model = db.session.scalar(select(TrialApp).where(TrialApp.app_id == app_id).limit(1)) - if trial_app_model: - app["can_trial"] = True - else: - app["can_trial"] = False + app["can_trial"] = cls._can_trial_app(session, app_id) return result @classmethod - def get_recommend_app_detail(cls, app_id: str) -> dict[str, Any] | None: + def get_learn_dify_apps(cls, session: scoped_session, language: str) -> dict[str, Any]: + """ + Get database-backed recommended apps marked as Learn Dify. + :param language: language + :return: + """ + result = DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db(language) + + if FeatureService.get_system_features().enable_trial_app: + for app in result["recommended_apps"]: + app["can_trial"] = cls._can_trial_app(session, app["app_id"]) + + return {"recommended_apps": result["recommended_apps"]} + + @classmethod + def get_recommend_app_detail(cls, session: scoped_session, app_id: str) -> dict[str, Any] | None: """ Get recommend app detail. :param app_id: app id @@ -52,28 +64,29 @@ class RecommendedAppService: return None if FeatureService.get_system_features().enable_trial_app: app_id = result["id"] - trial_app_model = db.session.scalar(select(TrialApp).where(TrialApp.app_id == app_id).limit(1)) - if trial_app_model: - result["can_trial"] = True - else: - result["can_trial"] = False + result["can_trial"] = cls._can_trial_app(session, app_id) return result @classmethod - def add_trial_app_record(cls, app_id: str, account_id: str): + def add_trial_app_record(cls, session: scoped_session, app_id: str, account_id: str): """ Add trial app record. :param app_id: app id :return: """ - account_trial_app_record = db.session.scalar( + account_trial_app_record = session.scalar( select(AccountTrialAppRecord) .where(AccountTrialAppRecord.app_id == app_id, AccountTrialAppRecord.account_id == account_id) .limit(1) ) if account_trial_app_record: account_trial_app_record.count += 1 - db.session.commit() + session.commit() else: - db.session.add(AccountTrialAppRecord(app_id=app_id, count=1, account_id=account_id)) - db.session.commit() + session.add(AccountTrialAppRecord(app_id=app_id, count=1, account_id=account_id)) + session.commit() + + @staticmethod + def _can_trial_app(session: scoped_session, app_id: str) -> bool: + trial_app_model = session.scalar(select(TrialApp).where(TrialApp.app_id == app_id).limit(1)) + return trial_app_model is not None diff --git a/api/services/retention/workflow_run/clear_free_plan_expired_workflow_run_logs.py b/api/services/retention/workflow_run/clear_free_plan_expired_workflow_run_logs.py index 58e8ac57a8f..3652997f8af 100644 --- a/api/services/retention/workflow_run/clear_free_plan_expired_workflow_run_logs.py +++ b/api/services/retention/workflow_run/clear_free_plan_expired_workflow_run_logs.py @@ -1,3 +1,10 @@ +"""Cleanup expired workflow run logs for free-plan tenants. + +The cleanup service owns billing eligibility decisions while repositories own database-efficient batch selection and +deletion. Free-plan cleanup intentionally scans lightweight workflow run references first, then re-queries the same +candidate cursor slice with eligible tenant IDs so paid tenants are skipped without hydrating full WorkflowRun models. +""" + import datetime import logging import random @@ -11,8 +18,11 @@ from sqlalchemy.orm import Session, sessionmaker from configs import dify_config from enums.cloud_plan import CloudPlan from extensions.ext_database import db -from models.workflow import WorkflowRun -from repositories.api_workflow_run_repository import APIWorkflowRunRepository, RunsWithRelatedCountsDict +from repositories.api_workflow_run_repository import ( + APIWorkflowRunRepository, + RunsWithRelatedCountsDict, + WorkflowRunCleanupRef, +) from repositories.factory import DifyAPIRepositoryFactory from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWorkflowTriggerLogRepository from services.billing_service import BillingService, SubscriptionPlan @@ -186,6 +196,13 @@ _RELATED_RECORD_KEYS = ("node_executions", "offloads", "app_logs", "trigger_logs class WorkflowRunCleanup: + """ + Coordinates free-plan workflow run retention cleanup. + + The cleanup cursor advances by candidate refs, not target refs. This keeps pagination stable + when billing filters out paid or unknown tenants before the repository performs the target lookup. + """ + def __init__( self, days: int, @@ -254,26 +271,28 @@ class WorkflowRunCleanup: batch_start = time.monotonic() fetch_start = time.monotonic() - run_rows = self.workflow_run_repo.get_runs_batch_by_time_range( + candidate_last_seen = last_seen + candidate_refs = self.workflow_run_repo.get_cleanup_refs_batch_by_time_range( start_from=self.window_start, end_before=self.window_end, - last_seen=last_seen, + last_seen=candidate_last_seen, batch_size=self.batch_size, ) - if not run_rows: + if not candidate_refs: logger.info("workflow_run_cleanup (batch #%s): no more rows to process", batch_index + 1) break batch_index += 1 - last_seen = (run_rows[-1].created_at, run_rows[-1].id) + candidate_high_water = self._cursor_from_ref(candidate_refs[-1]) + last_seen = candidate_high_water logger.info( - "workflow_run_cleanup (batch #%s): fetched %s rows in %sms", + "workflow_run_cleanup (batch #%s): fetched %s candidate refs in %sms", batch_index, - len(run_rows), + len(candidate_refs), int((time.monotonic() - fetch_start) * 1000), ) - tenant_ids = {row.tenant_id for row in run_rows} + tenant_ids = {ref.tenant_id for ref in candidate_refs} filter_start = time.monotonic() free_tenants = self._filter_free_tenants(tenant_ids) @@ -285,10 +304,28 @@ class WorkflowRunCleanup: int((time.monotonic() - filter_start) * 1000), ) - free_runs = [row for row in run_rows if row.tenant_id in free_tenants] - paid_or_skipped = len(run_rows) - len(free_runs) + target_refs: Sequence[WorkflowRunCleanupRef] = [] + if free_tenants: + target_fetch_start = time.monotonic() + target_refs = self.workflow_run_repo.get_cleanup_refs_batch_by_time_range( + start_from=self.window_start, + end_before=self.window_end, + last_seen=candidate_last_seen, + batch_size=self.batch_size, + tenant_ids=sorted(free_tenants), + upper_bound=candidate_high_water, + ) + logger.info( + "workflow_run_cleanup (batch #%s): fetched %s target refs in %sms", + batch_index, + len(target_refs), + int((time.monotonic() - target_fetch_start) * 1000), + ) - if not free_runs: + target_run_ids = [ref.id for ref in target_refs] + paid_or_skipped = max(len(candidate_refs) - len(target_run_ids), 0) + + if not target_run_ids: skipped_message = ( f"[batch #{batch_index}] skipped (no sandbox runs in batch, {paid_or_skipped} paid/unknown)" ) @@ -299,7 +336,7 @@ class WorkflowRunCleanup: ) ) self._metrics.record_batch( - batch_rows=len(run_rows), + batch_rows=len(candidate_refs), targeted_runs=0, skipped_runs=paid_or_skipped, deleted_runs=0, @@ -309,13 +346,13 @@ class WorkflowRunCleanup: ) continue - total_runs_targeted += len(free_runs) + total_runs_targeted += len(target_run_ids) if self.dry_run: count_start = time.monotonic() - batch_counts = self.workflow_run_repo.count_runs_with_related( - free_runs, - count_node_executions=self._count_node_executions, + batch_counts = self.workflow_run_repo.count_runs_with_related_by_ids( + target_run_ids, + count_node_executions=self._count_node_executions_by_run_ids, count_trigger_logs=self._count_trigger_logs, ) logger.info( @@ -325,10 +362,10 @@ class WorkflowRunCleanup: ) if related_totals is not None: self._accumulate_related_counts(related_totals, batch_counts) - sample_ids = ", ".join(run.id for run in free_runs[:5]) + sample_ids = ", ".join(target_run_ids[:5]) click.echo( click.style( - f"[batch #{batch_index}] would delete {len(free_runs)} runs " + f"[batch #{batch_index}] would delete {len(target_run_ids)} runs " f"(sample ids: {sample_ids}) and skip {paid_or_skipped} paid/unknown", fg="yellow", ) @@ -339,8 +376,8 @@ class WorkflowRunCleanup: int((time.monotonic() - batch_start) * 1000), ) self._metrics.record_batch( - batch_rows=len(run_rows), - targeted_runs=len(free_runs), + batch_rows=len(candidate_refs), + targeted_runs=len(target_run_ids), skipped_runs=paid_or_skipped, deleted_runs=0, related_counts={ @@ -354,14 +391,14 @@ class WorkflowRunCleanup: try: delete_start = time.monotonic() - counts = self.workflow_run_repo.delete_runs_with_related( - free_runs, - delete_node_executions=self._delete_node_executions, + counts = self.workflow_run_repo.delete_runs_with_related_by_ids( + target_run_ids, + delete_node_executions=self._delete_node_executions_by_run_ids, delete_trigger_logs=self._delete_trigger_logs, ) delete_ms = int((time.monotonic() - delete_start) * 1000) except Exception: - logger.exception("Failed to delete workflow runs batch ending at %s", last_seen[0]) + logger.exception("Failed to delete workflow runs batch ending at %s", candidate_high_water[0]) raise total_runs_deleted += counts["runs"] @@ -382,8 +419,8 @@ class WorkflowRunCleanup: int((time.monotonic() - batch_start) * 1000), ) self._metrics.record_batch( - batch_rows=len(run_rows), - targeted_runs=len(free_runs), + batch_rows=len(candidate_refs), + targeted_runs=len(target_run_ids), skipped_runs=paid_or_skipped, deleted_runs=counts["runs"], related_counts={ @@ -439,7 +476,7 @@ class WorkflowRunCleanup: ) def _filter_free_tenants(self, tenant_ids: Iterable[str]) -> set[str]: - tenant_id_list = list(tenant_ids) + tenant_id_list = sorted(set(tenant_ids)) if not dify_config.BILLING_ENABLED: return set(tenant_id_list) @@ -553,15 +590,17 @@ class WorkflowRunCleanup: totals["pauses"] += batch.get("pauses", 0) totals["pause_reasons"] += batch.get("pause_reasons", 0) - def _count_node_executions(self, session: Session, runs: Sequence[WorkflowRun]) -> tuple[int, int]: - run_ids = [run.id for run in runs] + @staticmethod + def _cursor_from_ref(ref: WorkflowRunCleanupRef) -> tuple[datetime.datetime, str]: + return ref.created_at, ref.id + + def _count_node_executions_by_run_ids(self, session: Session, run_ids: Sequence[str]) -> tuple[int, int]: repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository( session_maker=sessionmaker(bind=session.get_bind(), expire_on_commit=False) ) return repo.count_by_runs(session, run_ids) - def _delete_node_executions(self, session: Session, runs: Sequence[WorkflowRun]) -> tuple[int, int]: - run_ids = [run.id for run in runs] + def _delete_node_executions_by_run_ids(self, session: Session, run_ids: Sequence[str]) -> tuple[int, int]: repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository( session_maker=sessionmaker(bind=session.get_bind(), expire_on_commit=False) ) diff --git a/api/services/snippet_dsl_service.py b/api/services/snippet_dsl_service.py index ad8d76a3878..19e0dabab95 100644 --- a/api/services/snippet_dsl_service.py +++ b/api/services/snippet_dsl_service.py @@ -6,7 +6,7 @@ from datetime import UTC, datetime from enum import StrEnum from urllib.parse import urlparse -import yaml # type: ignore +import yaml from packaging import version from pydantic import BaseModel, Field from sqlalchemy import select @@ -498,7 +498,7 @@ class SnippetDslService: export_data=export_data, snippet=snippet, workflow=workflow, include_secret=include_secret ) - return yaml.dump(export_data, allow_unicode=True) # type: ignore + return yaml.dump(export_data, allow_unicode=True) def _append_workflow_export_data( self, *, export_data: dict, snippet: CustomizedSnippet, workflow: Workflow, include_secret: bool diff --git a/api/services/summary_index_service.py b/api/services/summary_index_service.py index cf39469be85..1657cfdd256 100644 --- a/api/services/summary_index_service.py +++ b/api/services/summary_index_service.py @@ -123,7 +123,7 @@ class SummaryIndexService: # Update existing record existing_summary.summary_content = summary_content existing_summary.status = status - existing_summary.error = None # type: ignore[assignment] # Clear any previous errors + existing_summary.error = None # Clear any previous errors # Re-enable if it was disabled if not existing_summary.enabled: existing_summary.enabled = True @@ -583,7 +583,7 @@ class SummaryIndexService: if existing_summary: # Update existing record existing_summary.status = status - existing_summary.error = None # type: ignore[assignment] # Clear any previous errors + existing_summary.error = None # Clear any previous errors if not existing_summary.enabled: existing_summary.enabled = True existing_summary.disabled_at = None @@ -685,7 +685,7 @@ class SummaryIndexService: # Update status to "generating" summary_record_in_session.status = SummaryStatus.GENERATING - summary_record_in_session.error = None # type: ignore[assignment] + summary_record_in_session.error = None session.add(summary_record_in_session) # Don't flush here - wait until after vectorization succeeds @@ -1127,7 +1127,7 @@ class SummaryIndexService: # Update summary content summary_record.summary_content = summary_content summary_record.status = SummaryStatus.GENERATING - summary_record.error = None # type: ignore[assignment] # Clear any previous errors + summary_record.error = None # Clear any previous errors session.add(summary_record) # Flush to ensure summary_content is saved before vectorize_summary queries it session.flush() diff --git a/api/services/tag_service.py b/api/services/tag_service.py index 404ccb0d75f..20f9a2c73d5 100644 --- a/api/services/tag_service.py +++ b/api/services/tag_service.py @@ -6,6 +6,7 @@ from flask_login import current_user from pydantic import BaseModel, Field from sqlalchemy import delete, func, select from sqlalchemy.engine import CursorResult +from sqlalchemy.orm import Session from werkzeug.exceptions import NotFound from extensions.ext_database import db @@ -38,7 +39,7 @@ class TagBindingDeletePayload(BaseModel): class TagService: @staticmethod - def get_tags(tag_type: str, current_tenant_id: str, keyword: str | None = None): + def get_tags(session: Session, tag_type: str, current_tenant_id: str, keyword: str | None = None): stmt = ( select(Tag.id, Tag.type, Tag.name, func.count(TagBinding.id).label("binding_count")) .outerjoin(TagBinding, Tag.id == TagBinding.tag_id) @@ -50,7 +51,7 @@ class TagService: escaped_keyword = escape_like_pattern(keyword) stmt = stmt.where(sa.and_(Tag.name.ilike(f"%{escaped_keyword}%", escape="\\"))) stmt = stmt.group_by(Tag.id, Tag.type, Tag.name, Tag.created_at) - results: list = list(db.session.execute(stmt.order_by(Tag.created_at.desc())).all()) + results: list = list(session.execute(stmt.order_by(Tag.created_at.desc())).all()) return results @staticmethod diff --git a/api/services/workflow/node_output_inspector_service.py b/api/services/workflow/node_output_inspector_service.py index 3bed0d8c715..66dcfec591f 100644 --- a/api/services/workflow/node_output_inspector_service.py +++ b/api/services/workflow/node_output_inspector_service.py @@ -117,7 +117,7 @@ class NodeOutputView(BaseModel): name: str type: DeclaredOutputType | None = None status: NodeOutputStatus - value_preview: Any = Field(default=None, json_schema_extra={"x-dify-opaque": True}) + value_preview: Any = Field(default=None) type_check: CheckResultView | None = None output_check: CheckResultView | None = None retried: int = 0 @@ -150,7 +150,7 @@ class OutputPreviewView(BaseModel): output_name: str type: DeclaredOutputType | None = None status: NodeOutputStatus - value: Any = Field(default=None, json_schema_extra={"x-dify-opaque": True}) + value: Any = Field(default=None) class NodeOutputInspectorError(Exception): diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index 50dd977749b..9f8e4b83093 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -322,6 +322,12 @@ class WorkflowService: from services.agent.workflow_publish_service import WorkflowAgentPublishService + db.session.flush() + WorkflowAgentPublishService.sync_agent_bindings_for_draft( + session=cast(Session, db.session), + draft_workflow=workflow, + account_id=account.id, + ) WorkflowAgentPublishService.validate_agent_nodes_for_draft_sync( session=cast(Session, db.session), draft_workflow=workflow, diff --git a/api/tasks/app_generate/resume_agent_app_task.py b/api/tasks/app_generate/resume_agent_app_task.py new file mode 100644 index 00000000000..118a476bc8d --- /dev/null +++ b/api/tasks/app_generate/resume_agent_app_task.py @@ -0,0 +1,70 @@ +"""Background resume of an Agent v2 chat after a submitted ask_human HITL form. + +ENG-635. When a human submits a conversation-owned ask_human form (delivered via +email/webapp), ``HumanInputService`` enqueues this task. It reconstructs the +conversation context and runs one blocking Agent App turn; the runner detects the +answered form and continues the agent run with the human's reply +(``deferred_tool_results``), persisting the assistant answer to the conversation. +""" + +from __future__ import annotations + +import logging + +from celery import shared_task + +from core.app.apps.agent_app.app_generator import AgentAppGenerator +from core.app.entities.app_invoke_entities import InvokeFrom +from extensions.ext_database import db +from models.account import Account +from models.human_input import HumanInputForm +from models.model import App, Conversation, EndUser +from tasks.app_generate.workflow_execute_task import WORKFLOW_BASED_APP_EXECUTION_QUEUE + +logger = logging.getLogger(__name__) + + +@shared_task(queue=WORKFLOW_BASED_APP_EXECUTION_QUEUE, name="resume_agent_app_execution") +def resume_agent_app_execution(*, conversation_id: str, form_id: str) -> None: + form = db.session.get(HumanInputForm, form_id) + if form is None or form.conversation_id != conversation_id: + logger.warning("Agent App resume: form %s missing or conversation mismatch", form_id) + return + + app_model = db.session.get(App, form.app_id) + if app_model is None: + logger.warning("Agent App resume: app %s not found for form %s", form.app_id, form_id) + return + + conversation = db.session.get(Conversation, conversation_id) + if conversation is None: + logger.warning("Agent App resume: conversation %s not found", conversation_id) + return + + user = _resolve_conversation_user(app_model=app_model, conversation=conversation) + if user is None: + logger.warning("Agent App resume: no user resolvable for conversation %s", conversation_id) + return + + try: + AgentAppGenerator().resume_after_form_submission( + app_model=app_model, + user=user, + conversation_id=conversation_id, + invoke_from=InvokeFrom.WEB_APP, + ) + except Exception: + logger.exception("Agent App resume failed for conversation %s form %s", conversation_id, form_id) + finally: + db.session.close() + + +def _resolve_conversation_user(*, app_model: App, conversation: Conversation) -> Account | EndUser | None: + if conversation.from_account_id: + account = db.session.get(Account, conversation.from_account_id) + if account is not None: + account.set_tenant_id(app_model.tenant_id) + return account + if conversation.from_end_user_id: + return db.session.get(EndUser, conversation.from_end_user_id) + return None diff --git a/api/tasks/human_input_timeout_tasks.py b/api/tasks/human_input_timeout_tasks.py index fd743205a1b..f670d5a3382 100644 --- a/api/tasks/human_input_timeout_tasks.py +++ b/api/tasks/human_input_timeout_tasks.py @@ -95,16 +95,30 @@ def check_and_handle_human_input_timeouts(limit: int = 100) -> None: timeout_status=HumanInputFormStatus.EXPIRED if is_global else HumanInputFormStatus.TIMEOUT, reason="global_timeout" if is_global else "node_timeout", ) - assert record.workflow_run_id is not None, "workflow_run_id should not be None for non-test form" if is_global: + # Global timeout applies only to workflow-owned forms + # (_is_global_timeout requires a workflow_run_id): end the run. + assert record.workflow_run_id is not None, "global timeout requires a workflow_run_id" _handle_global_timeout( form_id=record.form_id, workflow_run_id=record.workflow_run_id, node_id=record.node_id, session_factory=session_factory, ) - else: + elif record.workflow_run_id is not None: + # Workflow Agent node / Human Input node form: resume the workflow. service.enqueue_resume(record.workflow_run_id) + elif record.conversation_id is not None: + # ENG-635: Agent v2 chat ask_human form is conversation-owned (no + # workflow_run_id). Resume the chat turn so the timeout is threaded + # back to the agent run as the ask_human deferred_tool_result + # (status="timeout"), mirroring HumanInputService.submit_form_by_token. + service.enqueue_agent_app_resume(conversation_id=record.conversation_id, form_id=record.form_id) + else: + logger.warning( + "Timed-out form %s has neither workflow_run_id nor conversation_id; skipping resume", + record.form_id, + ) except Exception: logger.exception( "Failed to handle timeout for form_id=%s workflow_run_id=%s", diff --git a/api/tasks/process_tenant_plugin_autoupgrade_check_task.py b/api/tasks/process_tenant_plugin_autoupgrade_check_task.py index ab54b9e72ed..0840a595b7d 100644 --- a/api/tasks/process_tenant_plugin_autoupgrade_check_task.py +++ b/api/tasks/process_tenant_plugin_autoupgrade_check_task.py @@ -7,7 +7,7 @@ import click from celery import shared_task from core.plugin.entities.marketplace import MarketplacePluginSnapshot -from core.plugin.entities.plugin import PluginInstallationSource +from core.plugin.entities.plugin import PluginInstallation, PluginInstallationSource from core.plugin.impl.plugin import PluginInstaller from core.plugin.plugin_service import PluginService from extensions.ext_redis import redis_client @@ -15,6 +15,7 @@ from models.account import TenantPluginAutoUpgradeStrategy logger = logging.getLogger(__name__) +PluginCategory = TenantPluginAutoUpgradeStrategy.PluginCategory RETRY_TIMES_OF_ONE_PLUGIN_IN_ONE_TENANT = 3 CACHE_REDIS_KEY_PREFIX = "plugin_autoupgrade_check_task:cached_plugin_snapshot:" CACHE_REDIS_TTL = 60 * 60 # 1 hour @@ -72,6 +73,25 @@ def marketplace_batch_fetch_plugin_manifests( return result +def _normalize_category(category: PluginCategory | str | None) -> str | None: + if category is None: + return None + if isinstance(category, PluginCategory): + return category.value + return str(category) + + +def _plugin_matches_category(plugin: PluginInstallation, category: str | None) -> bool: + """Return whether an installed plugin should be checked by a category strategy.""" + if category is None: + return True + + declaration = getattr(plugin, "declaration", None) + plugin_category = getattr(declaration, "category", None) + plugin_category_value = getattr(plugin_category, "value", plugin_category) + return plugin_category_value == category + + @shared_task(queue="plugin") def process_tenant_plugin_autoupgrade_check_task( tenant_id: str, @@ -80,13 +100,15 @@ def process_tenant_plugin_autoupgrade_check_task( upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode, exclude_plugins: list[str], include_plugins: list[str], + category: PluginCategory | str | None = None, ): try: manager = PluginInstaller() + category_value = _normalize_category(category) click.echo( click.style( - f"Checking upgradable plugin for tenant: {tenant_id}", + f"Checking upgradable plugin for tenant: {tenant_id}, category: {category_value or 'all'}", fg="green", ) ) @@ -102,7 +124,11 @@ def process_tenant_plugin_autoupgrade_check_task( all_plugins = manager.list_plugins(tenant_id) for plugin in all_plugins: - if plugin.source == PluginInstallationSource.Marketplace and plugin.plugin_id in include_plugins: + if ( + plugin.source == PluginInstallationSource.Marketplace + and plugin.plugin_id in include_plugins + and _plugin_matches_category(plugin, category_value) + ): plugin_ids.append( ( plugin.plugin_id, @@ -117,7 +143,9 @@ def process_tenant_plugin_autoupgrade_check_task( plugin_ids = [ (plugin.plugin_id, plugin.version, plugin.plugin_unique_identifier) for plugin in all_plugins - if plugin.source == PluginInstallationSource.Marketplace and plugin.plugin_id not in exclude_plugins + if plugin.source == PluginInstallationSource.Marketplace + and plugin.plugin_id not in exclude_plugins + and _plugin_matches_category(plugin, category_value) ] elif upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL: all_plugins = manager.list_plugins(tenant_id) @@ -125,6 +153,7 @@ def process_tenant_plugin_autoupgrade_check_task( (plugin.plugin_id, plugin.version, plugin.plugin_unique_identifier) for plugin in all_plugins if plugin.source == PluginInstallationSource.Marketplace + and _plugin_matches_category(plugin, category_value) ] if not plugin_ids: diff --git a/api/tasks/remove_app_and_related_data_task.py b/api/tasks/remove_app_and_related_data_task.py index 5f1f0952af5..d0763a7a1ad 100644 --- a/api/tasks/remove_app_and_related_data_task.py +++ b/api/tasks/remove_app_and_related_data_task.py @@ -22,6 +22,7 @@ from models import ( AppDatasetJoin, AppMCPServer, AppModelConfig, + AppStar, AppTrigger, Conversation, EndUser, @@ -64,6 +65,7 @@ def remove_app_and_related_data_task(self, tenant_id: str, app_id: str): _delete_app_mcp_servers(tenant_id, app_id) _delete_app_api_tokens(tenant_id, app_id) _delete_installed_apps(tenant_id, app_id) + _delete_app_stars(tenant_id, app_id) _delete_recommended_apps(tenant_id, app_id) _delete_app_annotation_data(tenant_id, app_id) _delete_app_dataset_joins(tenant_id, app_id) @@ -173,6 +175,18 @@ def _delete_installed_apps(tenant_id: str, app_id: str): ) +def _delete_app_stars(tenant_id: str, app_id: str): + def del_app_star(session, app_star_id: str): + session.execute(delete(AppStar).where(AppStar.id == app_star_id).execution_options(synchronize_session=False)) + + _delete_records( + """select id from app_stars where tenant_id=:tenant_id and app_id=:app_id limit 1000""", + {"tenant_id": tenant_id, "app_id": app_id}, + del_app_star, + "app star", + ) + + def _delete_recommended_apps(tenant_id: str, app_id: str): def del_recommended_app(session, recommended_app_id: str): session.execute( diff --git a/api/tasks/workflow_execution_tasks.py b/api/tasks/workflow_execution_tasks.py index 5ca04fd7c2e..d1eb24d860d 100644 --- a/api/tasks/workflow_execution_tasks.py +++ b/api/tasks/workflow_execution_tasks.py @@ -101,11 +101,13 @@ def _create_workflow_run_from_execution( workflow_run.triggered_from = triggered_from workflow_run.version = execution.workflow_version json_converter = WorkflowRuntimeTypeConverter() - workflow_run.graph = json.dumps(json_converter.to_json_encodable(execution.graph)) - workflow_run.inputs = json.dumps(json_converter.to_json_encodable(execution.inputs)) + workflow_run.graph = json.dumps(json_converter.to_json_encodable(execution.graph), ensure_ascii=False) + workflow_run.inputs = json.dumps(json_converter.to_json_encodable(execution.inputs), ensure_ascii=False) workflow_run.status = execution.status workflow_run.outputs = ( - json.dumps(json_converter.to_json_encodable(execution.outputs)) if execution.outputs else "{}" + json.dumps(json_converter.to_json_encodable(execution.outputs), ensure_ascii=False) + if execution.outputs + else "{}" ) workflow_run.error = execution.error_message workflow_run.elapsed_time = execution.elapsed_time diff --git a/api/tests/fixtures/workflow/response_stream_filter_issue_170_workflow.yml b/api/tests/fixtures/workflow/response_stream_filter_issue_170_workflow.yml new file mode 100644 index 00000000000..f2f6e779c3a --- /dev/null +++ b/api/tests/fixtures/workflow/response_stream_filter_issue_170_workflow.yml @@ -0,0 +1,138 @@ +app: + description: Response stream ordering fixture matching graphon issue 170. + icon: 🤖 + icon_background: '#FFEAD5' + mode: advanced-chat + name: response_stream_filter_issue_170_workflow + use_icon_as_answer_icon: false +dependencies: [] +kind: app +version: 0.3.1 +workflow: + conversation_variables: [] + environment_variables: [] + features: + file_upload: {} + opening_statement: '' + retriever_resource: + enabled: true + sensitive_word_avoidance: + enabled: false + speech_to_text: + enabled: false + suggested_questions: [] + suggested_questions_after_answer: + enabled: false + text_to_speech: + enabled: false + language: '' + voice: '' + graph: + edges: + - id: start-llm + source: start + sourceHandle: source + target: llm + targetHandle: target + - id: llm-dufu + source: llm + sourceHandle: source + target: dufu + targetHandle: target + - id: dufu-answer + source: dufu + sourceHandle: source + target: answer + targetHandle: target + nodes: + - data: + desc: '' + title: Start + type: start + variables: [] + id: start + position: + x: 80 + y: 282 + sourcePosition: right + targetPosition: left + type: custom + - data: + context: + enabled: false + variable_selector: [] + desc: '' + memory: + query_prompt_template: '{{#sys.query#}}' + window: + enabled: false + size: 10 + model: + completion_params: + temperature: 0.7 + mode: chat + name: gpt-4o-mini + provider: openai + prompt_template: + - role: system + text: Please output a poem by Li Bai + selected: false + title: Li Bai + type: llm + variables: [] + vision: + enabled: false + id: llm + position: + x: 380 + y: 282 + sourcePosition: right + targetPosition: left + type: custom + - data: + context: + enabled: false + variable_selector: [] + desc: '' + model: + completion_params: + temperature: 0.7 + mode: chat + name: gpt-4o-mini + provider: openai + prompt_template: + - role: system + text: Please output a poem by Du Fu + selected: false + title: Du Fu + type: llm + variables: [] + vision: + enabled: false + id: dufu + position: + x: 680 + y: 282 + sourcePosition: right + targetPosition: left + type: custom + - data: + answer: |- + # Du Fu + + {{#dufu.text#}} + + # Li Bai + + {{#llm.text#}} + desc: '' + title: Answer + type: answer + variables: [] + id: answer + position: + x: 980 + y: 282 + sourcePosition: right + targetPosition: left + type: custom diff --git a/api/tests/integration_tests/services/plugin/test_plugin_lifecycle.py b/api/tests/integration_tests/services/plugin/test_plugin_lifecycle.py index 0a19debc392..fc76129e3c7 100644 --- a/api/tests/integration_tests/services/plugin/test_plugin_lifecycle.py +++ b/api/tests/integration_tests/services/plugin/test_plugin_lifecycle.py @@ -7,6 +7,8 @@ from models.account import TenantPluginAutoUpgradeStrategy, TenantPluginPermissi from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService from services.plugin.plugin_permission_service import PluginPermissionService +PLUGIN_CATEGORY = TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL + @pytest.fixture def tenant(flask_req_ctx): @@ -71,7 +73,7 @@ class TestPluginPermissionLifecycle: class TestPluginAutoUpgradeLifecycle: def test_get_returns_none_for_new_tenant(self, tenant): - assert PluginAutoUpgradeService.get_strategy(tenant) is None + assert PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY) is None def test_change_creates_row(self, tenant): result = PluginAutoUpgradeService.change_strategy( @@ -81,10 +83,11 @@ class TestPluginAutoUpgradeLifecycle: upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL, exclude_plugins=[], include_plugins=[], + category=PLUGIN_CATEGORY, ) assert result is True - strategy = PluginAutoUpgradeService.get_strategy(tenant) + strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY) assert strategy is not None assert strategy.strategy_setting == TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST assert strategy.upgrade_time_of_day == 3 @@ -97,6 +100,7 @@ class TestPluginAutoUpgradeLifecycle: upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL, exclude_plugins=[], include_plugins=[], + category=PLUGIN_CATEGORY, ) PluginAutoUpgradeService.change_strategy( tenant, @@ -105,9 +109,10 @@ class TestPluginAutoUpgradeLifecycle: upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL, exclude_plugins=[], include_plugins=["plugin-a"], + category=PLUGIN_CATEGORY, ) - strategy = PluginAutoUpgradeService.get_strategy(tenant) + strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY) assert strategy is not None assert strategy.strategy_setting == TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST assert strategy.upgrade_time_of_day == 12 @@ -115,9 +120,9 @@ class TestPluginAutoUpgradeLifecycle: assert strategy.include_plugins == ["plugin-a"] def test_exclude_plugin_creates_strategy_when_none_exists(self, tenant): - PluginAutoUpgradeService.exclude_plugin(tenant, "my-plugin") + PluginAutoUpgradeService.exclude_plugin(tenant, "my-plugin", PLUGIN_CATEGORY) - strategy = PluginAutoUpgradeService.get_strategy(tenant) + strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY) assert strategy is not None assert strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE assert "my-plugin" in strategy.exclude_plugins @@ -130,10 +135,11 @@ class TestPluginAutoUpgradeLifecycle: upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE, exclude_plugins=["existing"], include_plugins=[], + category=PLUGIN_CATEGORY, ) - PluginAutoUpgradeService.exclude_plugin(tenant, "new-plugin") + PluginAutoUpgradeService.exclude_plugin(tenant, "new-plugin", PLUGIN_CATEGORY) - strategy = PluginAutoUpgradeService.get_strategy(tenant) + strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY) assert strategy is not None assert "existing" in strategy.exclude_plugins assert "new-plugin" in strategy.exclude_plugins @@ -146,10 +152,11 @@ class TestPluginAutoUpgradeLifecycle: upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE, exclude_plugins=["same-plugin"], include_plugins=[], + category=PLUGIN_CATEGORY, ) - PluginAutoUpgradeService.exclude_plugin(tenant, "same-plugin") + PluginAutoUpgradeService.exclude_plugin(tenant, "same-plugin", PLUGIN_CATEGORY) - strategy = PluginAutoUpgradeService.get_strategy(tenant) + strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY) assert strategy is not None assert strategy.exclude_plugins.count("same-plugin") == 1 @@ -161,10 +168,11 @@ class TestPluginAutoUpgradeLifecycle: upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL, exclude_plugins=[], include_plugins=["p1", "p2"], + category=PLUGIN_CATEGORY, ) - PluginAutoUpgradeService.exclude_plugin(tenant, "p1") + PluginAutoUpgradeService.exclude_plugin(tenant, "p1", PLUGIN_CATEGORY) - strategy = PluginAutoUpgradeService.get_strategy(tenant) + strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY) assert strategy is not None assert "p1" not in strategy.include_plugins assert "p2" in strategy.include_plugins @@ -177,10 +185,11 @@ class TestPluginAutoUpgradeLifecycle: upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL, exclude_plugins=[], include_plugins=[], + category=PLUGIN_CATEGORY, ) - PluginAutoUpgradeService.exclude_plugin(tenant, "excluded-plugin") + PluginAutoUpgradeService.exclude_plugin(tenant, "excluded-plugin", PLUGIN_CATEGORY) - strategy = PluginAutoUpgradeService.get_strategy(tenant) + strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY) assert strategy is not None assert strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE assert "excluded-plugin" in strategy.exclude_plugins diff --git a/api/tests/integration_tests/workflow/test_response_stream_filter_integration.py b/api/tests/integration_tests/workflow/test_response_stream_filter_integration.py new file mode 100644 index 00000000000..fd1391849fd --- /dev/null +++ b/api/tests/integration_tests/workflow/test_response_stream_filter_integration.py @@ -0,0 +1,75 @@ +"""Integration coverage for Dify's ResponseStreamFilter boundary behavior.""" + +from core.workflow.workflow_entry import iter_dify_graph_engine_events +from graphon.graph_engine import GraphEngine, GraphEngineConfig +from graphon.graph_engine.command_channels import InMemoryChannel +from graphon.graph_events import GraphRunSucceededEvent, NodeRunStreamChunkEvent +from tests.unit_tests.core.workflow.graph_engine.test_mock_config import MockConfigBuilder +from tests.unit_tests.core.workflow.graph_engine.test_table_runner import WorkflowRunner + + +def _build_issue_170_mock_config(): + runner = WorkflowRunner() + mock_config = ( + MockConfigBuilder() + .with_node_output( + "llm", + { + "text": "Quiet Night Thought", + "usage": { + "prompt_tokens": 10, + "completion_tokens": 5, + "total_tokens": 15, + }, + "finish_reason": "stop", + }, + ) + .with_node_output( + "dufu", + { + "text": "Spring View", + "usage": { + "prompt_tokens": 10, + "completion_tokens": 5, + "total_tokens": 15, + }, + "finish_reason": "stop", + }, + ) + .build() + ) + + return runner, mock_config + + +def test_dify_response_stream_filter_handles_issue_170_shape() -> None: + runner, mock_config = _build_issue_170_mock_config() + fixture_data = runner.load_fixture("response_stream_filter_issue_170_workflow") + graph, graph_runtime_state = runner.create_graph_from_fixture( + fixture_data=fixture_data, + query="1", + use_mock_factory=True, + mock_config=mock_config, + ) + + expected_answer = "# Du Fu\n\nSpring View\n\n# Li Bai\n\nQuiet Night Thought" + + engine = GraphEngine( + workflow_id="test_workflow", + graph=graph, + graph_runtime_state=graph_runtime_state, + command_channel=InMemoryChannel(), + config=GraphEngineConfig(), + ) + events = list(iter_dify_graph_engine_events(engine)) + + stream_chunk_events = [event for event in events if isinstance(event, NodeRunStreamChunkEvent)] + success_events = [event for event in events if isinstance(event, GraphRunSucceededEvent)] + + assert success_events + assert stream_chunk_events + actual_answer = "".join(event.chunk for event in stream_chunk_events) + assert actual_answer.strip() == expected_answer + assert stream_chunk_events[-1].is_final is True + assert success_events[-1].outputs["answer"].strip() == expected_answer + assert actual_answer.strip() == success_events[-1].outputs["answer"].strip() diff --git a/api/tests/test_containers_integration_tests/.ruff.toml b/api/tests/test_containers_integration_tests/.ruff.toml index 390eb14851c..250cf103ab9 100644 --- a/api/tests/test_containers_integration_tests/.ruff.toml +++ b/api/tests/test_containers_integration_tests/.ruff.toml @@ -10,7 +10,6 @@ extend-select = ["ANN401", "ARG", "TID251"] "services/test_app_dsl_service.py" = ["ANN401", "TID251", "ARG"] "services/test_file_service_zip_and_lookup.py" = ["ANN401", "TID251", "ARG"] "services/test_hit_testing_service.py" = ["ANN401", "TID251"] -"services/test_recommended_app_service.py" = ["ANN401", "TID251", "ARG"] "trigger/conftest.py" = ["ANN401", "TID251"] "trigger/test_trigger_e2e.py" = ["ANN401", "TID251", "ARG"] "controllers/console/app/test_app_apis.py" = ["ARG"] @@ -21,6 +20,8 @@ extend-select = ["ANN401", "ARG", "TID251"] "controllers/console/test_apikey.py" = ["ARG"] "controllers/console/workspace/test_tool_provider.py" = ["ARG"] "controllers/mcp/test_mcp.py" = ["ARG"] +"controllers/openapi/test_app_dsl.py" = ["ARG"] +"controllers/openapi/test_workspaces.py" = ["ARG"] "controllers/service_api/dataset/test_dataset.py" = ["ARG"] "controllers/web/test_conversation.py" = ["ARG"] "controllers/web/test_human_input_form.py" = ["ARG"] diff --git a/api/tests/test_containers_integration_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline.py b/api/tests/test_containers_integration_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline.py index 027356b6278..75b0d3c5002 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline.py +++ b/api/tests/test_containers_integration_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable +from inspect import unwrap from typing import cast from unittest.mock import MagicMock, patch from uuid import uuid4 @@ -20,8 +21,8 @@ from controllers.console.datasets.rag_pipeline.rag_pipeline import ( PipelineTemplateListApi, PublishCustomizedPipelineTemplateApi, ) +from models.account import Account from models.dataset import PipelineCustomizedTemplate -from tests.test_containers_integration_tests.controllers.console.helpers import unwrap class TestPipelineTemplateListApi: @@ -53,7 +54,7 @@ class TestPipelineTemplateListApi: return_value=templates, ), ): - response, status = method(api) + response, status = method(api, str(uuid4())) assert status == 200 assert response == { @@ -147,6 +148,9 @@ class TestCustomizedPipelineTemplateApi: def test_patch_success(self, app: Flask) -> None: api = CustomizedPipelineTemplateApi() method = unwrap(api.patch) + account = Account(name="Test User", email="test@example.com") + account.id = str(uuid4()) + tenant_id = str(uuid4()) payload = { "name": "Template", @@ -161,15 +165,18 @@ class TestCustomizedPipelineTemplateApi: "controllers.console.datasets.rag_pipeline.rag_pipeline.RagPipelineService.update_customized_pipeline_template" ) as update_mock, ): - response, status = method(api, "tpl-1") + response, status = method(api, tenant_id, account, "tpl-1") update_mock.assert_called_once() + assert update_mock.call_args.args[2] is account + assert update_mock.call_args.args[3] == tenant_id assert status == 204 assert response == "" def test_delete_success(self, app: Flask) -> None: api = CustomizedPipelineTemplateApi() method = unwrap(api.delete) + tenant_id = str(uuid4()) with ( app.test_request_context("/"), @@ -177,9 +184,9 @@ class TestCustomizedPipelineTemplateApi: "controllers.console.datasets.rag_pipeline.rag_pipeline.RagPipelineService.delete_customized_pipeline_template" ) as delete_mock, ): - response, status = method(api, "tpl-1") + response, status = method(api, tenant_id, "tpl-1") - delete_mock.assert_called_once_with("tpl-1") + delete_mock.assert_called_once_with("tpl-1", tenant_id) assert status == 204 assert response == "" @@ -227,6 +234,9 @@ class TestPublishCustomizedPipelineTemplateApi: def test_post_success(self, app: Flask) -> None: api = PublishCustomizedPipelineTemplateApi() method = unwrap(api.post) + account = Account(name="Test User", email="test@example.com") + account.id = str(uuid4()) + tenant_id = str(uuid4()) payload = { "name": "Template", @@ -244,8 +254,10 @@ class TestPublishCustomizedPipelineTemplateApi: return_value=service, ), ): - response, status = method(api, "pipeline-1") + response, status = method(api, tenant_id, account, "pipeline-1") service.publish_customized_pipeline_template.assert_called_once() + assert service.publish_customized_pipeline_template.call_args.args[2] is account + assert service.publish_customized_pipeline_template.call_args.args[3] == tenant_id assert status == 204 assert response == "" 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 d1d8e6fd757..bdec903ef33 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 @@ -5,7 +5,6 @@ from __future__ import annotations import json from datetime import datetime from inspect import unwrap -from types import SimpleNamespace from typing import TypedDict, Unpack from unittest.mock import MagicMock, patch from uuid import uuid4 @@ -35,12 +34,15 @@ from controllers.console.datasets.rag_pipeline.rag_pipeline_workflow import ( RagPipelineTaskStopApi, RagPipelineTransformApi, RagPipelineWorkflowLastRunApi, + RagPipelineWorkflowRunNodeExecutionListApi, ) from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError +from graphon.enums import WorkflowNodeExecutionStatus from libs.datetime_utils import naive_utc_now from models.account import Account, TenantAccountRole from models.dataset import Pipeline -from models.workflow import Workflow +from models.enums import CreatorUserRole +from models.workflow import Workflow, WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError from services.errors.llm import InvokeRateLimitError @@ -71,7 +73,7 @@ class WorkflowFactoryPayload(TypedDict): created_by: str created_at: datetime updated_by: str | None - updated_at: datetime + updated_at: datetime | None environment_variables: list[WorkflowVariablePayload] conversation_variables: list[WorkflowVariablePayload] rag_pipeline_variables: list[WorkflowVariablePayload] @@ -90,39 +92,69 @@ class WorkflowFactoryOverrides(TypedDict, total=False): created_by: str created_at: datetime updated_by: str | None - updated_at: datetime + updated_at: datetime | None environment_variables: list[WorkflowVariablePayload] conversation_variables: list[WorkflowVariablePayload] rag_pipeline_variables: list[WorkflowVariablePayload] -def make_node_execution(**overrides: object) -> SimpleNamespace: - payload: dict[str, object] = { +class NodeExecutionOverrides(TypedDict, total=False): + id: str + tenant_id: str + app_id: str + workflow_id: str + workflow_run_id: str | None + index: int + predecessor_node_id: str | None + node_execution_id: str | None + node_id: str + node_type: str + title: str + inputs: str | None + process_data: str | None + outputs: str | None + status: WorkflowNodeExecutionStatus + error: str | None + elapsed_time: float + execution_metadata: str | None + created_at: datetime + created_by_role: CreatorUserRole + created_by: str + finished_at: datetime | None + + +def make_node_execution(**overrides: Unpack[NodeExecutionOverrides]) -> WorkflowNodeExecutionModel: + payload: NodeExecutionOverrides = { "id": "node-exec-1", + "tenant_id": DEFAULT_WORKFLOW_TENANT_ID, + "app_id": DEFAULT_WORKFLOW_APP_ID, + "workflow_id": "workflow-1", + "workflow_run_id": None, "index": 1, "predecessor_node_id": None, + "node_execution_id": None, "node_id": "node1", "node_type": "start", "title": "Start", - "inputs_dict": {"query": "hello"}, - "process_data_dict": {}, - "outputs_dict": {"answer": "world"}, - "status": "succeeded", + "inputs": json.dumps({"query": "hello"}), + "process_data": json.dumps({}), + "outputs": json.dumps({"answer": "world"}), + "status": WorkflowNodeExecutionStatus.SUCCEEDED, "error": None, "elapsed_time": 1.0, - "execution_metadata_dict": {}, - "extras": {}, + "execution_metadata": json.dumps({}), "created_at": datetime(2026, 1, 1, 0, 0, 0), - "created_by_role": "account", - "created_by_account": None, - "created_by_end_user": None, + "created_by_role": CreatorUserRole.ACCOUNT, + "created_by": DEFAULT_WORKFLOW_CREATED_BY, "finished_at": datetime(2026, 1, 1, 0, 0, 1), - "inputs_truncated": False, - "outputs_truncated": False, - "process_data_truncated": False, } payload.update(overrides) - return SimpleNamespace(**payload) + execution = WorkflowNodeExecutionModel( + triggered_from=WorkflowNodeExecutionTriggeredFrom.RAG_PIPELINE_RUN, + **payload, + ) + execution.offload_data = [] + return execution def default_workflow_payload() -> WorkflowFactoryPayload: @@ -274,7 +306,10 @@ class TestDraftWorkflowApi: pipeline = make_pipeline() user = make_account(id="account-1") - workflow = MagicMock(unique_hash="restored-hash", updated_at=None, created_at=datetime(2024, 1, 1)) + workflow = make_workflow( + graph=json.dumps({"nodes": [{"id": "restored"}], "edges": []}), + created_at=datetime(2024, 1, 1), + ) service = MagicMock() service.restore_published_workflow_to_draft.return_value = workflow @@ -289,7 +324,7 @@ class TestDraftWorkflowApi: result = method(api, user, pipeline, "published-workflow") assert result["result"] == "success" - assert result["hash"] == "restored-hash" + assert result["hash"] == workflow.unique_hash def test_restore_published_workflow_to_draft_not_found(self, app: Flask) -> None: api = RagPipelineDraftWorkflowRestoreApi() @@ -515,10 +550,7 @@ class TestPublishedPipelineApis: user = make_account(id="u1") - workflow = MagicMock( - id=str(uuid4()), - created_at=naive_utc_now(), - ) + workflow = make_workflow(id=str(uuid4()), created_at=naive_utc_now()) service = MagicMock() service.publish_workflow.return_value = workflow @@ -576,6 +608,8 @@ class TestMiscApis: service = MagicMock() service.get_recommended_plugins.return_value = [{"id": "p1"}] + user = make_account() + tenant_id = "tenant-1" with ( app.test_request_context("/?type=all"), @@ -584,8 +618,9 @@ class TestMiscApis: return_value=service, ), ): - result = method(api) + result = method(api, tenant_id, user) assert result == [{"id": "p1"}] + service.get_recommended_plugins.assert_called_once_with("all", user, tenant_id) class TestPublishedRagPipelineRunApi: @@ -814,7 +849,7 @@ class TestRagPipelineWorkflowLastRunApi: method = unwrap(api.get) pipeline = make_pipeline() - workflow = MagicMock() + workflow = make_workflow() node_exec = make_node_execution() service = MagicMock() @@ -853,6 +888,42 @@ class TestRagPipelineWorkflowLastRunApi: method(api, pipeline, "node1") +class TestRagPipelineWorkflowRunNodeExecutionListApi: + @pytest.fixture + def app(self, flask_app_with_containers: Flask) -> Flask: + return flask_app_with_containers + + def test_get_node_executions_passes_current_user(self, app: Flask) -> None: + api = RagPipelineWorkflowRunNodeExecutionListApi() + method = unwrap(api.get) + + user = make_account() + pipeline = make_pipeline() + run_id = uuid4() + node_exec = make_node_execution(workflow_run_id=str(run_id)) + + service = MagicMock() + service.get_rag_pipeline_workflow_run_node_executions.return_value = [node_exec] + + with ( + app.test_request_context("/"), + patch( + "controllers.console.datasets.rag_pipeline.rag_pipeline_workflow.RagPipelineService", + return_value=service, + ), + ): + result = method(api, user, pipeline, run_id) + + service.get_rag_pipeline_workflow_run_node_executions.assert_called_once_with( + pipeline=pipeline, + run_id=str(run_id), + user=user, + ) + assert result["data"][0]["id"] == "node-exec-1" + assert result["data"][0]["inputs"] == {"query": "hello"} + assert result["data"][0]["outputs"] == {"answer": "world"} + + class TestRagPipelineDatasourceVariableApi: @pytest.fixture def app(self, flask_app_with_containers: Flask) -> Flask: diff --git a/api/tests/test_containers_integration_tests/controllers/console/explore/test_conversation.py b/api/tests/test_containers_integration_tests/controllers/console/explore/test_conversation.py index b5f5917ee99..3d5fce4b6ca 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/explore/test_conversation.py +++ b/api/tests/test_containers_integration_tests/controllers/console/explore/test_conversation.py @@ -2,7 +2,10 @@ from __future__ import annotations -from unittest.mock import MagicMock, patch +from dataclasses import dataclass +from inspect import unwrap +from typing import cast +from unittest.mock import patch import pytest from flask import Flask @@ -10,85 +13,103 @@ from werkzeug.exceptions import NotFound import controllers.console.explore.conversation as conversation_module from controllers.console.explore.error import NotChatAppError +from libs.infinite_scroll_pagination import InfiniteScrollPagination from models import Account -from models.model import AppMode +from models.enums import ConversationFromSource, ConversationStatus +from models.model import App, AppMode, Conversation, InstalledApp from services.errors.conversation import ( ConversationNotExistsError, LastConversationNotExistsError, ) -def unwrap(func): - while hasattr(func, "__wrapped__"): - func = func.__wrapped__ - return func - - -class FakeConversation: - def __init__(self, cid): - self.id = cid - self.name = "test" - self.inputs = {} - self.status = "normal" - self.introduction = "" +@dataclass +class InstalledAppCarrier: + app: App | None @pytest.fixture -def chat_app(): - app_model = MagicMock(mode=AppMode.CHAT, id="app-id") - return MagicMock(app=app_model) +def chat_app() -> InstalledApp: + app_model = App( + tenant_id="tenant-1", + name="Chat App", + mode=AppMode.CHAT, + enable_site=True, + enable_api=False, + ) + app_model.id = "app-id" + return cast(InstalledApp, InstalledAppCarrier(app=app_model)) @pytest.fixture -def non_chat_app(): - app_model = MagicMock(mode=AppMode.COMPLETION) - return MagicMock(app=app_model) +def non_chat_app() -> InstalledApp: + app_model = App( + tenant_id="tenant-1", + name="Completion App", + mode=AppMode.COMPLETION, + enable_site=True, + enable_api=False, + ) + app_model.id = "app-id" + return cast(InstalledApp, InstalledAppCarrier(app=app_model)) + + +def make_conversation(*, id: str) -> Conversation: + conversation = Conversation( + app_id="app-id", + mode=AppMode.CHAT, + name="test", + from_source=ConversationFromSource.API, + ) + conversation.id = id + conversation.inputs = {} + conversation.status = ConversationStatus.NORMAL + conversation.introduction = "" + return conversation @pytest.fixture -def user(): - user = MagicMock(spec=Account) +def user() -> Account: + user = Account(name="User", email="user.com") user.id = "uid" return user class TestConversationListApi: @pytest.fixture - def app(self, flask_app_with_containers: Flask): + def app(self, flask_app_with_containers: Flask) -> Flask: return flask_app_with_containers - def test_get_success(self, app: Flask, chat_app, user): + def test_get_success(self, app: Flask, chat_app: InstalledApp, user: Account) -> None: api = conversation_module.ConversationListApi() method = unwrap(api.get) - pagination = MagicMock( + pagination = InfiniteScrollPagination( + data=[make_conversation(id="c1"), make_conversation(id="c2")], limit=20, has_more=False, - data=[FakeConversation("c1"), FakeConversation("c2")], ) with ( app.test_request_context("/?limit=20"), - patch.object(conversation_module, "current_user", user), patch.object( conversation_module.WebConversationService, "pagination_by_last_id", return_value=pagination, ), ): - result = method(chat_app) + result = method(api, user, chat_app) assert result["limit"] == 20 assert result["has_more"] is False assert len(result["data"]) == 2 - def test_last_conversation_not_exists(self, app: Flask, chat_app, user): + def test_last_conversation_not_exists(self, app: Flask, chat_app: InstalledApp, user: Account) -> None: api = conversation_module.ConversationListApi() method = unwrap(api.get) with ( app.test_request_context("/"), - patch.object(conversation_module, "current_user", user), patch.object( conversation_module.WebConversationService, "pagination_by_last_id", @@ -96,47 +117,45 @@ class TestConversationListApi: ), ): with pytest.raises(NotFound): - method(chat_app) + method(api, user, chat_app) - def test_wrong_app_mode(self, app: Flask, non_chat_app): + def test_wrong_app_mode(self, app: Flask, non_chat_app: InstalledApp, user: Account) -> None: api = conversation_module.ConversationListApi() method = unwrap(api.get) with app.test_request_context("/"): with pytest.raises(NotChatAppError): - method(non_chat_app) + method(api, user, non_chat_app) class TestConversationApi: @pytest.fixture - def app(self, flask_app_with_containers: Flask): + def app(self, flask_app_with_containers: Flask) -> Flask: return flask_app_with_containers - def test_delete_success(self, app: Flask, chat_app, user): + def test_delete_success(self, app: Flask, chat_app: InstalledApp, user: Account) -> None: api = conversation_module.ConversationApi() method = unwrap(api.delete) with ( app.test_request_context("/"), - patch.object(conversation_module, "current_user", user), patch.object( conversation_module.ConversationService, "delete", ), ): - result = method(chat_app, "cid") + result = method(api, user, chat_app, "cid") body, status = result assert status == 204 assert body == "" - def test_delete_not_found(self, app: Flask, chat_app, user): + def test_delete_not_found(self, app: Flask, chat_app: InstalledApp, user: Account) -> None: api = conversation_module.ConversationApi() method = unwrap(api.delete) with ( app.test_request_context("/"), - patch.object(conversation_module, "current_user", user), patch.object( conversation_module.ConversationService, "delete", @@ -144,48 +163,46 @@ class TestConversationApi: ), ): with pytest.raises(NotFound): - method(chat_app, "cid") + method(api, user, chat_app, "cid") - def test_delete_wrong_app_mode(self, app: Flask, non_chat_app): + def test_delete_wrong_app_mode(self, app: Flask, non_chat_app: InstalledApp, user: Account) -> None: api = conversation_module.ConversationApi() method = unwrap(api.delete) with app.test_request_context("/"): with pytest.raises(NotChatAppError): - method(non_chat_app, "cid") + method(api, user, non_chat_app, "cid") class TestConversationRenameApi: @pytest.fixture - def app(self, flask_app_with_containers: Flask): + def app(self, flask_app_with_containers: Flask) -> Flask: return flask_app_with_containers - def test_rename_success(self, app: Flask, chat_app, user): + def test_rename_success(self, app: Flask, chat_app: InstalledApp, user: Account) -> None: api = conversation_module.ConversationRenameApi() method = unwrap(api.post) - conversation = FakeConversation("cid") + conversation = make_conversation(id="cid") with ( app.test_request_context("/", json={"name": "new"}), - patch.object(conversation_module, "current_user", user), patch.object( conversation_module.ConversationService, "rename", return_value=conversation, ), ): - result = method(chat_app, "cid") + result = method(api, user, chat_app, "cid") assert result["id"] == "cid" - def test_rename_not_found(self, app: Flask, chat_app, user): + def test_rename_not_found(self, app: Flask, chat_app: InstalledApp, user: Account) -> None: api = conversation_module.ConversationRenameApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"name": "new"}), - patch.object(conversation_module, "current_user", user), patch.object( conversation_module.ConversationService, "rename", @@ -193,48 +210,46 @@ class TestConversationRenameApi: ), ): with pytest.raises(NotFound): - method(chat_app, "cid") + method(api, user, chat_app, "cid") class TestConversationPinApi: @pytest.fixture - def app(self, flask_app_with_containers: Flask): + def app(self, flask_app_with_containers: Flask) -> Flask: return flask_app_with_containers - def test_pin_success(self, app: Flask, chat_app, user): + def test_pin_success(self, app: Flask, chat_app: InstalledApp, user: Account) -> None: api = conversation_module.ConversationPinApi() method = unwrap(api.patch) with ( app.test_request_context("/"), - patch.object(conversation_module, "current_user", user), patch.object( conversation_module.WebConversationService, "pin", ), ): - result = method(chat_app, "cid") + result = method(api, user, chat_app, "cid") assert result == {"result": "success"} class TestConversationUnPinApi: @pytest.fixture - def app(self, flask_app_with_containers: Flask): + def app(self, flask_app_with_containers: Flask) -> Flask: return flask_app_with_containers - def test_unpin_success(self, app: Flask, chat_app, user): + def test_unpin_success(self, app: Flask, chat_app: InstalledApp, user: Account) -> None: api = conversation_module.ConversationUnPinApi() method = unwrap(api.patch) with ( app.test_request_context("/"), - patch.object(conversation_module, "current_user", user), patch.object( conversation_module.WebConversationService, "unpin", ), ): - result = method(chat_app, "cid") + result = method(api, user, chat_app, "cid") assert result == {"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 6c74b3193b9..6684381880c 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 inspect import unwrap from unittest.mock import MagicMock, patch import pytest @@ -31,123 +32,110 @@ from core.plugin.entities.plugin_daemon import CredentialType from models.account import Account -def unwrap(func): - while hasattr(func, "__wrapped__"): - func = func.__wrapped__ - return func - - -def mock_user(): - user = MagicMock(spec=Account) +def mock_user() -> Account: + user = Account(name="User", email="user.com") user.id = "u1" - user.current_tenant_id = "t1" return user class TestTriggerProviderApis: @pytest.fixture - def app(self, flask_app_with_containers: Flask): + def app(self, flask_app_with_containers: Flask) -> Flask: return flask_app_with_containers - def test_icon_success(self, app: Flask): + def test_icon_success(self, app: Flask) -> None: api = TriggerProviderIconApi() method = unwrap(api.get) with ( app.test_request_context("/"), - patch("controllers.console.workspace.trigger_providers.current_user", mock_user()), patch( "controllers.console.workspace.trigger_providers.TriggerManager.get_trigger_plugin_icon", return_value="icon", ), ): - assert method(api, "github") == "icon" + assert method(api, "t1", "github") == "icon" - def test_list_providers(self, app: Flask): + def test_list_providers(self, app: Flask) -> None: api = TriggerProviderListApi() method = unwrap(api.get) with ( app.test_request_context("/"), - patch("controllers.console.workspace.trigger_providers.current_user", mock_user()), patch( "controllers.console.workspace.trigger_providers.TriggerProviderService.list_trigger_providers", return_value=[], ), ): - assert method(api) == [] + assert method(api, "t1") == [] - def test_provider_info(self, app: Flask): + def test_provider_info(self, app: Flask) -> None: api = TriggerProviderInfoApi() method = unwrap(api.get) with ( app.test_request_context("/"), - patch("controllers.console.workspace.trigger_providers.current_user", mock_user()), patch( "controllers.console.workspace.trigger_providers.TriggerProviderService.get_trigger_provider", return_value={"id": "p1"}, ), ): - assert method(api, "github") == {"id": "p1"} + assert method(api, "t1", "github") == {"id": "p1"} class TestTriggerSubscriptionListApi: @pytest.fixture - def app(self, flask_app_with_containers: Flask): + def app(self, flask_app_with_containers: Flask) -> Flask: return flask_app_with_containers - def test_list_success(self, app: Flask): + def test_list_success(self, app: Flask) -> None: api = TriggerSubscriptionListApi() method = unwrap(api.get) with ( app.test_request_context("/"), - patch("controllers.console.workspace.trigger_providers.current_user", mock_user()), patch( "controllers.console.workspace.trigger_providers.TriggerProviderService.list_trigger_provider_subscriptions", return_value=[], ), ): - assert method(api, "github") == [] + assert method(api, "t1", mock_user(), "github") == [] - def test_list_invalid_provider(self, app: Flask): + def test_list_invalid_provider(self, app: Flask) -> None: api = TriggerSubscriptionListApi() method = unwrap(api.get) with ( app.test_request_context("/"), - patch("controllers.console.workspace.trigger_providers.current_user", mock_user()), patch( "controllers.console.workspace.trigger_providers.TriggerProviderService.list_trigger_provider_subscriptions", side_effect=ValueError("bad"), ), ): - result, status = method(api, "bad") + result, status = method(api, "t1", mock_user(), "bad") assert status == 404 class TestTriggerSubscriptionBuilderApis: @pytest.fixture - def app(self, flask_app_with_containers: Flask): + def app(self, flask_app_with_containers: Flask) -> Flask: return flask_app_with_containers - def test_create_builder(self, app: Flask): + def test_create_builder(self, app: Flask) -> None: api = TriggerSubscriptionBuilderCreateApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"credential_type": "UNAUTHORIZED"}), - patch("controllers.console.workspace.trigger_providers.current_user", mock_user()), patch( "controllers.console.workspace.trigger_providers.TriggerSubscriptionBuilderService.create_trigger_subscription_builder", return_value={"id": "b1"}, ), ): - result = method(api, "github") + result = method(api, "t1", mock_user(), "github") assert "subscription_builder" in result - def test_get_builder(self, app: Flask): + def test_get_builder(self, app: Flask) -> None: api = TriggerSubscriptionBuilderGetApi() method = unwrap(api.get) @@ -160,50 +148,47 @@ class TestTriggerSubscriptionBuilderApis: ): assert method(api, "github", "b1") == {"id": "b1"} - def test_verify_builder(self, app: Flask): + def test_verify_builder(self, app: Flask) -> None: api = TriggerSubscriptionBuilderVerifyApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"credentials": {"a": 1}}), - patch("controllers.console.workspace.trigger_providers.current_user", mock_user()), patch( "controllers.console.workspace.trigger_providers.TriggerSubscriptionBuilderService.update_and_verify_builder", return_value={"ok": True}, ), ): - assert method(api, "github", "b1") == {"ok": True} + assert method(api, "t1", mock_user(), "github", "b1") == {"ok": True} - def test_verify_builder_error(self, app: Flask): + def test_verify_builder_error(self, app: Flask) -> None: api = TriggerSubscriptionBuilderVerifyApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"credentials": {}}), - patch("controllers.console.workspace.trigger_providers.current_user", mock_user()), patch( "controllers.console.workspace.trigger_providers.TriggerSubscriptionBuilderService.update_and_verify_builder", side_effect=Exception("err"), ), ): with pytest.raises(ValueError): - method(api, "github", "b1") + method(api, "t1", mock_user(), "github", "b1") - def test_update_builder(self, app: Flask): + def test_update_builder(self, app: Flask) -> None: api = TriggerSubscriptionBuilderUpdateApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"name": "n"}), - patch("controllers.console.workspace.trigger_providers.current_user", mock_user()), patch( "controllers.console.workspace.trigger_providers.TriggerSubscriptionBuilderService.update_trigger_subscription_builder", return_value={"id": "b1"}, ), ): - assert method(api, "github", "b1") == {"id": "b1"} + assert method(api, "t1", "github", "b1") == {"id": "b1"} - def test_logs(self, app: Flask): + def test_logs(self, app: Flask) -> None: api = TriggerSubscriptionBuilderLogsApi() method = unwrap(api.get) @@ -212,7 +197,6 @@ class TestTriggerSubscriptionBuilderApis: with ( app.test_request_context("/"), - patch("controllers.console.workspace.trigger_providers.current_user", mock_user()), patch( "controllers.console.workspace.trigger_providers.TriggerSubscriptionBuilderService.list_logs", return_value=[log], @@ -220,27 +204,26 @@ class TestTriggerSubscriptionBuilderApis: ): assert "logs" in method(api, "github", "b1") - def test_build(self, app: Flask): + def test_build(self, app: Flask) -> None: api = TriggerSubscriptionBuilderBuildApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"name": "x"}), - patch("controllers.console.workspace.trigger_providers.current_user", mock_user()), patch( "controllers.console.workspace.trigger_providers.TriggerSubscriptionBuilderService.update_and_build_builder", return_value=None, ), ): - assert method(api, "github", "b1") == 200 + assert method(api, "t1", mock_user(), "github", "b1") == 200 class TestTriggerSubscriptionCrud: @pytest.fixture - def app(self, flask_app_with_containers: Flask): + def app(self, flask_app_with_containers: Flask) -> Flask: return flask_app_with_containers - def test_update_rename_only(self, app: Flask): + def test_update_rename_only(self, app: Flask) -> None: api = TriggerSubscriptionUpdateApi() method = unwrap(api.post) @@ -250,43 +233,40 @@ class TestTriggerSubscriptionCrud: with ( app.test_request_context("/", json={"name": "x"}), - patch("controllers.console.workspace.trigger_providers.current_user", mock_user()), patch( "controllers.console.workspace.trigger_providers.TriggerProviderService.get_subscription_by_id", return_value=sub, ), patch("controllers.console.workspace.trigger_providers.TriggerProviderService.update_trigger_subscription"), ): - assert method(api, "s1") == 200 + assert method(api, "t1", "s1") == 200 - def test_update_not_found(self, app: Flask): + def test_update_not_found(self, app: Flask) -> None: api = TriggerSubscriptionUpdateApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"name": "x"}), - patch("controllers.console.workspace.trigger_providers.current_user", mock_user()), patch( "controllers.console.workspace.trigger_providers.TriggerProviderService.get_subscription_by_id", return_value=None, ), ): with pytest.raises(NotFoundError): - method(api, "x") + method(api, "t1", "x") - def test_update_rebuild(self, app: Flask): + def test_update_rebuild(self, app: Flask) -> None: api = TriggerSubscriptionUpdateApi() method = unwrap(api.post) sub = MagicMock() sub.provider_id = "github" sub.credential_type = CredentialType.OAUTH2 - sub.credentials = {} - sub.parameters = {} + sub.credentials = {"token": "old"} + sub.parameters = {"repo": "demo"} with ( app.test_request_context("/", json={"credentials": {}}), - patch("controllers.console.workspace.trigger_providers.current_user", mock_user()), patch( "controllers.console.workspace.trigger_providers.TriggerProviderService.get_subscription_by_id", return_value=sub, @@ -295,9 +275,9 @@ class TestTriggerSubscriptionCrud: "controllers.console.workspace.trigger_providers.TriggerProviderService.rebuild_trigger_subscription" ), ): - assert method(api, "s1") == 200 + assert method(api, "t1", "s1") == 200 - def test_delete_subscription(self, app: Flask): + def test_delete_subscription(self, app: Flask) -> None: api = TriggerSubscriptionDeleteApi() method = unwrap(api.post) @@ -305,7 +285,6 @@ class TestTriggerSubscriptionCrud: with ( app.test_request_context("/"), - patch("controllers.console.workspace.trigger_providers.current_user", mock_user()), patch("controllers.console.workspace.trigger_providers.db") as mock_db, patch("controllers.console.workspace.trigger_providers.sessionmaker") as mock_session_cls, patch("controllers.console.workspace.trigger_providers.TriggerProviderService.delete_trigger_provider"), @@ -316,17 +295,16 @@ class TestTriggerSubscriptionCrud: mock_db.engine = MagicMock() mock_session_cls.return_value.begin.return_value.__enter__.return_value = mock_session - result = method(api, "sub1") + result = method(api, "t1", "sub1") assert result["result"] == "success" - def test_delete_subscription_value_error(self, app: Flask): + def test_delete_subscription_value_error(self, app: Flask) -> None: api = TriggerSubscriptionDeleteApi() method = unwrap(api.post) with ( app.test_request_context("/"), - patch("controllers.console.workspace.trigger_providers.current_user", mock_user()), patch("controllers.console.workspace.trigger_providers.db") as mock_db, patch("controllers.console.workspace.trigger_providers.sessionmaker") as session_cls, patch( @@ -338,21 +316,20 @@ class TestTriggerSubscriptionCrud: session_cls.return_value.begin.return_value.__enter__.return_value = MagicMock() with pytest.raises(BadRequest): - method(api, "sub1") + method(api, "t1", "sub1") class TestTriggerOAuthApis: @pytest.fixture - def app(self, flask_app_with_containers: Flask): + def app(self, flask_app_with_containers: Flask) -> Flask: return flask_app_with_containers - def test_oauth_authorize_success(self, app: Flask): + def test_oauth_authorize_success(self, app: Flask) -> None: api = TriggerOAuthAuthorizeApi() method = unwrap(api.get) with ( app.test_request_context("/"), - patch("controllers.console.workspace.trigger_providers.current_user", mock_user()), patch( "controllers.console.workspace.trigger_providers.TriggerProviderService.get_oauth_client", return_value={"a": 1}, @@ -370,25 +347,24 @@ class TestTriggerOAuthApis: return_value=MagicMock(authorization_url="url"), ), ): - resp = method(api, "github") + resp = method(api, "t1", mock_user(), "github") assert resp.status_code == 200 - def test_oauth_authorize_no_client(self, app: Flask): + def test_oauth_authorize_no_client(self, app: Flask) -> None: api = TriggerOAuthAuthorizeApi() method = unwrap(api.get) with ( app.test_request_context("/"), - patch("controllers.console.workspace.trigger_providers.current_user", mock_user()), patch( "controllers.console.workspace.trigger_providers.TriggerProviderService.get_oauth_client", return_value=None, ), ): with pytest.raises(NotFoundError): - method(api, "github") + method(api, "t1", mock_user(), "github") - def test_oauth_callback_forbidden(self, app: Flask): + def test_oauth_callback_forbidden(self, app: Flask) -> None: api = TriggerOAuthCallbackApi() method = unwrap(api.get) @@ -396,7 +372,7 @@ class TestTriggerOAuthApis: with pytest.raises(Forbidden): method(api, "github") - def test_oauth_callback_success(self, app: Flask): + def test_oauth_callback_success(self, app: Flask) -> None: api = TriggerOAuthCallbackApi() method = unwrap(api.get) @@ -426,7 +402,7 @@ class TestTriggerOAuthApis: resp = method(api, "github") assert resp.status_code == 302 - def test_oauth_callback_no_oauth_client(self, app: Flask): + def test_oauth_callback_no_oauth_client(self, app: Flask) -> None: api = TriggerOAuthCallbackApi() method = unwrap(api.get) @@ -450,7 +426,7 @@ class TestTriggerOAuthApis: with pytest.raises(Forbidden): method(api, "github") - def test_oauth_callback_empty_credentials(self, app: Flask): + def test_oauth_callback_empty_credentials(self, app: Flask) -> None: api = TriggerOAuthCallbackApi() method = unwrap(api.get) @@ -481,16 +457,15 @@ class TestTriggerOAuthApis: class TestTriggerOAuthClientManageApi: @pytest.fixture - def app(self, flask_app_with_containers: Flask): + def app(self, flask_app_with_containers: Flask) -> Flask: return flask_app_with_containers - def test_get_client(self, app: Flask): + def test_get_client(self, app: Flask) -> None: api = TriggerOAuthClientManageApi() method = unwrap(api.get) with ( app.test_request_context("/"), - patch("controllers.console.workspace.trigger_providers.current_user", mock_user()), patch( "controllers.console.workspace.trigger_providers.TriggerProviderService.get_custom_oauth_client_params", return_value={}, @@ -508,84 +483,79 @@ class TestTriggerOAuthClientManageApi: return_value=MagicMock(get_oauth_client_schema=lambda: {}), ), ): - result = method(api, "github") + result = method(api, "t1", "github") assert "configured" in result - def test_post_client(self, app: Flask): + def test_post_client(self, app: Flask) -> None: api = TriggerOAuthClientManageApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"enabled": True}), - patch("controllers.console.workspace.trigger_providers.current_user", mock_user()), patch( "controllers.console.workspace.trigger_providers.TriggerProviderService.save_custom_oauth_client_params", return_value={"ok": True}, ), ): - assert method(api, "github") == {"ok": True} + assert method(api, "t1", "github") == {"ok": True} - def test_delete_client(self, app: Flask): + def test_delete_client(self, app: Flask) -> None: api = TriggerOAuthClientManageApi() method = unwrap(api.delete) with ( app.test_request_context("/"), - patch("controllers.console.workspace.trigger_providers.current_user", mock_user()), patch( "controllers.console.workspace.trigger_providers.TriggerProviderService.delete_custom_oauth_client_params", return_value={"ok": True}, ), ): - assert method(api, "github") == {"ok": True} + assert method(api, "t1", "github") == {"ok": True} - def test_oauth_client_post_value_error(self, app: Flask): + def test_oauth_client_post_value_error(self, app: Flask) -> None: api = TriggerOAuthClientManageApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"enabled": True}), - patch("controllers.console.workspace.trigger_providers.current_user", mock_user()), patch( "controllers.console.workspace.trigger_providers.TriggerProviderService.save_custom_oauth_client_params", side_effect=ValueError("bad"), ), ): with pytest.raises(BadRequest): - method(api, "github") + method(api, "t1", "github") class TestTriggerSubscriptionVerifyApi: @pytest.fixture - def app(self, flask_app_with_containers: Flask): + def app(self, flask_app_with_containers: Flask) -> Flask: return flask_app_with_containers - def test_verify_success(self, app: Flask): + def test_verify_success(self, app: Flask) -> None: api = TriggerSubscriptionVerifyApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"credentials": {}}), - patch("controllers.console.workspace.trigger_providers.current_user", mock_user()), patch( "controllers.console.workspace.trigger_providers.TriggerProviderService.verify_subscription_credentials", return_value={"ok": True}, ), ): - assert method(api, "github", "s1") == {"ok": True} + assert method(api, "t1", mock_user(), "github", "s1") == {"ok": True} @pytest.mark.parametrize("raised_exception", [ValueError("bad"), Exception("boom")]) - def test_verify_errors(self, app: Flask, raised_exception): + def test_verify_errors(self, app: Flask, raised_exception: Exception) -> None: api = TriggerSubscriptionVerifyApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"credentials": {}}), - patch("controllers.console.workspace.trigger_providers.current_user", mock_user()), patch( "controllers.console.workspace.trigger_providers.TriggerProviderService.verify_subscription_credentials", side_effect=raised_exception, ), ): with pytest.raises(BadRequest): - method(api, "github", "s1") + method(api, "t1", mock_user(), "github", "s1") diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/__init__.py b/api/tests/test_containers_integration_tests/controllers/openapi/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/conftest.py b/api/tests/test_containers_integration_tests/controllers/openapi/conftest.py new file mode 100644 index 00000000000..d961479f55b --- /dev/null +++ b/api/tests/test_containers_integration_tests/controllers/openapi/conftest.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import uuid +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from typing import Literal +from unittest.mock import patch + +import pytest +from faker import Faker +from flask import Flask +from sqlalchemy.orm import Session + +from controllers.openapi.auth.data import AuthData +from libs.oauth_bearer import AuthContext, Scope, SubjectType, TokenType, reset_auth_ctx, set_auth_ctx +from models import Account, Tenant +from services.account_service import AccountService, TenantService +from tests.test_containers_integration_tests.helpers import generate_valid_password + + +@pytest.fixture +def app(flask_app_with_containers: Flask) -> Flask: + return flask_app_with_containers + + +@pytest.fixture +def make_account(db_session_with_containers: Session) -> Callable[..., Account]: + """Factory that registers a real Account and gives it an owner workspace. + + System feature gates are stubbed (registration / workspace creation + allowed) exactly like the AppDslService integration tests, so this stays a + pure account+tenant setup helper. + """ + + # Depend on db_session_with_containers so the app context / DB session is + # active for the real AccountService/TenantService calls below. + assert db_session_with_containers is not None + + def _make(*, with_owner_tenant: bool = True) -> Account: + fake = Faker() + with patch("services.account_service.FeatureService") as mock_feature_service: + mock_feature_service.get_system_features.return_value.is_allow_register = True + account = AccountService.create_account( + email=fake.email(), + name=fake.name(), + interface_language="en-US", + password=generate_valid_password(fake), + ) + if with_owner_tenant: + TenantService.create_owner_tenant_if_not_exist(account, name=fake.company()) + return account + + return _make + + +def add_tenant_for_account(account: Account, *, role: str = "normal", name: str = "Second WS") -> Tenant: + """Create an additional tenant and join ``account`` to it (real service calls).""" + with patch("services.account_service.FeatureService") as mock_feature_service: + mock_feature_service.get_system_features.return_value.is_allow_create_workspace = True + tenant = TenantService.create_tenant(name=name) + TenantService.create_tenant_member(tenant, account, role=role) + return tenant + + +def auth_for( + account: Account, + *, + app_model: object | None = None, + token_id: uuid.UUID | None = None, + caller_kind: Literal["account", "end_user"] | None = None, +) -> AuthData: + """Build an AuthData for ``account`` (and optionally an app context). + + ``token_id`` is needed by the self-revoke endpoint, and ``caller_kind`` by + any handler calling ``require_app_context`` (e.g. file upload / task stop). + """ + return AuthData( + token_type=TokenType.OAUTH_ACCOUNT, + account_id=uuid.UUID(str(account.id)), + token_hash="integration-test", + token_id=token_id, + scopes=frozenset({Scope.FULL}), + caller=account, + caller_kind=caller_kind, + app=app_model, # type: ignore[arg-type] + ) + + +@contextmanager +def account_auth_context( + account: Account, + *, + token_id: uuid.UUID, + client_id: str = "integration-cli", +) -> Iterator[AuthContext]: + """Publish an account ``AuthContext`` for handlers that read ``get_auth_ctx()``. + + The auth pipeline normally sets this ContextVar; the integration suite + bypasses the pipeline via ``inspect.unwrap``, so endpoints that resolve the + caller through ``get_auth_ctx()`` (the ``/account/sessions*`` family) need it + set explicitly. Resets on exit so the worker thread can't leak identity. + """ + ctx = AuthContext( + subject_type=SubjectType.ACCOUNT, + subject_email=account.email, + subject_issuer=None, + account_id=uuid.UUID(str(account.id)), + client_id=client_id, + scopes=frozenset({Scope.FULL}), + token_id=token_id, + token_type=TokenType.OAUTH_ACCOUNT, + expires_at=None, + token_hash="integration-test", + ) + reset_token = set_auth_ctx(ctx) + try: + yield ctx + finally: + reset_auth_ctx(reset_token) diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/test_account.py b/api/tests/test_containers_integration_tests/controllers/openapi/test_account.py new file mode 100644 index 00000000000..77c812c0b34 --- /dev/null +++ b/api/tests/test_containers_integration_tests/controllers/openapi/test_account.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from collections.abc import Callable +from inspect import unwrap + +from flask import Flask + +from controllers.openapi.account import AccountApi +from models import Account +from models.account import TenantAccountRole +from tests.test_containers_integration_tests.controllers.openapi.conftest import add_tenant_for_account, auth_for + + +class TestAccountInfo: + def test_returns_account_and_owner_workspace(self, app: Flask, make_account: Callable[..., Account]) -> None: + account = make_account() + owner_tenant = account.current_tenant + assert owner_tenant is not None + + api = AccountApi() + with app.test_request_context("/openapi/v1/account"): + result = unwrap(api.get)(api, auth_data=auth_for(account)) + + assert result.subject_type == "account" + assert result.subject_email == account.email + assert result.account is not None + assert result.account.id == account.id + assert result.account.email == account.email + + workspaces = {w.id: w for w in result.workspaces} + assert set(workspaces) == {owner_tenant.id} + assert workspaces[owner_tenant.id].role == TenantAccountRole.OWNER.value + # No membership is flagged `current` yet, so the default falls back to + # the only workspace the account belongs to. + assert result.default_workspace_id == owner_tenant.id + + def test_lists_all_joined_workspaces(self, app: Flask, make_account: Callable[..., Account]) -> None: + account = make_account() + owner_tenant = account.current_tenant + assert owner_tenant is not None + second = add_tenant_for_account(account, role="normal", name="Second WS") + + api = AccountApi() + with app.test_request_context("/openapi/v1/account"): + result = unwrap(api.get)(api, auth_data=auth_for(account)) + + assert {w.id for w in result.workspaces} == {owner_tenant.id, second.id} + roles = {w.id: w.role for w in result.workspaces} + assert roles[owner_tenant.id] == TenantAccountRole.OWNER.value + assert roles[second.id] == "normal" diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/test_account_sessions.py b/api/tests/test_containers_integration_tests/controllers/openapi/test_account_sessions.py new file mode 100644 index 00000000000..4cdbec3e30e --- /dev/null +++ b/api/tests/test_containers_integration_tests/controllers/openapi/test_account_sessions.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +from collections.abc import Callable +from inspect import unwrap +from uuid import uuid4 + +import pytest +from flask import Flask +from sqlalchemy.orm import Session +from werkzeug.exceptions import NotFound + +from controllers.openapi._models import SessionListQuery +from controllers.openapi.account import ( + AccountSessionByIdApi, + AccountSessionsApi, + AccountSessionsSelfApi, +) +from extensions.ext_redis import redis_client +from models import Account +from services.oauth_device_flow import PREFIX_OAUTH_ACCOUNT, MintResult, mint_oauth_token +from tests.test_containers_integration_tests.controllers.openapi.conftest import account_auth_context, auth_for + + +def _mint_account_token( + db_session: Session, + account: Account, + *, + client_id: str = "integration-cli", + device_label: str = "Test Device", +) -> MintResult: + """Mint a real, persisted ``dfoa_`` access token for ``account``.""" + return mint_oauth_token( + db_session, + redis_client, + subject_email=account.email, + subject_issuer=None, + account_id=str(account.id), + client_id=client_id, + device_label=device_label, + prefix=PREFIX_OAUTH_ACCOUNT, + ttl_days=14, + ) + + +class TestSessionList: + def test_lists_active_session( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + account = make_account() + mint = _mint_account_token(db_session_with_containers, account, device_label="Laptop") + + api = AccountSessionsApi() + with app.test_request_context("/openapi/v1/account/sessions"): + with account_auth_context(account, token_id=mint.token_id): + result = unwrap(api.get)( + api, auth_data=auth_for(account, token_id=mint.token_id), query=SessionListQuery() + ) + + assert result.total == 1 + row = result.data[0] + assert row.id == str(mint.token_id) + assert row.prefix == PREFIX_OAUTH_ACCOUNT + assert row.device_label == "Laptop" + + def test_excludes_other_accounts_sessions( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + """Sessions are subject-scoped: another account's token must not appear.""" + account = make_account() + other = make_account() + mine = _mint_account_token(db_session_with_containers, account) + _mint_account_token(db_session_with_containers, other) + + api = AccountSessionsApi() + with app.test_request_context("/openapi/v1/account/sessions"): + with account_auth_context(account, token_id=mine.token_id): + result = unwrap(api.get)( + api, auth_data=auth_for(account, token_id=mine.token_id), query=SessionListQuery() + ) + + assert {row.id for row in result.data} == {str(mine.token_id)} + + +class TestSessionRevoke: + def test_revoke_self_removes_from_active_list( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + account = make_account() + mint = _mint_account_token(db_session_with_containers, account) + + revoke_api = AccountSessionsSelfApi() + with app.test_request_context("/openapi/v1/account/sessions/self", method="DELETE"): + with account_auth_context(account, token_id=mint.token_id): + result = unwrap(revoke_api.delete)(revoke_api, auth_data=auth_for(account, token_id=mint.token_id)) + + assert result.status == "revoked" + + # Revocation persisted: the real list path no longer returns it. + list_api = AccountSessionsApi() + with app.test_request_context("/openapi/v1/account/sessions"): + with account_auth_context(account, token_id=mint.token_id): + listing = unwrap(list_api.get)( + list_api, auth_data=auth_for(account, token_id=mint.token_id), query=SessionListQuery() + ) + assert listing.total == 0 + + def test_revoke_by_id_for_own_session( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + account = make_account() + mint = _mint_account_token(db_session_with_containers, account) + session_id = str(mint.token_id) + + api = AccountSessionByIdApi() + with app.test_request_context(f"/openapi/v1/account/sessions/{session_id}", method="DELETE"): + with account_auth_context(account, token_id=mint.token_id): + result = unwrap(api.delete)( + api, session_id=session_id, auth_data=auth_for(account, token_id=mint.token_id) + ) + + assert result.status == "revoked" + + def test_revoke_foreign_session_is_404( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + """A token id owned by another subject must be indistinguishable from a + missing one (404), so token ids can't be probed across subjects.""" + owner = make_account() + outsider = make_account() + foreign = _mint_account_token(db_session_with_containers, owner) + + api = AccountSessionByIdApi() + session_id = str(foreign.token_id) + with app.test_request_context(f"/openapi/v1/account/sessions/{session_id}", method="DELETE"): + with account_auth_context(outsider, token_id=uuid4()): + with pytest.raises(NotFound): + unwrap(api.delete)(api, session_id=session_id, auth_data=auth_for(outsider, token_id=uuid4())) diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/test_app_dsl.py b/api/tests/test_containers_integration_tests/controllers/openapi/test_app_dsl.py new file mode 100644 index 00000000000..12018c3c67c --- /dev/null +++ b/api/tests/test_containers_integration_tests/controllers/openapi/test_app_dsl.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +import json +from collections.abc import Callable, Generator +from inspect import unwrap +from unittest.mock import patch +from uuid import uuid4 + +import pytest +import yaml +from faker import Faker +from flask import Flask +from sqlalchemy.orm import Session + +from controllers.openapi._models import AppDslExportQuery, AppDslImportPayload +from controllers.openapi.app_dsl import ( + AppDslCheckDependenciesApi, + AppDslExportApi, + AppDslImportApi, + AppDslImportConfirmApi, +) +from models import Account, App +from models.model import AppModelConfig +from services.account_service import AccountService, TenantService +from services.app_dsl_service import CURRENT_DSL_VERSION +from services.app_service import AppService, CreateAppParams +from services.entities.dsl_entities import ImportStatus +from tests.test_containers_integration_tests.controllers.openapi.conftest import auth_for +from tests.test_containers_integration_tests.helpers import generate_valid_password + + +def _workflow_yaml(*, version: str = CURRENT_DSL_VERSION, name: str = "My App") -> str: + return yaml.safe_dump( + { + "version": version, + "kind": "app", + "app": {"name": name, "mode": "workflow"}, + "workflow": {"graph": {"nodes": []}, "features": {}}, + }, + allow_unicode=True, + ) + + +@pytest.fixture +def external_deps() -> Generator[dict[str, object], None, None]: + """Stub the heavy collaborators an import/export touches (model runtime, + workflow sync, dependency analysis, enterprise hooks) while leaving the DSL + service and DB writes real.""" + with ( + patch("services.app_dsl_service.WorkflowService") as mock_workflow_service, + patch("services.app_dsl_service.DependenciesAnalysisService") as mock_dependencies_service, + patch("services.app_dsl_service.app_was_created") as mock_app_was_created, + patch("services.app_service.ModelManager.for_tenant") as mock_model_manager, + patch("services.app_service.FeatureService") as mock_feature_service, + patch("services.app_service.EnterpriseService") as mock_enterprise_service, + ): + mock_workflow_service.return_value.get_draft_workflow.return_value = None + mock_workflow_service.return_value.sync_draft_workflow.return_value = None + mock_dependencies_service.generate_latest_dependencies.return_value = [] # type: ignore[assignment] + mock_dependencies_service.get_leaked_dependencies.return_value = [] # type: ignore[assignment] + mock_dependencies_service.generate_dependencies.return_value = [] # type: ignore[assignment] + mock_app_was_created.send.return_value = None + + mock_model_instance = mock_model_manager.return_value + mock_model_instance.get_default_model_instance.return_value = None + mock_model_instance.get_default_provider_model_name.return_value = ("openai", "gpt-3.5-turbo") + + mock_feature_service.get_system_features.return_value.webapp_auth.enabled = False + mock_enterprise_service.WebAppAuth.update_app_access_mode.return_value = None + mock_enterprise_service.WebAppAuth.cleanup_webapp.return_value = None + + yield {"workflow_service": mock_workflow_service} + + +def _app_and_account(db_session: Session, *, mode: str = "chat") -> tuple[App, Account]: + fake = Faker() + with patch("services.account_service.FeatureService") as mock_account_feature_service: + mock_account_feature_service.get_system_features.return_value.is_allow_register = True + account = AccountService.create_account( + email=fake.email(), + name=fake.name(), + interface_language="en-US", + password=generate_valid_password(fake), + ) + TenantService.create_owner_tenant_if_not_exist(account, name=fake.company()) + tenant = account.current_tenant + assert tenant is not None + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode=mode, # type: ignore[arg-type] + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ) + app_model = AppService().create_app(tenant.id, app_args, account) + return app_model, account + + +class TestDslImport: + def test_invalid_dsl_maps_to_400_and_persists_nothing( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + account = make_account() + tenant = account.current_tenant + assert tenant is not None + + api = AppDslImportApi() + body = AppDslImportPayload(mode="yaml-content", yaml_content="[]") # not a mapping + with app.test_request_context(f"/openapi/v1/workspaces/{tenant.id}/apps/imports", method="POST"): + result, code = unwrap(api.post)(api, workspace_id=tenant.id, auth_data=auth_for(account), body=body) + + assert code == 400 + assert result.status == ImportStatus.FAILED + # A failed import must not leave an app behind. + assert db_session_with_containers.query(App).filter(App.tenant_id == tenant.id).count() == 0 + + def test_major_version_mismatch_maps_to_202_pending( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + account = make_account() + tenant = account.current_tenant + assert tenant is not None + + api = AppDslImportApi() + body = AppDslImportPayload(mode="yaml-content", yaml_content=_workflow_yaml(version="99.0.0")) + with app.test_request_context(f"/openapi/v1/workspaces/{tenant.id}/apps/imports", method="POST"): + result, code = unwrap(api.post)(api, workspace_id=tenant.id, auth_data=auth_for(account), body=body) + + assert code == 202 + assert result.status == ImportStatus.PENDING + assert result.id # a pending import id the caller can confirm with + + def test_valid_dsl_maps_to_200_completed( + self, + app: Flask, + db_session_with_containers: Session, + make_account: Callable[..., Account], + external_deps: dict[str, object], + ) -> None: + account = make_account() + tenant = account.current_tenant + assert tenant is not None + + api = AppDslImportApi() + body = AppDslImportPayload(mode="yaml-content", yaml_content=_workflow_yaml(name="Imported")) + with app.test_request_context(f"/openapi/v1/workspaces/{tenant.id}/apps/imports", method="POST"): + result, code = unwrap(api.post)(api, workspace_id=tenant.id, auth_data=auth_for(account), body=body) + + assert code == 200 + assert result.status in (ImportStatus.COMPLETED, ImportStatus.COMPLETED_WITH_WARNINGS) + assert result.app_id is not None + + +class TestDslImportConfirm: + def test_unknown_pending_import_maps_to_400( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + """An expired/unknown import id has no Redis pending data → FAILED → 400.""" + account = make_account() + tenant = account.current_tenant + assert tenant is not None + import_id = str(uuid4()) + + api = AppDslImportConfirmApi() + with app.test_request_context( + f"/openapi/v1/workspaces/{tenant.id}/apps/imports/{import_id}/confirm", method="POST" + ): + result, code = unwrap(api.post)( + api, workspace_id=tenant.id, import_id=import_id, auth_data=auth_for(account) + ) + + assert code == 400 + assert result.status == ImportStatus.FAILED + + +class TestDslExport: + def test_export_returns_dsl_yaml( + self, app: Flask, db_session_with_containers: Session, external_deps: dict[str, object] + ) -> None: + app_model, account = _app_and_account(db_session_with_containers, mode="chat") + model_config = AppModelConfig( + app_id=app_model.id, + provider="openai", + model_id="gpt-3.5-turbo", + model=json.dumps({"provider": "openai", "name": "gpt-3.5-turbo", "mode": "chat", "completion_params": {}}), + pre_prompt="You are a helpful assistant.", + prompt_type="simple", # type: ignore[arg-type] + created_by=account.id, + updated_by=account.id, + ) + model_config.id = str(uuid4()) + app_model.app_model_config_id = model_config.id + db_session_with_containers.add(model_config) + db_session_with_containers.commit() + + api = AppDslExportApi() + with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/export"): + response, code = unwrap(api.get)( + api, app_id=app_model.id, auth_data=auth_for(account, app_model=app_model), query=AppDslExportQuery() + ) + + assert code == 200 + parsed = yaml.safe_load(response.data) + assert parsed["kind"] == "app" + assert parsed["app"]["name"] == app_model.name + + def test_export_workflow_app_without_draft_maps_to_404( + self, app: Flask, db_session_with_containers: Session, external_deps: dict[str, object] + ) -> None: + """A workflow app with no draft workflow can't be exported → the service + raises WorkflowNotFoundError, which the controller maps to 404.""" + app_model, account = _app_and_account(db_session_with_containers, mode="workflow") + + api = AppDslExportApi() + with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/export"): + result, code = unwrap(api.get)( + api, app_id=app_model.id, auth_data=auth_for(account, app_model=app_model), query=AppDslExportQuery() + ) + + assert code == 404 + assert isinstance(result, str) + + +class TestDslCheckDependencies: + def test_check_dependencies_returns_result( + self, app: Flask, db_session_with_containers: Session, external_deps: dict[str, object] + ) -> None: + app_model, account = _app_and_account(db_session_with_containers, mode="chat") + + api = AppDslCheckDependenciesApi() + with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/check-dependencies"): + result, code = unwrap(api.get)(api, app_id=app_model.id, auth_data=auth_for(account, app_model=app_model)) + + assert code == 200 + assert result.leaked_dependencies == [] diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/test_app_run.py b/api/tests/test_containers_integration_tests/controllers/openapi/test_app_run.py new file mode 100644 index 00000000000..c6fde623677 --- /dev/null +++ b/api/tests/test_containers_integration_tests/controllers/openapi/test_app_run.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from collections.abc import Callable +from inspect import unwrap +from uuid import uuid4 + +from flask import Flask +from sqlalchemy.orm import Session + +from controllers.openapi.app_run import AppRunTaskStopApi +from models import Account, App +from services.app_service import AppService, CreateAppParams +from tests.test_containers_integration_tests.controllers.openapi.conftest import auth_for + + +def _create_app(db_session: Session, account: Account, *, name: str = "Runner") -> App: + tenant = account.current_tenant + assert tenant is not None + params = CreateAppParams( + name=name, + description="", + mode="workflow", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + ) + app_model = AppService().create_app(tenant.id, params, account) + db_session.commit() + return app_model + + +class TestAppRunTaskStop: + def test_task_stop_returns_success( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + account = make_account() + app_model = _create_app(db_session_with_containers, account) + task_id = str(uuid4()) + + api = AppRunTaskStopApi() + with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/tasks/{task_id}/stop", method="POST"): + result = unwrap(api.post)( + api, + app_id=app_model.id, + task_id=task_id, + auth_data=auth_for(account, app_model=app_model, caller_kind="account"), + ) + + assert result.result == "success" diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/test_apps.py b/api/tests/test_containers_integration_tests/controllers/openapi/test_apps.py new file mode 100644 index 00000000000..22f812e125b --- /dev/null +++ b/api/tests/test_containers_integration_tests/controllers/openapi/test_apps.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +from collections.abc import Callable +from inspect import unwrap +from uuid import uuid4 + +import pytest +from flask import Flask +from sqlalchemy.orm import Session +from werkzeug.exceptions import NotFound + +from controllers.openapi._models import AppDescribeQuery, AppListQuery +from controllers.openapi.apps import AppDescribeApi, AppListApi +from models import Account, App +from services.app_service import AppService, CreateAppParams +from tests.test_containers_integration_tests.controllers.openapi.conftest import auth_for + + +def _create_app( + db_session: Session, + account: Account, + *, + name: str, + enable_api: bool = True, +) -> App: + """Create a workflow app in the account's owner tenant. + + Workflow mode is used because its template seeds no ``model_config``, so + ``AppService.create_app`` never reaches ``ModelManager`` — keeping the + fixture free of model-runtime patching. + """ + tenant = account.current_tenant + assert tenant is not None + params = CreateAppParams( + name=name, + description="", + mode="workflow", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + ) + app_model = AppService().create_app(tenant.id, params, account) + # The openapi surface gate keys off ``enable_api``; flip it explicitly so + # the test states the visibility precondition rather than relying on the + # template default. + app_model.enable_api = enable_api + db_session.add(app_model) + db_session.commit() + return app_model + + +class TestAppList: + def test_lists_only_api_enabled_apps_in_workspace( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + account = make_account() + tenant = account.current_tenant + assert tenant is not None + visible = _create_app(db_session_with_containers, account, name="Visible", enable_api=True) + _create_app(db_session_with_containers, account, name="Hidden", enable_api=False) + + api = AppListApi() + with app.test_request_context(f"/openapi/v1/apps?workspace_id={tenant.id}"): + result = unwrap(api.get)(api, auth_data=auth_for(account), query=AppListQuery(workspace_id=str(tenant.id))) + + # The api-disabled app is gated out, so it counts neither in `data` + # nor in `total` (the gate is pushed into the query for stable paging). + assert result.total == 1 + assert [row.id for row in result.data] == [visible.id] + assert result.has_more is False + + def test_uuid_name_filter_returns_matching_app( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + account = make_account() + tenant = account.current_tenant + assert tenant is not None + target = _create_app(db_session_with_containers, account, name="Target", enable_api=True) + + api = AppListApi() + with app.test_request_context(f"/openapi/v1/apps?workspace_id={tenant.id}&name={target.id}"): + result = unwrap(api.get)( + api, + auth_data=auth_for(account), + query=AppListQuery(workspace_id=str(tenant.id), name=str(target.id)), + ) + + assert result.total == 1 + assert result.data[0].id == target.id + assert result.data[0].workspace_id == str(tenant.id) + + def test_uuid_name_filter_for_foreign_app_returns_empty( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + """A UUID that resolves to an app in another workspace must not leak + across tenants — the list returns empty rather than the foreign app.""" + owner = make_account() + outsider = make_account() + foreign_app = _create_app(db_session_with_containers, owner, name="Foreign", enable_api=True) + outsider_tenant = outsider.current_tenant + assert outsider_tenant is not None + + api = AppListApi() + with app.test_request_context(f"/openapi/v1/apps?workspace_id={outsider_tenant.id}&name={foreign_app.id}"): + result = unwrap(api.get)( + api, + auth_data=auth_for(outsider), + query=AppListQuery(workspace_id=str(outsider_tenant.id), name=str(foreign_app.id)), + ) + + assert result.total == 0 + assert result.data == [] + + +class TestAppDescribe: + def test_describe_info_returns_metadata( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + account = make_account() + app_model = _create_app(db_session_with_containers, account, name="Describe Me", enable_api=True) + + api = AppDescribeApi() + with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/describe?fields=info"): + result = unwrap(api.get)( + api, app_id=app_model.id, auth_data=auth_for(account), query=AppDescribeQuery(fields="info") + ) + + assert result.info is not None + assert result.info.id == app_model.id + assert result.info.name == "Describe Me" + assert result.info.service_api_enabled is True + # Only the requested block is materialized. + assert result.parameters is None + assert result.input_schema is None + + def test_describe_unknown_app_is_404(self, app: Flask, make_account: Callable[..., Account]) -> None: + account = make_account() + missing_id = str(uuid4()) + + api = AppDescribeApi() + with app.test_request_context(f"/openapi/v1/apps/{missing_id}/describe"): + with pytest.raises(NotFound): + unwrap(api.get)(api, app_id=missing_id, auth_data=auth_for(account), query=AppDescribeQuery()) + + def test_describe_api_disabled_app_is_404( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + """An api-disabled app fails the openapi visibility gate, so describe + must behave as if it doesn't exist (404), not expose it.""" + account = make_account() + hidden = _create_app(db_session_with_containers, account, name="Hidden", enable_api=False) + + api = AppDescribeApi() + with app.test_request_context(f"/openapi/v1/apps/{hidden.id}/describe"): + with pytest.raises(NotFound): + unwrap(api.get)(api, app_id=hidden.id, auth_data=auth_for(account), query=AppDescribeQuery()) diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/test_files.py b/api/tests/test_containers_integration_tests/controllers/openapi/test_files.py new file mode 100644 index 00000000000..b90d5ab907c --- /dev/null +++ b/api/tests/test_containers_integration_tests/controllers/openapi/test_files.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from collections.abc import Callable +from inspect import unwrap +from io import BytesIO + +from flask import Flask +from sqlalchemy.orm import Session + +from controllers.openapi.files import AppFileUploadApi +from models import Account, App +from services.app_service import AppService, CreateAppParams +from tests.test_containers_integration_tests.controllers.openapi.conftest import auth_for + + +def _create_app(db_session: Session, account: Account, *, name: str = "Uploader") -> App: + """Create a workflow app (no model_config → no ModelManager) for the upload context.""" + tenant = account.current_tenant + assert tenant is not None + params = CreateAppParams( + name=name, + description="", + mode="workflow", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + ) + app_model = AppService().create_app(tenant.id, params, account) + db_session.commit() + return app_model + + +class TestAppFileUpload: + def test_upload_persists_and_returns_metadata( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + account = make_account() + tenant = account.current_tenant + assert tenant is not None + app_model = _create_app(db_session_with_containers, account) + content = b"hello integration world" + + api = AppFileUploadApi() + data = {"file": (BytesIO(content), "note.txt", "text/plain")} + with app.test_request_context( + f"/openapi/v1/apps/{app_model.id}/files/upload", + method="POST", + data=data, + content_type="multipart/form-data", + ): + result = unwrap(api.post)( + api, + app_id=app_model.id, + auth_data=auth_for(account, app_model=app_model, caller_kind="account"), + ) + + assert result.id + assert result.name == "note.txt" + assert result.size == len(content) + assert result.extension == "txt" + assert result.mime_type == "text/plain" + # Persisted under the caller's tenant. + assert result.tenant_id == str(tenant.id) diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/test_workspaces.py b/api/tests/test_containers_integration_tests/controllers/openapi/test_workspaces.py new file mode 100644 index 00000000000..aed8c415454 --- /dev/null +++ b/api/tests/test_containers_integration_tests/controllers/openapi/test_workspaces.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from collections.abc import Callable +from inspect import unwrap + +import pytest +from flask import Flask +from sqlalchemy.orm import Session +from werkzeug.exceptions import NotFound + +from controllers.openapi.workspaces import WorkspaceByIdApi, WorkspacesApi, WorkspaceSwitchApi +from models import Account +from models.account import TenantAccountRole +from tests.test_containers_integration_tests.controllers.openapi.conftest import add_tenant_for_account, auth_for + + +class TestWorkspacesList: + def test_lists_only_members_workspaces_with_role( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + account = make_account() + owner_tenant = account.current_tenant + assert owner_tenant is not None + + api = WorkspacesApi() + with app.test_request_context("/openapi/v1/workspaces"): + result = unwrap(api.get)(api, auth_data=auth_for(account)) + + ids = {w.id for w in result.workspaces} + assert ids == {owner_tenant.id} + only = result.workspaces[0] + assert only.role == TenantAccountRole.OWNER.value + assert only.status == "normal" + # Newly-created owner membership is not yet "current"; switching flips it + # (see TestWorkspaceSwitch). + assert only.current is False + + def test_lists_all_joined_workspaces( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + account = make_account() + owner_tenant = account.current_tenant + assert owner_tenant is not None + second = add_tenant_for_account(account, role="normal", name="Second WS") + + api = WorkspacesApi() + with app.test_request_context("/openapi/v1/workspaces"): + result = unwrap(api.get)(api, auth_data=auth_for(account)) + + assert {w.id for w in result.workspaces} == {owner_tenant.id, second.id} + + +class TestWorkspaceDetail: + def test_member_can_read_detail( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + account = make_account() + tenant = account.current_tenant + assert tenant is not None + + api = WorkspaceByIdApi() + with app.test_request_context(f"/openapi/v1/workspaces/{tenant.id}"): + detail = unwrap(api.get)(api, workspace_id=tenant.id, auth_data=auth_for(account)) + + assert detail.id == tenant.id + assert detail.role == TenantAccountRole.OWNER.value + assert detail.current is False + assert detail.created_at is not None + + def test_non_member_detail_is_404_not_403( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + """A workspace the caller doesn't belong to must be indistinguishable + from a missing one (404), so IDs can't be probed across tenants.""" + owner = make_account() + outsider = make_account() + someone_elses_ws = owner.current_tenant + assert someone_elses_ws is not None + + api = WorkspaceByIdApi() + with app.test_request_context(f"/openapi/v1/workspaces/{someone_elses_ws.id}"): + with pytest.raises(NotFound): + unwrap(api.get)(api, workspace_id=someone_elses_ws.id, auth_data=auth_for(outsider)) + + +class TestWorkspaceSwitch: + def test_switch_sets_current_and_persists( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + account = make_account() + owner_tenant = account.current_tenant + assert owner_tenant is not None + target = add_tenant_for_account(account, role="normal", name="Switch Target") + + api = WorkspaceSwitchApi() + with app.test_request_context(f"/openapi/v1/workspaces/{target.id}/switch", method="POST"): + detail = unwrap(api.post)(api, workspace_id=target.id, auth_data=auth_for(account)) + + # Response reflects the post-switch state. + assert detail.id == target.id + assert detail.current is True + + # And the switch persisted: the previously-current owner workspace is no + # longer current (verified through the real read path). + with app.test_request_context("/openapi/v1/workspaces"): + listing = unwrap(WorkspacesApi().get)(WorkspacesApi(), auth_data=auth_for(account)) + by_id = {w.id: w for w in listing.workspaces} + assert by_id[target.id].current is True + assert by_id[owner_tenant.id].current is False + + def test_switch_to_non_member_workspace_is_404( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + account = make_account() + outsider_ws = make_account().current_tenant + assert outsider_ws is not None + + api = WorkspaceSwitchApi() + with app.test_request_context(f"/openapi/v1/workspaces/{outsider_ws.id}/switch", method="POST"): + with pytest.raises(NotFound): + unwrap(api.post)(api, workspace_id=outsider_ws.id, auth_data=auth_for(account)) diff --git a/api/tests/test_containers_integration_tests/controllers/service_api/dataset/test_dataset.py b/api/tests/test_containers_integration_tests/controllers/service_api/dataset/test_dataset.py index 91b0055e069..642dd3ab62d 100644 --- a/api/tests/test_containers_integration_tests/controllers/service_api/dataset/test_dataset.py +++ b/api/tests/test_containers_integration_tests/controllers/service_api/dataset/test_dataset.py @@ -23,6 +23,12 @@ from flask import Flask from sqlalchemy.orm import Session from werkzeug.exceptions import Forbidden, NotFound + +class SessionMatcher: + def __eq__(self, other): + return isinstance(other, Session) + + import services from controllers.service_api.dataset.dataset import ( DatasetCreatePayload, @@ -998,7 +1004,7 @@ class TestDatasetTagsApiGet: assert status == 200 assert response == [{"id": "tag-1", "name": "Test Tag", "type": "knowledge", "binding_count": "0"}] - mock_tag_svc.get_tags.assert_called_once_with("knowledge", "tenant-1") + mock_tag_svc.get_tags.assert_called_once_with(SessionMatcher(), "knowledge", "tenant-1") @patch("controllers.service_api.dataset.dataset.current_user") def test_list_tags_from_db( diff --git a/api/tests/test_containers_integration_tests/pyrefly.toml b/api/tests/test_containers_integration_tests/pyrefly.toml index 36d83da43ee..06ea10036f5 100644 --- a/api/tests/test_containers_integration_tests/pyrefly.toml +++ b/api/tests/test_containers_integration_tests/pyrefly.toml @@ -114,7 +114,6 @@ project-excludes = [ "services/test_model_provider_service.py", "services/test_oauth_server_service.py", "services/test_ops_service.py", - "services/test_recommended_app_service.py", "services/test_restore_archived_workflow_run.py", "services/test_saved_message_service.py", "services/test_schedule_service.py", diff --git a/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_workflow_run_cleanup_repository.py b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_workflow_run_cleanup_repository.py new file mode 100644 index 00000000000..be659fac184 --- /dev/null +++ b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_workflow_run_cleanup_repository.py @@ -0,0 +1,320 @@ +"""Integration tests for workflow run cleanup repository queries.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import override +from uuid import uuid4 + +from sqlalchemy import Engine, select +from sqlalchemy.orm import Session, sessionmaker + +from graphon.entities import WorkflowExecution +from graphon.entities.pause_reason import PauseReasonType +from graphon.enums import WorkflowExecutionStatus, WorkflowType +from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom +from models.workflow import WorkflowAppLog, WorkflowAppLogCreatedFrom, WorkflowPause, WorkflowPauseReason, WorkflowRun +from repositories.sqlalchemy_api_workflow_run_repository import DifyAPISQLAlchemyWorkflowRunRepository + + +class _TestWorkflowRunRepository(DifyAPISQLAlchemyWorkflowRunRepository): + """Concrete repository for tests where save() is not under test.""" + + @override + def save(self, execution: WorkflowExecution) -> None: + return None + + +@dataclass +class _TestScope: + """Per-test identifiers for rows created by cleanup repository tests.""" + + tenant_id: str = field(default_factory=lambda: str(uuid4())) + app_id: str = field(default_factory=lambda: str(uuid4())) + workflow_id: str = field(default_factory=lambda: str(uuid4())) + user_id: str = field(default_factory=lambda: str(uuid4())) + + +def _repository(db_session_with_containers: Session) -> DifyAPISQLAlchemyWorkflowRunRepository: + engine = db_session_with_containers.get_bind() + assert isinstance(engine, Engine) + return _TestWorkflowRunRepository(session_maker=sessionmaker(bind=engine, expire_on_commit=False)) + + +def _create_workflow_run( + session: Session, + scope: _TestScope, + *, + status: WorkflowExecutionStatus = WorkflowExecutionStatus.SUCCEEDED, + created_at: datetime, + tenant_id: str | None = None, + workflow_id: str | None = None, + workflow_type: str = WorkflowType.WORKFLOW, +) -> WorkflowRun: + workflow_run = WorkflowRun( + id=str(uuid4()), + tenant_id=tenant_id or scope.tenant_id, + app_id=scope.app_id, + workflow_id=workflow_id or scope.workflow_id, + type=workflow_type, + triggered_from=WorkflowRunTriggeredFrom.DEBUGGING, + version="draft", + graph="{}", + inputs="{}", + status=status, + created_by_role=CreatorUserRole.ACCOUNT, + created_by=scope.user_id, + created_at=created_at, + ) + session.add(workflow_run) + session.commit() + return workflow_run + + +def _add_app_log(session: Session, scope: _TestScope, workflow_run: WorkflowRun) -> None: + session.add( + WorkflowAppLog( + tenant_id=workflow_run.tenant_id, + app_id=scope.app_id, + workflow_id=workflow_run.workflow_id, + workflow_run_id=workflow_run.id, + created_from=WorkflowAppLogCreatedFrom.SERVICE_API, + created_by_role=CreatorUserRole.ACCOUNT, + created_by=scope.user_id, + ) + ) + session.commit() + + +def _add_pause_with_reason(session: Session, workflow_run: WorkflowRun) -> WorkflowPause: + pause = WorkflowPause( + workflow_id=workflow_run.workflow_id, + workflow_run_id=workflow_run.id, + state_object_key=f"workflow-state-{uuid4()}.json", + ) + pause_reason = WorkflowPauseReason( + pause_id=pause.id, + type_=PauseReasonType.SCHEDULED_PAUSE, + message="scheduled pause", + ) + session.add_all([pause, pause_reason]) + session.commit() + return pause + + +class TestGetCleanupRefsBatchByTimeRange: + def test_applies_cursor_window_and_cleanup_filters(self, db_session_with_containers: Session) -> None: + repository = _repository(db_session_with_containers) + scope = _TestScope() + base = datetime(2024, 1, 1, 12, 0, 0) + + _create_workflow_run(db_session_with_containers, scope, created_at=base - timedelta(minutes=1)) + cursor_run = _create_workflow_run(db_session_with_containers, scope, created_at=base) + first_target = _create_workflow_run(db_session_with_containers, scope, created_at=base + timedelta(minutes=1)) + second_target = _create_workflow_run( + db_session_with_containers, + scope, + status=WorkflowExecutionStatus.FAILED, + created_at=base + timedelta(minutes=2), + ) + _create_workflow_run( + db_session_with_containers, + scope, + status=WorkflowExecutionStatus.RUNNING, + created_at=base + timedelta(minutes=1), + ) + _create_workflow_run( + db_session_with_containers, + scope, + created_at=base + timedelta(minutes=1), + tenant_id=str(uuid4()), + ) + _create_workflow_run( + db_session_with_containers, + scope, + created_at=base + timedelta(minutes=1), + workflow_id=str(uuid4()), + ) + _create_workflow_run( + db_session_with_containers, + scope, + created_at=base + timedelta(minutes=1), + workflow_type=WorkflowType.CHAT, + ) + _create_workflow_run(db_session_with_containers, scope, created_at=base + timedelta(minutes=3)) + + refs = repository.get_cleanup_refs_batch_by_time_range( + start_from=base, + end_before=base + timedelta(minutes=4), + last_seen=(cursor_run.created_at, cursor_run.id), + batch_size=10, + run_types=[WorkflowType.WORKFLOW], + tenant_ids=[scope.tenant_id], + workflow_ids=[scope.workflow_id], + upper_bound=(second_target.created_at, second_target.id), + ) + + assert [(ref.id, ref.tenant_id, ref.created_at) for ref in refs] == [ + (first_target.id, scope.tenant_id, first_target.created_at), + (second_target.id, scope.tenant_id, second_target.created_at), + ] + + def test_returns_empty_when_run_type_filter_is_empty(self, db_session_with_containers: Session) -> None: + repository = _repository(db_session_with_containers) + + refs = repository.get_cleanup_refs_batch_by_time_range( + start_from=None, + end_before=datetime(2024, 1, 2), + last_seen=None, + batch_size=10, + run_types=[], + ) + + assert refs == [] + + +class TestCountRunsWithRelatedByIds: + def test_counts_existing_runs_and_related_rows(self, db_session_with_containers: Session) -> None: + repository = _repository(db_session_with_containers) + scope = _TestScope() + workflow_run = _create_workflow_run( + db_session_with_containers, + scope, + created_at=datetime(2024, 1, 1, 12, 0, 0), + ) + missing_run_id = str(uuid4()) + _add_app_log(db_session_with_containers, scope, workflow_run) + _add_pause_with_reason(db_session_with_containers, workflow_run) + counted_node_run_ids: list[str] = [] + counted_trigger_run_ids: list[str] = [] + + counts = repository.count_runs_with_related_by_ids( + [workflow_run.id, missing_run_id], + count_node_executions=lambda _session, run_ids: counted_node_run_ids.extend(run_ids) or (2, 1), + count_trigger_logs=lambda _session, run_ids: counted_trigger_run_ids.extend(run_ids) or 3, + ) + + assert counted_node_run_ids == [workflow_run.id, missing_run_id] + assert counted_trigger_run_ids == [workflow_run.id, missing_run_id] + assert counts == { + "runs": 1, + "node_executions": 2, + "offloads": 1, + "app_logs": 1, + "trigger_logs": 3, + "pauses": 1, + "pause_reasons": 1, + } + + def test_defaults_optional_related_counts(self, db_session_with_containers: Session) -> None: + repository = _repository(db_session_with_containers) + scope = _TestScope() + workflow_run = _create_workflow_run( + db_session_with_containers, + scope, + created_at=datetime(2024, 1, 1, 12, 0, 0), + ) + + counts = repository.count_runs_with_related_by_ids([workflow_run.id]) + + assert counts == { + "runs": 1, + "node_executions": 0, + "offloads": 0, + "app_logs": 0, + "trigger_logs": 0, + "pauses": 0, + "pause_reasons": 0, + } + + +class TestDeleteRunsWithRelatedByIds: + def test_deletes_runs_and_related_rows(self, db_session_with_containers: Session) -> None: + repository = _repository(db_session_with_containers) + scope = _TestScope() + workflow_run = _create_workflow_run( + db_session_with_containers, + scope, + created_at=datetime(2024, 1, 1, 12, 0, 0), + ) + _add_app_log(db_session_with_containers, scope, workflow_run) + pause = _add_pause_with_reason(db_session_with_containers, workflow_run) + pause_id = pause.id + deleted_node_run_ids: list[str] = [] + deleted_trigger_run_ids: list[str] = [] + + counts = repository.delete_runs_with_related_by_ids( + [workflow_run.id], + delete_node_executions=lambda _session, run_ids: deleted_node_run_ids.extend(run_ids) or (2, 1), + delete_trigger_logs=lambda _session, run_ids: deleted_trigger_run_ids.extend(run_ids) or 3, + ) + + assert deleted_node_run_ids == [workflow_run.id] + assert deleted_trigger_run_ids == [workflow_run.id] + assert counts == { + "runs": 1, + "node_executions": 2, + "offloads": 1, + "app_logs": 1, + "trigger_logs": 3, + "pauses": 1, + "pause_reasons": 1, + } + verification_session = Session(bind=db_session_with_containers.get_bind()) + with verification_session: + assert verification_session.get(WorkflowRun, workflow_run.id) is None + assert verification_session.get(WorkflowPause, pause_id) is None + assert ( + verification_session.scalar( + select(WorkflowAppLog).where(WorkflowAppLog.workflow_run_id == workflow_run.id) + ) + is None + ) + assert ( + verification_session.scalar(select(WorkflowPauseReason).where(WorkflowPauseReason.pause_id == pause_id)) + is None + ) + + def test_defaults_optional_related_counts(self, db_session_with_containers: Session) -> None: + repository = _repository(db_session_with_containers) + scope = _TestScope() + workflow_run = _create_workflow_run( + db_session_with_containers, + scope, + created_at=datetime(2024, 1, 1, 12, 0, 0), + ) + + counts = repository.delete_runs_with_related_by_ids([workflow_run.id]) + + assert counts == { + "runs": 1, + "node_executions": 0, + "offloads": 0, + "app_logs": 0, + "trigger_logs": 0, + "pauses": 0, + "pause_reasons": 0, + } + + def test_empty_ids_return_empty_counts(self, db_session_with_containers: Session) -> None: + repository = _repository(db_session_with_containers) + + assert repository.count_runs_with_related_by_ids([]) == { + "runs": 0, + "node_executions": 0, + "offloads": 0, + "app_logs": 0, + "trigger_logs": 0, + "pauses": 0, + "pause_reasons": 0, + } + assert repository.delete_runs_with_related_by_ids([]) == { + "runs": 0, + "node_executions": 0, + "offloads": 0, + "app_logs": 0, + "trigger_logs": 0, + "pauses": 0, + "pause_reasons": 0, + } diff --git a/api/tests/test_containers_integration_tests/services/rag_pipeline/test_rag_pipeline_service_db.py b/api/tests/test_containers_integration_tests/services/rag_pipeline/test_rag_pipeline_service_db.py index 8fc1809a467..8f126e1cff0 100644 --- a/api/tests/test_containers_integration_tests/services/rag_pipeline/test_rag_pipeline_service_db.py +++ b/api/tests/test_containers_integration_tests/services/rag_pipeline/test_rag_pipeline_service_db.py @@ -11,19 +11,29 @@ Covers: """ from collections.abc import Generator -from types import SimpleNamespace from unittest.mock import patch from uuid import uuid4 import pytest +from flask import Flask from sqlalchemy.orm import Session, sessionmaker +from models import Account, Tenant from models.dataset import Dataset, Pipeline, PipelineCustomizedTemplate from models.enums import DataSourceType from services.entities.knowledge_entities.rag_pipeline_entities import IconInfo, PipelineTemplateInfoEntity from services.rag_pipeline.rag_pipeline import RagPipelineService +def _make_account(account_id: str, tenant_id: str) -> Account: + account = Account(name="Test User", email=f"{account_id}@example.com") + account.id = account_id + tenant = Tenant(name="Test Tenant") + tenant.id = tenant_id + account._current_tenant = tenant + return account + + class TestRagPipelineServiceGetPipeline: """Integration tests for RagPipelineService.get_pipeline.""" @@ -32,7 +42,7 @@ class TestRagPipelineServiceGetPipeline: yield db_session_with_containers.rollback() - def _make_service(self, flask_app_with_containers) -> RagPipelineService: + def _make_service(self, flask_app_with_containers: Flask) -> RagPipelineService: with ( patch( "services.rag_pipeline.rag_pipeline.DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository", @@ -72,7 +82,7 @@ class TestRagPipelineServiceGetPipeline: return dataset def test_get_pipeline_raises_when_dataset_not_found( - self, db_session_with_containers: Session, flask_app_with_containers + self, db_session_with_containers: Session, flask_app_with_containers: Flask ) -> None: """get_pipeline raises ValueError when dataset does not exist.""" service = self._make_service(flask_app_with_containers) @@ -81,7 +91,7 @@ class TestRagPipelineServiceGetPipeline: service.get_pipeline(tenant_id=str(uuid4()), dataset_id=str(uuid4())) def test_get_pipeline_raises_when_pipeline_not_found( - self, db_session_with_containers: Session, flask_app_with_containers + self, db_session_with_containers: Session, flask_app_with_containers: Flask ) -> None: """get_pipeline raises ValueError when dataset exists but has no linked pipeline.""" tenant_id = str(uuid4()) @@ -95,7 +105,7 @@ class TestRagPipelineServiceGetPipeline: service.get_pipeline(tenant_id=tenant_id, dataset_id=dataset.id) def test_get_pipeline_returns_pipeline_when_found( - self, db_session_with_containers: Session, flask_app_with_containers + self, db_session_with_containers: Session, flask_app_with_containers: Flask ) -> None: """get_pipeline returns the Pipeline when both Dataset and Pipeline exist.""" tenant_id = str(uuid4()) @@ -139,43 +149,44 @@ class TestUpdateCustomizedPipelineTemplate: db_session.flush() return template - def test_update_template_succeeds(self, db_session_with_containers: Session, flask_app_with_containers) -> None: + def test_update_template_succeeds( + self, db_session_with_containers: Session, flask_app_with_containers: Flask + ) -> None: """update_customized_pipeline_template updates name and description.""" tenant_id = str(uuid4()) created_by = str(uuid4()) template = self._create_template(db_session_with_containers, tenant_id, created_by) db_session_with_containers.flush() - fake_user = SimpleNamespace(id=created_by, current_tenant_id=tenant_id) + account = _make_account(created_by, tenant_id) - with patch("services.rag_pipeline.rag_pipeline.current_user", fake_user): - info = PipelineTemplateInfoEntity( - name="Updated Name", - description="Updated description", - icon_info=IconInfo(icon="🔥"), - ) - result = RagPipelineService.update_customized_pipeline_template(template.id, info) + info = PipelineTemplateInfoEntity( + name="Updated Name", + description="Updated description", + icon_info=IconInfo(icon="🔥"), + ) + result = RagPipelineService.update_customized_pipeline_template(template.id, info, account, tenant_id) assert result.name == "Updated Name" assert result.description == "Updated description" def test_update_template_raises_when_not_found( - self, db_session_with_containers: Session, flask_app_with_containers + self, db_session_with_containers: Session, flask_app_with_containers: Flask ) -> None: """update_customized_pipeline_template raises ValueError when template doesn't exist.""" - fake_user = SimpleNamespace(id=str(uuid4()), current_tenant_id=str(uuid4())) + tenant_id = str(uuid4()) + account = _make_account(str(uuid4()), tenant_id) - with patch("services.rag_pipeline.rag_pipeline.current_user", fake_user): - info = PipelineTemplateInfoEntity( - name="New Name", - description="desc", - icon_info=IconInfo(icon="📄"), - ) - with pytest.raises(ValueError, match="Customized pipeline template not found"): - RagPipelineService.update_customized_pipeline_template(str(uuid4()), info) + info = PipelineTemplateInfoEntity( + name="New Name", + description="desc", + icon_info=IconInfo(icon="📄"), + ) + with pytest.raises(ValueError, match="Customized pipeline template not found"): + RagPipelineService.update_customized_pipeline_template(str(uuid4()), info, account, tenant_id) def test_update_template_raises_on_duplicate_name( - self, db_session_with_containers: Session, flask_app_with_containers + self, db_session_with_containers: Session, flask_app_with_containers: Flask ) -> None: """update_customized_pipeline_template raises ValueError when new name already exists.""" tenant_id = str(uuid4()) @@ -184,16 +195,15 @@ class TestUpdateCustomizedPipelineTemplate: self._create_template(db_session_with_containers, tenant_id, created_by, name="Duplicate") db_session_with_containers.flush() - fake_user = SimpleNamespace(id=created_by, current_tenant_id=tenant_id) + account = _make_account(created_by, tenant_id) - with patch("services.rag_pipeline.rag_pipeline.current_user", fake_user): - info = PipelineTemplateInfoEntity( - name="Duplicate", - description="desc", - icon_info=IconInfo(icon="📄"), - ) - with pytest.raises(ValueError, match="Template name is already exists"): - RagPipelineService.update_customized_pipeline_template(template1.id, info) + info = PipelineTemplateInfoEntity( + name="Duplicate", + description="desc", + icon_info=IconInfo(icon="📄"), + ) + with pytest.raises(ValueError, match="Template name is already exists"): + RagPipelineService.update_customized_pipeline_template(template1.id, info, account, tenant_id) class TestDeleteCustomizedPipelineTemplate: @@ -221,7 +231,9 @@ class TestDeleteCustomizedPipelineTemplate: db_session.flush() return template - def test_delete_template_succeeds(self, db_session_with_containers: Session, flask_app_with_containers) -> None: + def test_delete_template_succeeds( + self, db_session_with_containers: Session, flask_app_with_containers: Flask + ) -> None: """delete_customized_pipeline_template removes the template from the DB.""" tenant_id = str(uuid4()) created_by = str(uuid4()) @@ -229,27 +241,23 @@ class TestDeleteCustomizedPipelineTemplate: template_id = template.id db_session_with_containers.flush() - fake_user = SimpleNamespace(id=created_by, current_tenant_id=tenant_id) + RagPipelineService.delete_customized_pipeline_template(template_id, tenant_id) - with patch("services.rag_pipeline.rag_pipeline.current_user", fake_user): - RagPipelineService.delete_customized_pipeline_template(template_id) + # Verify the record is deleted within the same context + from sqlalchemy import select - # Verify the record is deleted within the same context - from sqlalchemy import select + from extensions.ext_database import db as ext_db - from extensions.ext_database import db as ext_db - - remaining = ext_db.session.scalar( - select(PipelineCustomizedTemplate).where(PipelineCustomizedTemplate.id == template_id) - ) - assert remaining is None + remaining = ext_db.session.scalar( + select(PipelineCustomizedTemplate).where(PipelineCustomizedTemplate.id == template_id) + ) + assert remaining is None def test_delete_template_raises_when_not_found( - self, db_session_with_containers: Session, flask_app_with_containers + self, db_session_with_containers: Session, flask_app_with_containers: Flask ) -> None: """delete_customized_pipeline_template raises ValueError when template doesn't exist.""" - fake_user = SimpleNamespace(id=str(uuid4()), current_tenant_id=str(uuid4())) + tenant_id = str(uuid4()) - with patch("services.rag_pipeline.rag_pipeline.current_user", fake_user): - with pytest.raises(ValueError, match="Customized pipeline template not found"): - RagPipelineService.delete_customized_pipeline_template(str(uuid4())) + with pytest.raises(ValueError, match="Customized pipeline template not found"): + RagPipelineService.delete_customized_pipeline_template(str(uuid4()), tenant_id) diff --git a/api/tests/test_containers_integration_tests/services/recommend_app/test_database_retrieval.py b/api/tests/test_containers_integration_tests/services/recommend_app/test_database_retrieval.py index e4a106694b1..0f7c790ba14 100644 --- a/api/tests/test_containers_integration_tests/services/recommend_app/test_database_retrieval.py +++ b/api/tests/test_containers_integration_tests/services/recommend_app/test_database_retrieval.py @@ -51,6 +51,7 @@ def _create_recommended_app( categories: list[str] | None = None, language: str = "en-US", is_listed: bool = True, + is_learn_dify: bool = False, position: int = 1, ) -> RecommendedApp: rec = RecommendedApp( @@ -62,6 +63,7 @@ def _create_recommended_app( categories=[category] if categories is None else categories, language=language, is_listed=is_listed, + is_learn_dify=is_learn_dify, position=position, ) rec.id = str(uuid4()) @@ -205,6 +207,65 @@ class TestFetchRecommendedAppsFromDb: app_ids = {r["app_id"] for r in result["recommended_apps"]} assert app1.id not in app_ids + def test_fetch_learn_dify_apps_uses_flag_not_categories( + self, + flask_app_with_containers, + db_session_with_containers: Session, + ): + tenant_id = str(uuid4()) + learn_dify_app = _create_app(db_session_with_containers, tenant_id=tenant_id) + _create_site(db_session_with_containers, app_id=learn_dify_app.id) + _create_recommended_app( + db_session_with_containers, + app_id=learn_dify_app.id, + category="workflow", + categories=["Workflow"], + is_learn_dify=True, + ) + + category_only_app = _create_app(db_session_with_containers, tenant_id=tenant_id) + _create_site(db_session_with_containers, app_id=category_only_app.id) + _create_recommended_app( + db_session_with_containers, + app_id=category_only_app.id, + category="Learn Dify", + categories=["Learn Dify"], + is_learn_dify=False, + ) + + db_session_with_containers.expire_all() + + result = DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db("en-US") + + app_ids = {r["app_id"] for r in result["recommended_apps"]} + assert learn_dify_app.id in app_ids + assert category_only_app.id not in app_ids + recommended_app = next(item for item in result["recommended_apps"] if item["app_id"] == learn_dify_app.id) + assert recommended_app["categories"] == ["Workflow"] + + def test_fetch_learn_dify_apps_falls_back_to_default_language( + self, + flask_app_with_containers, + db_session_with_containers: Session, + ): + tenant_id = str(uuid4()) + learn_dify_app = _create_app(db_session_with_containers, tenant_id=tenant_id) + _create_site(db_session_with_containers, app_id=learn_dify_app.id) + _create_recommended_app( + db_session_with_containers, + app_id=learn_dify_app.id, + categories=["Workflow"], + is_learn_dify=True, + language="en-US", + ) + + db_session_with_containers.expire_all() + + result = DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db("fr-FR") + + app_ids = {r["app_id"] for r in result["recommended_apps"]} + assert learn_dify_app.id in app_ids + class TestFetchRecommendedAppDetailFromDb: def test_returns_none_when_not_listed(self, flask_app_with_containers: Flask, db_session_with_containers: Session): diff --git a/api/tests/test_containers_integration_tests/services/test_app_service.py b/api/tests/test_containers_integration_tests/services/test_app_service.py index 56d643e4c1d..384f83fce3e 100644 --- a/api/tests/test_containers_integration_tests/services/test_app_service.py +++ b/api/tests/test_containers_integration_tests/services/test_app_service.py @@ -1,6 +1,8 @@ +from datetime import datetime from unittest.mock import create_autospec, patch import pytest +import sqlalchemy as sa from faker import Faker from pydantic import ValidationError from sqlalchemy.orm import Session @@ -245,6 +247,236 @@ class TestAppService: assert app.tenant_id == tenant.id assert app.mode == "chat" + def test_get_paginate_apps_sorts_by_modified_and_created_times( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): + """ + Test app list sort options for modified time and creation time. + """ + fake = Faker() + + account = AccountService.create_account( + email=fake.email(), + name=fake.name(), + interface_language="en-US", + password=generate_valid_password(fake), + ) + TenantService.create_owner_tenant_if_not_exist(account, name=fake.company()) + tenant = account.current_tenant + + from services.app_service import AppListParams, AppService, CreateAppParams + + app_service = AppService() + oldest_created = app_service.create_app( + tenant.id, + CreateAppParams(name="Oldest Created", mode="chat", icon_type="emoji", icon="1"), + account, + ) + newest_modified = app_service.create_app( + tenant.id, + CreateAppParams(name="Newest Modified", mode="chat", icon_type="emoji", icon="2"), + account, + ) + newest_created = app_service.create_app( + tenant.id, + CreateAppParams(name="Newest Created", mode="chat", icon_type="emoji", icon="3"), + account, + ) + + timestamp_by_app_id = { + oldest_created.id: (datetime(2026, 1, 1, 10, 0, 0), datetime(2026, 1, 1, 10, 0, 0)), + newest_modified.id: (datetime(2026, 1, 2, 10, 0, 0), datetime(2026, 1, 4, 10, 0, 0)), + newest_created.id: (datetime(2026, 1, 3, 10, 0, 0), datetime(2026, 1, 3, 10, 0, 0)), + } + for app_id, (created_at, updated_at) in timestamp_by_app_id.items(): + db_session_with_containers.execute( + sa.update(App).where(App.id == app_id).values(created_at=created_at, updated_at=updated_at) + ) + db_session_with_containers.commit() + + last_modified_apps = app_service.get_paginate_apps( + account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat") + ) + assert last_modified_apps is not None + assert [app.name for app in last_modified_apps.items] == [ + "Newest Modified", + "Newest Created", + "Oldest Created", + ] + + recently_created_apps = app_service.get_paginate_apps( + account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat", sort_by="recently_created") + ) + assert recently_created_apps is not None + assert [app.name for app in recently_created_apps.items] == [ + "Newest Created", + "Newest Modified", + "Oldest Created", + ] + + earliest_created_apps = app_service.get_paginate_apps( + account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat", sort_by="earliest_created") + ) + assert earliest_created_apps is not None + assert [app.name for app in earliest_created_apps.items] == [ + "Oldest Created", + "Newest Modified", + "Newest Created", + ] + + def test_get_paginate_apps_marks_starred_apps( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): + """ + Test app list marks apps starred by the current account. + """ + fake = Faker() + + account = AccountService.create_account( + email=fake.email(), + name=fake.name(), + interface_language="en-US", + password=generate_valid_password(fake), + ) + TenantService.create_owner_tenant_if_not_exist(account, name=fake.company()) + tenant = account.current_tenant + + from models import AppStar + from services.app_service import AppListParams, AppService, CreateAppParams + + app_service = AppService() + starred_app = app_service.create_app( + tenant.id, + CreateAppParams(name="Starred App", mode="chat", icon_type="emoji", icon="1"), + account, + ) + unstarred_app = app_service.create_app( + tenant.id, + CreateAppParams(name="Unstarred App", mode="chat", icon_type="emoji", icon="2"), + account, + ) + + app_service.star_app(db_session_with_containers, app=starred_app, account_id=account.id) + app_service.star_app(db_session_with_containers, app=starred_app, account_id=account.id) + db_session_with_containers.commit() + + star_count = db_session_with_containers.scalar( + sa.select(sa.func.count()).select_from(AppStar).where(AppStar.app_id == starred_app.id) + ) + assert star_count == 1 + + paginated_apps = app_service.get_paginate_apps( + account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat") + ) + assert paginated_apps is not None + starred_by_app_id = {app.id: app.is_starred for app in paginated_apps.items} + assert starred_by_app_id[starred_app.id] is True + assert starred_by_app_id[unstarred_app.id] is False + + app_service.unstar_app(db_session_with_containers, app=starred_app, account_id=account.id) + db_session_with_containers.commit() + + paginated_apps = app_service.get_paginate_apps( + account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat") + ) + assert paginated_apps is not None + starred_by_app_id = {app.id: app.is_starred for app in paginated_apps.items} + assert starred_by_app_id[starred_app.id] is False + assert starred_by_app_id[unstarred_app.id] is False + + def test_get_paginate_starred_apps_returns_only_starred_apps_with_requested_sort( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): + """ + Test starred app list returns only starred apps ordered by requested app sort. + """ + fake = Faker() + + account = AccountService.create_account( + email=fake.email(), + name=fake.name(), + interface_language="en-US", + password=generate_valid_password(fake), + ) + TenantService.create_owner_tenant_if_not_exist(account, name=fake.company()) + tenant = account.current_tenant + + from services.app_service import AppService, CreateAppParams, StarredAppListParams + + app_service = AppService() + oldest_created_app = app_service.create_app( + tenant.id, + CreateAppParams(name="Oldest Created Starred App", mode="chat", icon_type="emoji", icon="1"), + account, + ) + newest_modified_app = app_service.create_app( + tenant.id, + CreateAppParams(name="Newest Modified Starred App", mode="chat", icon_type="emoji", icon="2"), + account, + ) + newest_created_app = app_service.create_app( + tenant.id, + CreateAppParams(name="Newest Created Starred App", mode="chat", icon_type="emoji", icon="3"), + account, + ) + unstarred_app = app_service.create_app( + tenant.id, + CreateAppParams(name="Unstarred App", mode="chat", icon_type="emoji", icon="4"), + account, + ) + + app_service.star_app(db_session_with_containers, app=oldest_created_app, account_id=account.id) + app_service.star_app(db_session_with_containers, app=newest_modified_app, account_id=account.id) + app_service.star_app(db_session_with_containers, app=newest_created_app, account_id=account.id) + + timestamp_by_app_id = { + oldest_created_app.id: (datetime(2026, 1, 1, 10, 0, 0), datetime(2026, 1, 1, 10, 0, 0)), + newest_modified_app.id: (datetime(2026, 1, 2, 10, 0, 0), datetime(2026, 1, 4, 10, 0, 0)), + newest_created_app.id: (datetime(2026, 1, 3, 10, 0, 0), datetime(2026, 1, 3, 10, 0, 0)), + unstarred_app.id: (datetime(2026, 1, 5, 10, 0, 0), datetime(2026, 1, 5, 10, 0, 0)), + } + for app_id, (created_at, updated_at) in timestamp_by_app_id.items(): + db_session_with_containers.execute( + sa.update(App).where(App.id == app_id).values(created_at=created_at, updated_at=updated_at) + ) + db_session_with_containers.commit() + + last_modified_apps = app_service.get_paginate_starred_apps( + account.id, tenant.id, StarredAppListParams(page=1, limit=10, mode="chat") + ) + assert last_modified_apps is not None + assert [app.name for app in last_modified_apps.items] == [ + "Newest Modified Starred App", + "Newest Created Starred App", + "Oldest Created Starred App", + ] + assert all(app.is_starred for app in last_modified_apps.items) + assert unstarred_app.id not in {app.id for app in last_modified_apps.items} + + recently_created_apps = app_service.get_paginate_starred_apps( + account.id, + tenant.id, + StarredAppListParams(page=1, limit=10, mode="chat", sort_by="recently_created"), + ) + assert recently_created_apps is not None + assert [app.name for app in recently_created_apps.items] == [ + "Newest Created Starred App", + "Newest Modified Starred App", + "Oldest Created Starred App", + ] + + earliest_created_apps = app_service.get_paginate_starred_apps( + account.id, + tenant.id, + StarredAppListParams(page=1, limit=10, mode="chat", sort_by="earliest_created"), + ) + assert earliest_created_apps is not None + assert [app.name for app in earliest_created_apps.items] == [ + "Oldest Created Starred App", + "Newest Modified Starred App", + "Newest Created Starred App", + ] + def test_get_paginate_apps_with_filters( self, db_session_with_containers: Session, mock_external_service_dependencies ): diff --git a/api/tests/test_containers_integration_tests/services/test_end_user_service.py b/api/tests/test_containers_integration_tests/services/test_end_user_service.py index 3f611d92f72..af6fb879acb 100644 --- a/api/tests/test_containers_integration_tests/services/test_end_user_service.py +++ b/api/tests/test_containers_integration_tests/services/test_end_user_service.py @@ -1,6 +1,6 @@ from __future__ import annotations -from unittest.mock import patch +import logging from uuid import uuid4 import pytest @@ -104,7 +104,9 @@ class TestEndUserServiceGetOrCreateEndUser: """Provide test data factory.""" return TestEndUserServiceFactory() - def test_get_or_create_end_user_with_custom_user_id(self, db_session_with_containers: Session, factory): + def test_get_or_create_end_user_with_custom_user_id( + self, db_session_with_containers: Session, factory: TestEndUserServiceFactory + ): """Test getting or creating end user with custom user_id.""" # Arrange app = factory.create_app_and_account(db_session_with_containers) @@ -120,7 +122,9 @@ class TestEndUserServiceGetOrCreateEndUser: assert result.type == InvokeFrom.SERVICE_API assert result.is_anonymous is False - def test_get_or_create_end_user_without_user_id(self, db_session_with_containers: Session, factory): + def test_get_or_create_end_user_without_user_id( + self, db_session_with_containers: Session, factory: TestEndUserServiceFactory + ): """Test getting or creating end user without user_id uses default session.""" # Arrange app = factory.create_app_and_account(db_session_with_containers) @@ -133,7 +137,7 @@ class TestEndUserServiceGetOrCreateEndUser: # Verify _is_anonymous is set correctly (property always returns False) assert result._is_anonymous is True - def test_get_existing_end_user(self, db_session_with_containers: Session, factory): + def test_get_existing_end_user(self, db_session_with_containers: Session, factory: TestEndUserServiceFactory): """Test retrieving an existing end user.""" # Arrange app = factory.create_app_and_account(db_session_with_containers) @@ -169,7 +173,9 @@ class TestEndUserServiceGetOrCreateEndUserByType: """Provide test data factory.""" return TestEndUserServiceFactory() - def test_create_end_user_service_api_type(self, db_session_with_containers: Session, factory): + def test_create_end_user_service_api_type( + self, db_session_with_containers: Session, factory: TestEndUserServiceFactory + ): """Test creating new end user with SERVICE_API type.""" # Arrange app = factory.create_app_and_account(db_session_with_containers) @@ -191,7 +197,9 @@ class TestEndUserServiceGetOrCreateEndUserByType: assert result.app_id == app_id assert result.session_id == user_id - def test_create_end_user_web_app_type(self, db_session_with_containers: Session, factory): + def test_create_end_user_web_app_type( + self, db_session_with_containers: Session, factory: TestEndUserServiceFactory + ): """Test creating new end user with WEB_APP type.""" # Arrange app = factory.create_app_and_account(db_session_with_containers) @@ -210,8 +218,9 @@ class TestEndUserServiceGetOrCreateEndUserByType: # Assert assert result.type == InvokeFrom.WEB_APP - @patch("services.end_user_service.logger") - def test_upgrade_legacy_end_user_type(self, mock_logger, db_session_with_containers: Session, factory): + def test_upgrade_legacy_end_user_type( + self, caplog: pytest.LogCaptureFixture, db_session_with_containers: Session, factory: TestEndUserServiceFactory + ): """Test upgrading legacy end user with different type.""" # Arrange app = factory.create_app_and_account(db_session_with_containers) @@ -227,25 +236,31 @@ class TestEndUserServiceGetOrCreateEndUserByType: session_id=user_id, invoke_type=InvokeFrom.SERVICE_API, ) - - # Act - Request with different type - result = EndUserService.get_or_create_end_user_by_type( - type=InvokeFrom.WEB_APP, - tenant_id=tenant_id, - app_id=app_id, - user_id=user_id, - ) + with caplog.at_level(logging.INFO, logger="services.end_user_service"): + # Act - Request with different type + result = EndUserService.get_or_create_end_user_by_type( + type=InvokeFrom.WEB_APP, + tenant_id=tenant_id, + app_id=app_id, + user_id=user_id, + ) # Assert assert result.id == existing_user.id assert result.type == InvokeFrom.WEB_APP # Type should be updated - mock_logger.info.assert_called_once() - # Verify log message contains upgrade info - log_call = mock_logger.info.call_args[0][0] - assert "Upgrading legacy EndUser" in log_call + matching_logs = [ + record + for record in caplog.records + if record.name == "services.end_user_service" + and record.levelno == logging.INFO + and "Upgrading legacy EndUser" in record.message + ] - @patch("services.end_user_service.logger") - def test_get_existing_end_user_matching_type(self, mock_logger, db_session_with_containers: Session, factory): + assert len(matching_logs) == 1 + + def test_get_existing_end_user_matching_type( + self, db_session_with_containers: Session, factory: TestEndUserServiceFactory, caplog + ): """Test retrieving existing end user with matching type.""" # Arrange app = factory.create_app_and_account(db_session_with_containers) @@ -262,19 +277,23 @@ class TestEndUserServiceGetOrCreateEndUserByType: ) # Act - Request with same type - result = EndUserService.get_or_create_end_user_by_type( - type=InvokeFrom.SERVICE_API, - tenant_id=tenant_id, - app_id=app_id, - user_id=user_id, - ) + with caplog.at_level(logging.INFO, logger="services.end_user_service"): + result = EndUserService.get_or_create_end_user_by_type( + type=InvokeFrom.SERVICE_API, + tenant_id=tenant_id, + app_id=app_id, + user_id=user_id, + ) # Assert assert result.id == existing_user.id assert result.type == InvokeFrom.SERVICE_API - mock_logger.info.assert_not_called() + # No legacy-upgrade log should be emitted when the existing user's type already matches. + assert [record for record in caplog.records if record.levelno == logging.INFO] == [] - def test_create_anonymous_user_with_default_session(self, db_session_with_containers: Session, factory): + def test_create_anonymous_user_with_default_session( + self, db_session_with_containers: Session, factory: TestEndUserServiceFactory + ): """Test creating anonymous user when user_id is None.""" # Arrange app = factory.create_app_and_account(db_session_with_containers) @@ -295,7 +314,9 @@ class TestEndUserServiceGetOrCreateEndUserByType: assert result._is_anonymous is True assert result.external_user_id == DefaultEndUserSessionID.DEFAULT_SESSION_ID - def test_query_ordering_prioritizes_matching_type(self, db_session_with_containers: Session, factory): + def test_query_ordering_prioritizes_matching_type( + self, db_session_with_containers: Session, factory: TestEndUserServiceFactory + ): """Test that query ordering prioritizes records with matching type.""" # Arrange app = factory.create_app_and_account(db_session_with_containers) @@ -330,7 +351,9 @@ class TestEndUserServiceGetOrCreateEndUserByType: assert result.id == matching.id assert result.id != non_matching.id - def test_external_user_id_matches_session_id(self, db_session_with_containers: Session, factory): + def test_external_user_id_matches_session_id( + self, db_session_with_containers: Session, factory: TestEndUserServiceFactory + ): """Test that external_user_id is set to match session_id.""" # Arrange app = factory.create_app_and_account(db_session_with_containers) @@ -360,7 +383,7 @@ class TestEndUserServiceGetOrCreateEndUserByType: ], ) def test_create_end_user_with_different_invoke_types( - self, db_session_with_containers: Session, invoke_type, factory + self, db_session_with_containers: Session, invoke_type: InvokeFrom, factory: TestEndUserServiceFactory ): """Test creating end users with different InvokeFrom types.""" # Arrange @@ -389,7 +412,9 @@ class TestEndUserServiceGetEndUserById: """Provide test data factory.""" return TestEndUserServiceFactory() - def test_get_end_user_by_id_returns_end_user(self, db_session_with_containers: Session, factory): + def test_get_end_user_by_id_returns_end_user( + self, db_session_with_containers: Session, factory: TestEndUserServiceFactory + ): app = factory.create_app_and_account(db_session_with_containers) existing_user = factory.create_end_user( db_session_with_containers, @@ -408,7 +433,9 @@ class TestEndUserServiceGetEndUserById: assert result is not None assert result.id == existing_user.id - def test_get_end_user_by_id_returns_none(self, db_session_with_containers: Session, factory): + def test_get_end_user_by_id_returns_none( + self, db_session_with_containers: Session, factory: TestEndUserServiceFactory + ): app = factory.create_app_and_account(db_session_with_containers) result = EndUserService.get_end_user_by_id( @@ -427,7 +454,9 @@ class TestEndUserServiceCreateBatch: def factory(self): return TestEndUserServiceFactory() - def _create_multiple_apps(self, db_session_with_containers: Session, factory, count: int = 3): + def _create_multiple_apps( + self, db_session_with_containers: Session, factory: TestEndUserServiceFactory, count: int = 3 + ): """Create multiple apps under the same tenant.""" first_app = factory.create_app_and_account(db_session_with_containers) tenant_id = first_app.tenant_id @@ -462,7 +491,9 @@ class TestEndUserServiceCreateBatch: ) assert result == {} - def test_create_batch_creates_users_for_all_apps(self, db_session_with_containers: Session, factory): + def test_create_batch_creates_users_for_all_apps( + self, db_session_with_containers: Session, factory: TestEndUserServiceFactory + ): tenant_id, apps = self._create_multiple_apps(db_session_with_containers, factory, count=3) app_ids = [a.id for a in apps] user_id = f"user-{uuid4()}" @@ -477,7 +508,9 @@ class TestEndUserServiceCreateBatch: assert result[app_id].session_id == user_id assert result[app_id].type == InvokeFrom.SERVICE_API - def test_create_batch_default_session_id(self, db_session_with_containers: Session, factory): + def test_create_batch_default_session_id( + self, db_session_with_containers: Session, factory: TestEndUserServiceFactory + ): tenant_id, apps = self._create_multiple_apps(db_session_with_containers, factory, count=2) app_ids = [a.id for a in apps] @@ -490,7 +523,9 @@ class TestEndUserServiceCreateBatch: assert end_user.session_id == DefaultEndUserSessionID.DEFAULT_SESSION_ID assert end_user._is_anonymous is True - def test_create_batch_deduplicate_app_ids(self, db_session_with_containers: Session, factory): + def test_create_batch_deduplicate_app_ids( + self, db_session_with_containers: Session, factory: TestEndUserServiceFactory + ): tenant_id, apps = self._create_multiple_apps(db_session_with_containers, factory, count=2) app_ids = [apps[0].id, apps[1].id, apps[0].id, apps[1].id] user_id = f"user-{uuid4()}" @@ -501,7 +536,9 @@ class TestEndUserServiceCreateBatch: assert len(result) == 2 - def test_create_batch_returns_existing_users(self, db_session_with_containers: Session, factory): + def test_create_batch_returns_existing_users( + self, db_session_with_containers: Session, factory: TestEndUserServiceFactory + ): tenant_id, apps = self._create_multiple_apps(db_session_with_containers, factory, count=2) app_ids = [a.id for a in apps] user_id = f"user-{uuid4()}" @@ -520,7 +557,9 @@ class TestEndUserServiceCreateBatch: for app_id in app_ids: assert first_result[app_id].id == second_result[app_id].id - def test_create_batch_partial_existing_users(self, db_session_with_containers: Session, factory): + def test_create_batch_partial_existing_users( + self, db_session_with_containers: Session, factory: TestEndUserServiceFactory + ): tenant_id, apps = self._create_multiple_apps(db_session_with_containers, factory, count=3) user_id = f"user-{uuid4()}" @@ -549,7 +588,9 @@ class TestEndUserServiceCreateBatch: "invoke_type", [InvokeFrom.SERVICE_API, InvokeFrom.WEB_APP, InvokeFrom.EXPLORE, InvokeFrom.DEBUGGER], ) - def test_create_batch_all_invoke_types(self, db_session_with_containers: Session, invoke_type, factory): + def test_create_batch_all_invoke_types( + self, db_session_with_containers: Session, invoke_type: InvokeFrom, factory: TestEndUserServiceFactory + ): tenant_id, apps = self._create_multiple_apps(db_session_with_containers, factory, count=1) user_id = f"user-{uuid4()}" diff --git a/api/tests/test_containers_integration_tests/services/test_metadata_partial_update.py b/api/tests/test_containers_integration_tests/services/test_metadata_partial_update.py index f3ab9eb3da8..5844441e6a5 100644 --- a/api/tests/test_containers_integration_tests/services/test_metadata_partial_update.py +++ b/api/tests/test_containers_integration_tests/services/test_metadata_partial_update.py @@ -1,6 +1,6 @@ from __future__ import annotations -from unittest.mock import Mock, patch +from unittest.mock import patch from uuid import uuid4 import pytest @@ -8,6 +8,7 @@ from flask import Flask from sqlalchemy import select from sqlalchemy.orm import Session +from models import Account, Tenant from models.dataset import Dataset, DatasetMetadataBinding, Document from models.enums import DataSourceType, DocumentCreatedFrom from services.entities.knowledge_entities.knowledge_entities import ( @@ -33,7 +34,7 @@ def _create_dataset(db_session: Session, *, tenant_id: str, built_in_field_enabl def _create_document( - db_session: Session, *, dataset_id: str, tenant_id: str, doc_metadata: dict | None = None + db_session: Session, *, dataset_id: str, tenant_id: str, doc_metadata: dict[str, str] | None = None ) -> Document: document = Document( tenant_id=tenant_id, @@ -63,18 +64,21 @@ class TestMetadataPartialUpdate: return str(uuid4()) @pytest.fixture - def mock_current_account(self, user_id, tenant_id): - account = Mock(id=user_id, current_tenant_id=tenant_id) - with patch("services.metadata_service.current_account_with_tenant", return_value=(account, tenant_id)): - yield account + def current_account(self, user_id: str, tenant_id: str) -> Account: + account = Account(name="Test User", email=f"{user_id}@example.com") + account.id = user_id + tenant = Tenant(name="Test Tenant") + tenant.id = tenant_id + account._current_tenant = tenant + return account def test_partial_update_merges_metadata( self, flask_app_with_containers: Flask, db_session_with_containers: Session, tenant_id: str, - mock_current_account, - ): + current_account: Account, + ) -> None: dataset = _create_dataset(db_session_with_containers, tenant_id=tenant_id) document = _create_document( db_session_with_containers, @@ -91,7 +95,7 @@ class TestMetadataPartialUpdate: ) metadata_args = MetadataOperationData(operation_data=[operation]) - MetadataService.update_documents_metadata(dataset, metadata_args) + MetadataService.update_documents_metadata(dataset, metadata_args, current_account) db_session_with_containers.expire_all() updated_doc = db_session_with_containers.get(Document, document.id) @@ -104,8 +108,8 @@ class TestMetadataPartialUpdate: flask_app_with_containers: Flask, db_session_with_containers: Session, tenant_id: str, - mock_current_account, - ): + current_account: Account, + ) -> None: dataset = _create_dataset(db_session_with_containers, tenant_id=tenant_id) document = _create_document( db_session_with_containers, @@ -122,7 +126,7 @@ class TestMetadataPartialUpdate: ) metadata_args = MetadataOperationData(operation_data=[operation]) - MetadataService.update_documents_metadata(dataset, metadata_args) + MetadataService.update_documents_metadata(dataset, metadata_args, current_account) db_session_with_containers.expire_all() updated_doc = db_session_with_containers.get(Document, document.id) @@ -134,10 +138,10 @@ class TestMetadataPartialUpdate: self, flask_app_with_containers: Flask, db_session_with_containers: Session, - tenant_id, - user_id, - mock_current_account, - ): + tenant_id: str, + user_id: str, + current_account: Account, + ) -> None: dataset = _create_dataset(db_session_with_containers, tenant_id=tenant_id) document = _create_document( db_session_with_containers, @@ -164,7 +168,7 @@ class TestMetadataPartialUpdate: ) metadata_args = MetadataOperationData(operation_data=[operation]) - MetadataService.update_documents_metadata(dataset, metadata_args) + MetadataService.update_documents_metadata(dataset, metadata_args, current_account) db_session_with_containers.expire_all() bindings = db_session_with_containers.scalars( @@ -180,8 +184,8 @@ class TestMetadataPartialUpdate: flask_app_with_containers: Flask, db_session_with_containers: Session, tenant_id: str, - mock_current_account, - ): + current_account: Account, + ) -> None: dataset = _create_dataset(db_session_with_containers, tenant_id=tenant_id) document = _create_document( db_session_with_containers, @@ -200,4 +204,4 @@ class TestMetadataPartialUpdate: with patch("services.metadata_service.db.session.commit", side_effect=RuntimeError("database connection lost")): with pytest.raises(RuntimeError, match="database connection lost"): - MetadataService.update_documents_metadata(dataset, metadata_args) + MetadataService.update_documents_metadata(dataset, metadata_args, current_account) diff --git a/api/tests/test_containers_integration_tests/services/test_metadata_service.py b/api/tests/test_containers_integration_tests/services/test_metadata_service.py index 8b1349be9a8..0c9e3830430 100644 --- a/api/tests/test_containers_integration_tests/services/test_metadata_service.py +++ b/api/tests/test_containers_integration_tests/services/test_metadata_service.py @@ -1,4 +1,6 @@ -from unittest.mock import create_autospec, patch +from collections.abc import Generator +from typing import TypedDict +from unittest.mock import Mock, patch import pytest from faker import Faker @@ -6,21 +8,25 @@ from sqlalchemy.orm import Session from core.rag.index_processor.constant.built_in_field import BuiltInField from core.rag.index_processor.constant.index_type import IndexStructureType -from models import Account, Tenant, TenantAccountJoin, TenantAccountRole +from models import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole, TenantStatus from models.dataset import Dataset, DatasetMetadata, DatasetMetadataBinding, Document -from models.enums import DatasetMetadataType, DataSourceType, DocumentCreatedFrom +from models.enums import DataSourceType, DocumentCreatedFrom from services.entities.knowledge_entities.knowledge_entities import MetadataArgs from services.metadata_service import MetadataService +class MetadataServiceDeps(TypedDict): + redis_client: Mock + document_service: Mock + + class TestMetadataService: """Integration tests for MetadataService using testcontainers.""" @pytest.fixture - def mock_external_service_dependencies(self): + def mock_external_service_dependencies(self) -> Generator[MetadataServiceDeps, None, None]: """Mock setup for external service dependencies.""" with ( - patch("libs.login.current_user", create_autospec(Account, instance=True)) as mock_current_user, patch("services.metadata_service.redis_client") as mock_redis_client, patch("services.dataset_service.DocumentService") as mock_document_service, ): @@ -30,12 +36,15 @@ class TestMetadataService: mock_redis_client.delete.return_value = 1 yield { - "current_user": mock_current_user, "redis_client": mock_redis_client, "document_service": mock_document_service, } - 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: MetadataServiceDeps, + ) -> tuple[Account, Tenant]: """ Helper method to create a test account and tenant for testing. @@ -53,7 +62,7 @@ class TestMetadataService: email=fake.email(), name=fake.name(), interface_language="en-US", - status="active", + status=AccountStatus.ACTIVE, ) db_session_with_containers.add(account) @@ -62,7 +71,7 @@ class TestMetadataService: # 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() @@ -83,8 +92,12 @@ class TestMetadataService: return account, tenant def _create_test_dataset( - self, db_session_with_containers: Session, mock_external_service_dependencies, account, tenant - ): + self, + db_session_with_containers: Session, + mock_external_service_dependencies: MetadataServiceDeps, + account: Account, + tenant: Tenant, + ) -> Dataset: """ Helper method to create a test dataset for testing. @@ -114,8 +127,12 @@ class TestMetadataService: return dataset def _create_test_document( - self, db_session_with_containers: Session, mock_external_service_dependencies, dataset, account - ): + self, + db_session_with_containers: Session, + mock_external_service_dependencies: MetadataServiceDeps, + dataset: Dataset, + account: Account, + ) -> Document: """ Helper method to create a test document for testing. @@ -149,7 +166,9 @@ class TestMetadataService: return document - def test_create_metadata_success(self, db_session_with_containers: Session, mock_external_service_dependencies): + def test_create_metadata_success( + self, db_session_with_containers: Session, mock_external_service_dependencies: MetadataServiceDeps + ) -> None: """ Test successful metadata creation with valid parameters. """ @@ -161,14 +180,10 @@ class TestMetadataService: db_session_with_containers, mock_external_service_dependencies, account, tenant ) - # Setup mocks - mock_external_service_dependencies["current_user"].current_tenant_id = tenant.id - mock_external_service_dependencies["current_user"].id = account.id - - metadata_args = MetadataArgs(type=DatasetMetadataType.STRING, name="test_metadata") + metadata_args = MetadataArgs(type="string", name="test_metadata") # Act: Execute the method under test - result = MetadataService.create_metadata(dataset.id, metadata_args) + result = MetadataService.create_metadata(dataset.id, metadata_args, account, tenant.id) # Assert: Verify the expected outcomes assert result is not None @@ -185,8 +200,8 @@ class TestMetadataService: assert result.created_at is not None def test_create_metadata_name_too_long( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, db_session_with_containers: Session, mock_external_service_dependencies: MetadataServiceDeps + ) -> None: """ Test metadata creation fails when name exceeds 255 characters. """ @@ -198,20 +213,16 @@ class TestMetadataService: db_session_with_containers, mock_external_service_dependencies, account, tenant ) - # Setup mocks - mock_external_service_dependencies["current_user"].current_tenant_id = tenant.id - mock_external_service_dependencies["current_user"].id = account.id - long_name = "a" * 256 # 256 characters, exceeding 255 limit - metadata_args = MetadataArgs(type=DatasetMetadataType.STRING, name=long_name) + metadata_args = MetadataArgs(type="string", name=long_name) # Act & Assert: Verify proper error handling with pytest.raises(ValueError, match="Metadata name cannot exceed 255 characters."): - MetadataService.create_metadata(dataset.id, metadata_args) + MetadataService.create_metadata(dataset.id, metadata_args, account, tenant.id) def test_create_metadata_name_already_exists( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, db_session_with_containers: Session, mock_external_service_dependencies: MetadataServiceDeps + ) -> None: """ Test metadata creation fails when name already exists in the same dataset. """ @@ -223,24 +234,20 @@ class TestMetadataService: db_session_with_containers, mock_external_service_dependencies, account, tenant ) - # Setup mocks - mock_external_service_dependencies["current_user"].current_tenant_id = tenant.id - mock_external_service_dependencies["current_user"].id = account.id - # Create first metadata - first_metadata_args = MetadataArgs(type=DatasetMetadataType.STRING, name="duplicate_name") - MetadataService.create_metadata(dataset.id, first_metadata_args) + first_metadata_args = MetadataArgs(type="string", name="duplicate_name") + MetadataService.create_metadata(dataset.id, first_metadata_args, account, tenant.id) # Try to create second metadata with same name - second_metadata_args = MetadataArgs(type=DatasetMetadataType.NUMBER, name="duplicate_name") + second_metadata_args = MetadataArgs(type="number", name="duplicate_name") # Act & Assert: Verify proper error handling with pytest.raises(ValueError, match="Metadata name already exists."): - MetadataService.create_metadata(dataset.id, second_metadata_args) + MetadataService.create_metadata(dataset.id, second_metadata_args, account, tenant.id) def test_create_metadata_name_conflicts_with_built_in_field( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, db_session_with_containers: Session, mock_external_service_dependencies: MetadataServiceDeps + ) -> None: """ Test metadata creation fails when name conflicts with built-in field names. """ @@ -252,21 +259,17 @@ class TestMetadataService: db_session_with_containers, mock_external_service_dependencies, account, tenant ) - # Setup mocks - mock_external_service_dependencies["current_user"].current_tenant_id = tenant.id - mock_external_service_dependencies["current_user"].id = account.id - # Try to create metadata with built-in field name built_in_field_name = BuiltInField.document_name - metadata_args = MetadataArgs(type=DatasetMetadataType.STRING, name=built_in_field_name) + metadata_args = MetadataArgs(type="string", name=built_in_field_name) # Act & Assert: Verify proper error handling with pytest.raises(ValueError, match="Metadata name already exists in Built-in fields."): - MetadataService.create_metadata(dataset.id, metadata_args) + MetadataService.create_metadata(dataset.id, metadata_args, account, tenant.id) def test_update_metadata_name_success( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, db_session_with_containers: Session, mock_external_service_dependencies: MetadataServiceDeps + ) -> None: """ Test successful metadata name update with valid parameters. """ @@ -278,17 +281,13 @@ class TestMetadataService: db_session_with_containers, mock_external_service_dependencies, account, tenant ) - # Setup mocks - mock_external_service_dependencies["current_user"].current_tenant_id = tenant.id - mock_external_service_dependencies["current_user"].id = account.id - # Create metadata first - metadata_args = MetadataArgs(type=DatasetMetadataType.STRING, name="old_name") - metadata = MetadataService.create_metadata(dataset.id, metadata_args) + metadata_args = MetadataArgs(type="string", name="old_name") + metadata = MetadataService.create_metadata(dataset.id, metadata_args, account, tenant.id) # Act: Execute the method under test new_name = "new_name" - result = MetadataService.update_metadata_name(dataset.id, metadata.id, new_name) + result = MetadataService.update_metadata_name(dataset.id, metadata.id, new_name, account, tenant.id) # Assert: Verify the expected outcomes assert result is not None @@ -302,8 +301,8 @@ class TestMetadataService: assert result.name == new_name def test_update_metadata_name_too_long( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, db_session_with_containers: Session, mock_external_service_dependencies: MetadataServiceDeps + ) -> None: """ Test metadata name update fails when new name exceeds 255 characters. """ @@ -315,24 +314,20 @@ class TestMetadataService: db_session_with_containers, mock_external_service_dependencies, account, tenant ) - # Setup mocks - mock_external_service_dependencies["current_user"].current_tenant_id = tenant.id - mock_external_service_dependencies["current_user"].id = account.id - # Create metadata first - metadata_args = MetadataArgs(type=DatasetMetadataType.STRING, name="old_name") - metadata = MetadataService.create_metadata(dataset.id, metadata_args) + metadata_args = MetadataArgs(type="string", name="old_name") + metadata = MetadataService.create_metadata(dataset.id, metadata_args, account, tenant.id) # Try to update with too long name long_name = "a" * 256 # 256 characters, exceeding 255 limit # Act & Assert: Verify proper error handling with pytest.raises(ValueError, match="Metadata name cannot exceed 255 characters."): - MetadataService.update_metadata_name(dataset.id, metadata.id, long_name) + MetadataService.update_metadata_name(dataset.id, metadata.id, long_name, account, tenant.id) def test_update_metadata_name_already_exists( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, db_session_with_containers: Session, mock_external_service_dependencies: MetadataServiceDeps + ) -> None: """ Test metadata name update fails when new name already exists in the same dataset. """ @@ -344,24 +339,20 @@ class TestMetadataService: db_session_with_containers, mock_external_service_dependencies, account, tenant ) - # Setup mocks - mock_external_service_dependencies["current_user"].current_tenant_id = tenant.id - mock_external_service_dependencies["current_user"].id = account.id - # Create two metadata entries - first_metadata_args = MetadataArgs(type=DatasetMetadataType.STRING, name="first_metadata") - first_metadata = MetadataService.create_metadata(dataset.id, first_metadata_args) + first_metadata_args = MetadataArgs(type="string", name="first_metadata") + first_metadata = MetadataService.create_metadata(dataset.id, first_metadata_args, account, tenant.id) - second_metadata_args = MetadataArgs(type=DatasetMetadataType.NUMBER, name="second_metadata") - second_metadata = MetadataService.create_metadata(dataset.id, second_metadata_args) + second_metadata_args = MetadataArgs(type="number", name="second_metadata") + second_metadata = MetadataService.create_metadata(dataset.id, second_metadata_args, account, tenant.id) # Try to update first metadata with second metadata's name with pytest.raises(ValueError, match="Metadata name already exists."): - MetadataService.update_metadata_name(dataset.id, first_metadata.id, "second_metadata") + MetadataService.update_metadata_name(dataset.id, first_metadata.id, "second_metadata", account, tenant.id) def test_update_metadata_name_conflicts_with_built_in_field( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, db_session_with_containers: Session, mock_external_service_dependencies: MetadataServiceDeps + ) -> None: """ Test metadata name update fails when new name conflicts with built-in field names. """ @@ -373,23 +364,19 @@ class TestMetadataService: db_session_with_containers, mock_external_service_dependencies, account, tenant ) - # Setup mocks - mock_external_service_dependencies["current_user"].current_tenant_id = tenant.id - mock_external_service_dependencies["current_user"].id = account.id - # Create metadata first - metadata_args = MetadataArgs(type=DatasetMetadataType.STRING, name="old_name") - metadata = MetadataService.create_metadata(dataset.id, metadata_args) + metadata_args = MetadataArgs(type="string", name="old_name") + metadata = MetadataService.create_metadata(dataset.id, metadata_args, account, tenant.id) # Try to update with built-in field name built_in_field_name = BuiltInField.document_name with pytest.raises(ValueError, match="Metadata name already exists in Built-in fields."): - MetadataService.update_metadata_name(dataset.id, metadata.id, built_in_field_name) + MetadataService.update_metadata_name(dataset.id, metadata.id, built_in_field_name, account, tenant.id) def test_update_metadata_name_not_found( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, db_session_with_containers: Session, mock_external_service_dependencies: MetadataServiceDeps + ) -> None: """ Test metadata name update fails when metadata ID does not exist. """ @@ -401,10 +388,6 @@ class TestMetadataService: db_session_with_containers, mock_external_service_dependencies, account, tenant ) - # Setup mocks - mock_external_service_dependencies["current_user"].current_tenant_id = tenant.id - mock_external_service_dependencies["current_user"].id = account.id - # Try to update non-existent metadata import uuid @@ -412,12 +395,14 @@ class TestMetadataService: new_name = "new_name" # Act: Execute the method under test - result = MetadataService.update_metadata_name(dataset.id, fake_metadata_id, new_name) + result = MetadataService.update_metadata_name(dataset.id, fake_metadata_id, new_name, account, tenant.id) # Assert: Verify the method returns None when metadata is not found assert result is None - def test_delete_metadata_success(self, db_session_with_containers: Session, mock_external_service_dependencies): + def test_delete_metadata_success( + self, db_session_with_containers: Session, mock_external_service_dependencies: MetadataServiceDeps + ) -> None: """ Test successful metadata deletion with valid parameters. """ @@ -429,13 +414,9 @@ class TestMetadataService: db_session_with_containers, mock_external_service_dependencies, account, tenant ) - # Setup mocks - mock_external_service_dependencies["current_user"].current_tenant_id = tenant.id - mock_external_service_dependencies["current_user"].id = account.id - # Create metadata first - metadata_args = MetadataArgs(type=DatasetMetadataType.STRING, name="to_be_deleted") - metadata = MetadataService.create_metadata(dataset.id, metadata_args) + metadata_args = MetadataArgs(type="string", name="to_be_deleted") + metadata = MetadataService.create_metadata(dataset.id, metadata_args, account, tenant.id) # Act: Execute the method under test result = MetadataService.delete_metadata(dataset.id, metadata.id) @@ -449,7 +430,9 @@ class TestMetadataService: deleted_metadata = db_session_with_containers.query(DatasetMetadata).filter_by(id=metadata.id).first() assert deleted_metadata is None - def test_delete_metadata_not_found(self, db_session_with_containers: Session, mock_external_service_dependencies): + def test_delete_metadata_not_found( + self, db_session_with_containers: Session, mock_external_service_dependencies: MetadataServiceDeps + ) -> None: """ Test metadata deletion fails when metadata ID does not exist. """ @@ -461,10 +444,6 @@ class TestMetadataService: db_session_with_containers, mock_external_service_dependencies, account, tenant ) - # Setup mocks - mock_external_service_dependencies["current_user"].current_tenant_id = tenant.id - mock_external_service_dependencies["current_user"].id = account.id - # Try to delete non-existent metadata import uuid @@ -477,8 +456,8 @@ class TestMetadataService: assert result is None def test_delete_metadata_with_document_bindings( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, db_session_with_containers: Session, mock_external_service_dependencies: MetadataServiceDeps + ) -> None: """ Test metadata deletion successfully removes document metadata bindings. """ @@ -493,13 +472,9 @@ class TestMetadataService: db_session_with_containers, mock_external_service_dependencies, dataset, account ) - # Setup mocks - mock_external_service_dependencies["current_user"].current_tenant_id = tenant.id - mock_external_service_dependencies["current_user"].id = account.id - # Create metadata - metadata_args = MetadataArgs(type=DatasetMetadataType.STRING, name="test_metadata") - metadata = MetadataService.create_metadata(dataset.id, metadata_args) + metadata_args = MetadataArgs(type="string", name="test_metadata") + metadata = MetadataService.create_metadata(dataset.id, metadata_args, account, tenant.id) # Create metadata binding binding = DatasetMetadataBinding( @@ -531,7 +506,9 @@ class TestMetadataService: # Note: The service attempts to update document metadata but may not succeed # due to mock configuration. The main functionality (metadata deletion) is verified. - def test_get_built_in_fields_success(self, db_session_with_containers: Session, mock_external_service_dependencies): + def test_get_built_in_fields_success( + self, db_session_with_containers: Session, mock_external_service_dependencies: MetadataServiceDeps + ) -> None: """ Test successful retrieval of built-in metadata fields. """ @@ -557,8 +534,8 @@ class TestMetadataService: assert "time" in field_types def test_enable_built_in_field_success( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, db_session_with_containers: Session, mock_external_service_dependencies: MetadataServiceDeps + ) -> None: """ Test successful enabling of built-in fields for a dataset. """ @@ -573,10 +550,6 @@ class TestMetadataService: db_session_with_containers, mock_external_service_dependencies, dataset, account ) - # Setup mocks - mock_external_service_dependencies["current_user"].current_tenant_id = tenant.id - mock_external_service_dependencies["current_user"].id = account.id - # Mock DocumentService.get_working_documents_by_dataset_id mock_external_service_dependencies["document_service"].get_working_documents_by_dataset_id.return_value = [ document @@ -591,14 +564,14 @@ class TestMetadataService: # Assert: Verify the expected outcomes db_session_with_containers.refresh(dataset) - assert dataset.built_in_field_enabled is True + assert dataset.built_in_field_enabled # Note: Document metadata update depends on DocumentService mock working correctly # The main functionality (enabling built-in fields) is verified def test_enable_built_in_field_already_enabled( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, db_session_with_containers: Session, mock_external_service_dependencies: MetadataServiceDeps + ) -> None: """ Test enabling built-in fields when they are already enabled. """ @@ -610,10 +583,6 @@ class TestMetadataService: db_session_with_containers, mock_external_service_dependencies, account, tenant ) - # Setup mocks - mock_external_service_dependencies["current_user"].current_tenant_id = tenant.id - mock_external_service_dependencies["current_user"].id = account.id - # Enable built-in fields first dataset.built_in_field_enabled = True @@ -621,7 +590,9 @@ class TestMetadataService: db_session_with_containers.commit() # Mock DocumentService.get_working_documents_by_dataset_id - mock_external_service_dependencies["document_service"].get_working_documents_by_dataset_id.return_value = [] + mock_external_service_dependencies["document_service"].get_working_documents_by_dataset_id.return_value = list[ + Document + ]() # Act: Execute the method under test MetadataService.enable_built_in_field(dataset) @@ -631,8 +602,8 @@ class TestMetadataService: assert dataset.built_in_field_enabled is True def test_enable_built_in_field_with_no_documents( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, db_session_with_containers: Session, mock_external_service_dependencies: MetadataServiceDeps + ) -> None: """ Test enabling built-in fields for a dataset with no documents. """ @@ -644,12 +615,10 @@ class TestMetadataService: db_session_with_containers, mock_external_service_dependencies, account, tenant ) - # Setup mocks - mock_external_service_dependencies["current_user"].current_tenant_id = tenant.id - mock_external_service_dependencies["current_user"].id = account.id - # Mock DocumentService.get_working_documents_by_dataset_id to return empty list - mock_external_service_dependencies["document_service"].get_working_documents_by_dataset_id.return_value = [] + mock_external_service_dependencies["document_service"].get_working_documents_by_dataset_id.return_value = list[ + Document + ]() # Act: Execute the method under test MetadataService.enable_built_in_field(dataset) @@ -657,11 +626,11 @@ class TestMetadataService: # Assert: Verify the expected outcomes db_session_with_containers.refresh(dataset) - assert dataset.built_in_field_enabled is True + assert dataset.built_in_field_enabled def test_disable_built_in_field_success( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, db_session_with_containers: Session, mock_external_service_dependencies: MetadataServiceDeps + ) -> None: """ Test successful disabling of built-in fields for a dataset. """ @@ -676,10 +645,6 @@ class TestMetadataService: db_session_with_containers, mock_external_service_dependencies, dataset, account ) - # Setup mocks - mock_external_service_dependencies["current_user"].current_tenant_id = tenant.id - mock_external_service_dependencies["current_user"].id = account.id - # Enable built-in fields first dataset.built_in_field_enabled = True @@ -713,8 +678,8 @@ class TestMetadataService: # The main functionality (disabling built-in fields) is verified def test_disable_built_in_field_already_disabled( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, db_session_with_containers: Session, mock_external_service_dependencies: MetadataServiceDeps + ) -> None: """ Test disabling built-in fields when they are already disabled. """ @@ -726,15 +691,13 @@ class TestMetadataService: db_session_with_containers, mock_external_service_dependencies, account, tenant ) - # Setup mocks - mock_external_service_dependencies["current_user"].current_tenant_id = tenant.id - mock_external_service_dependencies["current_user"].id = account.id - # Verify dataset starts with built-in fields disabled assert dataset.built_in_field_enabled is False # Mock DocumentService.get_working_documents_by_dataset_id - mock_external_service_dependencies["document_service"].get_working_documents_by_dataset_id.return_value = [] + mock_external_service_dependencies["document_service"].get_working_documents_by_dataset_id.return_value = list[ + Document + ]() # Act: Execute the method under test MetadataService.disable_built_in_field(dataset) @@ -742,11 +705,11 @@ class TestMetadataService: # Assert: Verify the method returns early without changes db_session_with_containers.refresh(dataset) - assert dataset.built_in_field_enabled is False + assert not dataset.built_in_field_enabled def test_disable_built_in_field_with_no_documents( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, db_session_with_containers: Session, mock_external_service_dependencies: MetadataServiceDeps + ) -> None: """ Test disabling built-in fields for a dataset with no documents. """ @@ -758,10 +721,6 @@ class TestMetadataService: db_session_with_containers, mock_external_service_dependencies, account, tenant ) - # Setup mocks - mock_external_service_dependencies["current_user"].current_tenant_id = tenant.id - mock_external_service_dependencies["current_user"].id = account.id - # Enable built-in fields first dataset.built_in_field_enabled = True @@ -769,7 +728,9 @@ class TestMetadataService: db_session_with_containers.commit() # Mock DocumentService.get_working_documents_by_dataset_id to return empty list - mock_external_service_dependencies["document_service"].get_working_documents_by_dataset_id.return_value = [] + mock_external_service_dependencies["document_service"].get_working_documents_by_dataset_id.return_value = list[ + Document + ]() # Act: Execute the method under test MetadataService.disable_built_in_field(dataset) @@ -779,8 +740,8 @@ class TestMetadataService: assert dataset.built_in_field_enabled is False def test_update_documents_metadata_success( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, db_session_with_containers: Session, mock_external_service_dependencies: MetadataServiceDeps + ) -> None: """ Test successful update of documents metadata. """ @@ -795,13 +756,9 @@ class TestMetadataService: db_session_with_containers, mock_external_service_dependencies, dataset, account ) - # Setup mocks - mock_external_service_dependencies["current_user"].current_tenant_id = tenant.id - mock_external_service_dependencies["current_user"].id = account.id - # Create metadata - metadata_args = MetadataArgs(type=DatasetMetadataType.STRING, name="test_metadata") - metadata = MetadataService.create_metadata(dataset.id, metadata_args) + metadata_args = MetadataArgs(type="string", name="test_metadata") + metadata = MetadataService.create_metadata(dataset.id, metadata_args, account, tenant.id) # Mock DocumentService.get_document mock_external_service_dependencies["document_service"].get_document.return_value = document @@ -820,7 +777,7 @@ class TestMetadataService: operation_data = MetadataOperationData(operation_data=[operation]) # Act: Execute the method under test - MetadataService.update_documents_metadata(dataset, operation_data) + MetadataService.update_documents_metadata(dataset, operation_data, account) # Assert: Verify the expected outcomes @@ -841,8 +798,8 @@ class TestMetadataService: assert binding.dataset_id == dataset.id def test_update_documents_metadata_with_built_in_fields_enabled( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, db_session_with_containers: Session, mock_external_service_dependencies: MetadataServiceDeps + ) -> None: """ Test update of documents metadata when built-in fields are enabled. """ @@ -863,13 +820,9 @@ class TestMetadataService: db_session_with_containers.add(dataset) db_session_with_containers.commit() - # Setup mocks - mock_external_service_dependencies["current_user"].current_tenant_id = tenant.id - mock_external_service_dependencies["current_user"].id = account.id - # Create metadata - metadata_args = MetadataArgs(type=DatasetMetadataType.STRING, name="test_metadata") - metadata = MetadataService.create_metadata(dataset.id, metadata_args) + metadata_args = MetadataArgs(type="string", name="test_metadata") + metadata = MetadataService.create_metadata(dataset.id, metadata_args, account, tenant.id) # Mock DocumentService.get_document mock_external_service_dependencies["document_service"].get_document.return_value = document @@ -888,7 +841,7 @@ class TestMetadataService: operation_data = MetadataOperationData(operation_data=[operation]) # Act: Execute the method under test - MetadataService.update_documents_metadata(dataset, operation_data) + MetadataService.update_documents_metadata(dataset, operation_data, account) # Assert: Verify the expected outcomes # Verify document metadata was updated with both custom and built-in fields @@ -901,8 +854,8 @@ class TestMetadataService: # The main functionality (custom metadata update) is verified def test_update_documents_metadata_document_not_found( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, db_session_with_containers: Session, mock_external_service_dependencies: MetadataServiceDeps + ) -> None: """ Test update of documents metadata when document is not found. """ @@ -914,13 +867,9 @@ class TestMetadataService: db_session_with_containers, mock_external_service_dependencies, account, tenant ) - # Setup mocks - mock_external_service_dependencies["current_user"].current_tenant_id = tenant.id - mock_external_service_dependencies["current_user"].id = account.id - # Create metadata - metadata_args = MetadataArgs(type=DatasetMetadataType.STRING, name="test_metadata") - metadata = MetadataService.create_metadata(dataset.id, metadata_args) + metadata_args = MetadataArgs(type="string", name="test_metadata") + metadata = MetadataService.create_metadata(dataset.id, metadata_args, account, tenant.id) # Create metadata operation data from services.entities.knowledge_entities.knowledge_entities import ( @@ -941,11 +890,11 @@ class TestMetadataService: # Act & Assert: The method should raise ValueError("Document not found.") # because the exception is now re-raised after rollback with pytest.raises(ValueError, match="Document not found"): - MetadataService.update_documents_metadata(dataset, operation_data) + MetadataService.update_documents_metadata(dataset, operation_data, account) def test_knowledge_base_metadata_lock_check_dataset_id( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, db_session_with_containers: Session, mock_external_service_dependencies: MetadataServiceDeps + ) -> None: """ Test metadata lock check for dataset operations. """ @@ -967,8 +916,8 @@ class TestMetadataService: assert call_args[0][0] == f"dataset_metadata_lock_{dataset_id}" def test_knowledge_base_metadata_lock_check_document_id( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, db_session_with_containers: Session, mock_external_service_dependencies: MetadataServiceDeps + ) -> None: """ Test metadata lock check for document operations. """ @@ -990,8 +939,8 @@ class TestMetadataService: assert call_args[0][0] == f"document_metadata_lock_{document_id}" def test_knowledge_base_metadata_lock_check_lock_exists( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, db_session_with_containers: Session, mock_external_service_dependencies: MetadataServiceDeps + ) -> None: """ Test metadata lock check when lock already exists. """ @@ -1007,8 +956,8 @@ class TestMetadataService: MetadataService.knowledge_base_metadata_lock_check(dataset_id, None) def test_knowledge_base_metadata_lock_check_document_lock_exists( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, db_session_with_containers: Session, mock_external_service_dependencies: MetadataServiceDeps + ) -> None: """ Test metadata lock check when document lock already exists. """ @@ -1022,8 +971,8 @@ class TestMetadataService: MetadataService.knowledge_base_metadata_lock_check(None, document_id) def test_get_dataset_metadatas_success( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, db_session_with_containers: Session, mock_external_service_dependencies: MetadataServiceDeps + ) -> None: """ Test successful retrieval of dataset metadata information. """ @@ -1035,13 +984,9 @@ class TestMetadataService: db_session_with_containers, mock_external_service_dependencies, account, tenant ) - # Setup mocks - mock_external_service_dependencies["current_user"].current_tenant_id = tenant.id - mock_external_service_dependencies["current_user"].id = account.id - # Create metadata - metadata_args = MetadataArgs(type=DatasetMetadataType.STRING, name="test_metadata") - metadata = MetadataService.create_metadata(dataset.id, metadata_args) + metadata_args = MetadataArgs(type="string", name="test_metadata") + metadata = MetadataService.create_metadata(dataset.id, metadata_args, account, tenant.id) # Create document and metadata binding document = self._create_test_document( @@ -1079,8 +1024,8 @@ class TestMetadataService: assert result["built_in_field_enabled"] is False def test_get_dataset_metadatas_with_built_in_fields_enabled( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, db_session_with_containers: Session, mock_external_service_dependencies: MetadataServiceDeps + ) -> None: """ Test retrieval of dataset metadata when built-in fields are enabled. """ @@ -1098,13 +1043,9 @@ class TestMetadataService: db_session_with_containers.add(dataset) db_session_with_containers.commit() - # Setup mocks - mock_external_service_dependencies["current_user"].current_tenant_id = tenant.id - mock_external_service_dependencies["current_user"].id = account.id - # Create metadata - metadata_args = MetadataArgs(type=DatasetMetadataType.STRING, name="test_metadata") - metadata = MetadataService.create_metadata(dataset.id, metadata_args) + metadata_args = MetadataArgs(type="string", name="test_metadata") + metadata = MetadataService.create_metadata(dataset.id, metadata_args, account, tenant.id) # Act: Execute the method under test result = MetadataService.get_dataset_metadatas(dataset) @@ -1122,8 +1063,8 @@ class TestMetadataService: assert result["built_in_field_enabled"] is True def test_get_dataset_metadatas_no_metadata( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): + self, db_session_with_containers: Session, mock_external_service_dependencies: MetadataServiceDeps + ) -> None: """ Test retrieval of dataset metadata when no metadata exists. """ diff --git a/api/tests/test_containers_integration_tests/services/test_recommended_app_service.py b/api/tests/test_containers_integration_tests/services/test_recommended_app_service.py index ac556a6c791..750e35843be 100644 --- a/api/tests/test_containers_integration_tests/services/test_recommended_app_service.py +++ b/api/tests/test_containers_integration_tests/services/test_recommended_app_service.py @@ -2,24 +2,52 @@ from __future__ import annotations import uuid from types import SimpleNamespace -from typing import Any, cast +from typing import TypedDict, Unpack, cast from unittest.mock import MagicMock, patch import pytest from sqlalchemy import select from sqlalchemy.orm import Session +from extensions.ext_database import db from models.model import AccountTrialAppRecord, TrialApp from services import recommended_app_service as service_module from services.recommended_app_service import RecommendedAppService + +class RecommendedAppPayload(TypedDict, total=False): + id: str + app_id: str + name: str + description: str + category: str + icon: str + model_config: object + workflows: list[str] + tools: list[str] + can_trial: bool + + +class AppsResponse(TypedDict): + recommended_apps: list[RecommendedAppPayload] | None + categories: list[str] + + +class AppDetailKwargs(TypedDict, total=False): + category: str + icon: str + model_config: object + workflows: list[str] + tools: list[str] + + # ── Helpers ──────────────────────────────────────────────────────────── def _apps_response( - recommended_apps: list[dict] | None = None, + recommended_apps: list[RecommendedAppPayload] | None = None, categories: list[str] | None = None, -) -> dict: +) -> AppsResponse: if recommended_apps is None: recommended_apps = [ {"id": "app-1", "name": "Test App 1", "description": "d1", "category": "productivity"}, @@ -34,30 +62,26 @@ def _app_detail( app_id: str = "app-123", name: str = "Test App", description: str = "Test description", - **kwargs: Any, -) -> dict: - detail: dict[str, Any] = { - "id": app_id, - "name": name, - "description": description, - "category": kwargs.get("category", "productivity"), - "icon": kwargs.get("icon", "🚀"), - "model_config": kwargs.get("model_config", {}), - } - detail.update(kwargs) + **kwargs: Unpack[AppDetailKwargs], +) -> RecommendedAppPayload: + detail = RecommendedAppPayload( + id=app_id, + name=name, + description=description, + category=kwargs.get("category", "productivity"), + icon=kwargs.get("icon", "🚀"), + model_config=kwargs.get("model_config", {}), + ) + detail.update(**kwargs) return detail -def _recommendation_detail(result: dict[str, Any] | None) -> dict[str, Any] | None: - return cast("dict[str, Any] | None", result) - - def _mock_factory_for_apps( monkeypatch: pytest.MonkeyPatch, *, mode: str, - result: dict[str, Any], - fallback_result: dict[str, Any] | None = None, + result: AppsResponse, + fallback_result: AppsResponse | None = None, ) -> tuple[MagicMock, MagicMock]: retrieval_instance = MagicMock() retrieval_instance.get_recommended_apps_and_categories.return_value = result @@ -85,7 +109,7 @@ def _mock_factory_for_apps( class TestRecommendedAppServiceGetApps: @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) @patch("services.recommended_app_service.dify_config") - def test_success_with_apps(self, mock_config, mock_factory_class): + def test_success_with_apps(self, mock_config: MagicMock, mock_factory_class: MagicMock) -> None: mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" expected = _apps_response() @@ -94,7 +118,7 @@ class TestRecommendedAppServiceGetApps: mock_factory = MagicMock(return_value=mock_instance) mock_factory_class.get_recommend_app_factory.return_value = mock_factory - result = RecommendedAppService.get_recommended_apps_and_categories("en-US") + result = RecommendedAppService.get_recommended_apps_and_categories(db.session, "en-US") assert result == expected assert len(result["recommended_apps"]) == 2 @@ -104,9 +128,9 @@ class TestRecommendedAppServiceGetApps: @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) @patch("services.recommended_app_service.dify_config") - def test_fallback_to_builtin_when_empty(self, mock_config, mock_factory_class): + def test_fallback_to_builtin_when_empty(self, mock_config: MagicMock, mock_factory_class: MagicMock) -> None: mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" - empty_response = {"recommended_apps": [], "categories": []} + empty_response = AppsResponse(recommended_apps=[], categories=[]) builtin_response = _apps_response( recommended_apps=[{"id": "builtin-1", "name": "Builtin App", "category": "default"}] ) @@ -119,7 +143,7 @@ class TestRecommendedAppServiceGetApps: mock_builtin_instance.fetch_recommended_apps_from_builtin.return_value = builtin_response mock_factory_class.get_buildin_recommend_app_retrieval.return_value = mock_builtin_instance - result = RecommendedAppService.get_recommended_apps_and_categories("zh-CN") + result = RecommendedAppService.get_recommended_apps_and_categories(db.session, "zh-CN") assert result == builtin_response assert result["recommended_apps"][0]["id"] == "builtin-1" @@ -127,9 +151,9 @@ class TestRecommendedAppServiceGetApps: @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) @patch("services.recommended_app_service.dify_config") - def test_fallback_when_none_recommended_apps(self, mock_config, mock_factory_class): + def test_fallback_when_none_recommended_apps(self, mock_config: MagicMock, mock_factory_class: MagicMock) -> None: mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "db" - none_response = {"recommended_apps": None, "categories": ["test"]} + none_response = AppsResponse(recommended_apps=None, categories=["test"]) builtin_response = _apps_response() mock_db_instance = MagicMock() @@ -140,14 +164,14 @@ class TestRecommendedAppServiceGetApps: mock_builtin_instance.fetch_recommended_apps_from_builtin.return_value = builtin_response mock_factory_class.get_buildin_recommend_app_retrieval.return_value = mock_builtin_instance - result = RecommendedAppService.get_recommended_apps_and_categories("en-US") + result = RecommendedAppService.get_recommended_apps_and_categories(db.session, "en-US") assert result == builtin_response mock_builtin_instance.fetch_recommended_apps_from_builtin.assert_called_once() @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) @patch("services.recommended_app_service.dify_config") - def test_different_languages(self, mock_config, mock_factory_class): + def test_different_languages(self, mock_config: MagicMock, mock_factory_class: MagicMock) -> None: mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "builtin" for language in ["en-US", "zh-CN", "ja-JP", "fr-FR"]: @@ -158,14 +182,14 @@ class TestRecommendedAppServiceGetApps: mock_instance.get_recommended_apps_and_categories.return_value = lang_response mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance) - result = RecommendedAppService.get_recommended_apps_and_categories(language) + result = RecommendedAppService.get_recommended_apps_and_categories(db.session, language) assert result["recommended_apps"][0]["id"] == f"app-{language}" mock_instance.get_recommended_apps_and_categories.assert_called_with(language) @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) @patch("services.recommended_app_service.dify_config") - def test_uses_correct_factory_mode(self, mock_config, mock_factory_class): + def test_uses_correct_factory_mode(self, mock_config: MagicMock, mock_factory_class: MagicMock) -> None: for mode in ["remote", "builtin", "db"]: mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = mode response = _apps_response() @@ -173,7 +197,7 @@ class TestRecommendedAppServiceGetApps: mock_instance.get_recommended_apps_and_categories.return_value = response mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance) - RecommendedAppService.get_recommended_apps_and_categories("en-US") + RecommendedAppService.get_recommended_apps_and_categories(db.session, "en-US") mock_factory_class.get_recommend_app_factory.assert_called_with(mode) @@ -182,25 +206,49 @@ class TestRecommendedAppServiceGetApps: class TestRecommendedAppServiceGetDetail: + @patch("services.recommended_app_service.FeatureService", autospec=True) @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) @patch("services.recommended_app_service.dify_config") - def test_success(self, mock_config, mock_factory_class): + def test_returns_retrieval_detail_when_trial_disabled( + self, mock_config: MagicMock, mock_factory_class: MagicMock, mock_feature_service: MagicMock + ) -> None: mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" - expected = _app_detail(app_id="app-123", name="Productivity App", description="A great app") + mock_feature_service.get_system_features.return_value = SimpleNamespace(enable_trial_app=False) + cases: list[tuple[str, RecommendedAppPayload]] = [ + ( + "complex-app", + _app_detail( + app_id="complex-app", + name="Complex App", + model_config={ + "provider": "openai", + "model": "gpt-4", + "parameters": {"temperature": 0.7, "max_tokens": 2000, "top_p": 1.0}, + }, + workflows=["workflow-1", "workflow-2"], + tools=["tool-1", "tool-2", "tool-3"], + ), + ), + ("app-empty", RecommendedAppPayload()), + ] - mock_instance = MagicMock() - mock_instance.get_recommend_app_detail.return_value = expected - mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance) + for app_id, expected in cases: + mock_instance = MagicMock() + mock_instance.get_recommend_app_detail.return_value = expected + mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance) - result = _recommendation_detail(RecommendedAppService.get_recommend_app_detail("app-123")) + result = RecommendedAppService.get_recommend_app_detail(db.session, app_id) - assert result == expected - assert result["id"] == "app-123" - mock_instance.get_recommend_app_detail.assert_called_once_with("app-123") + assert result == expected + mock_instance.get_recommend_app_detail.assert_called_once_with(app_id) + @patch("services.recommended_app_service.FeatureService", autospec=True) @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) @patch("services.recommended_app_service.dify_config") - def test_different_modes(self, mock_config, mock_factory_class): + def test_different_modes( + self, mock_config: MagicMock, mock_factory_class: MagicMock, mock_feature_service: MagicMock + ) -> None: + mock_feature_service.get_system_features.return_value = SimpleNamespace(enable_trial_app=False) for mode in ["remote", "builtin", "db"]: mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = mode detail = _app_detail(app_id="test-app", name=f"App from {mode}") @@ -208,71 +256,67 @@ class TestRecommendedAppServiceGetDetail: mock_instance.get_recommend_app_detail.return_value = detail mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance) - result = _recommendation_detail(RecommendedAppService.get_recommend_app_detail("test-app")) + result = RecommendedAppService.get_recommend_app_detail(db.session, "test-app") - assert result["name"] == f"App from {mode}" + assert result is not None + mock_instance.get_recommend_app_detail.assert_called_with("test-app") mock_factory_class.get_recommend_app_factory.assert_called_with(mode) - @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) - @patch("services.recommended_app_service.dify_config") - def test_returns_none_when_not_found(self, mock_config, mock_factory_class): - mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" - mock_instance = MagicMock() - mock_instance.get_recommend_app_detail.return_value = None - mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance) - result = _recommendation_detail(RecommendedAppService.get_recommend_app_detail("nonexistent")) +# ── Pure logic tests: get_learn_dify_apps ────────────────────────────── - assert result is None - mock_instance.get_recommend_app_detail.assert_called_once_with("nonexistent") - @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) - @patch("services.recommended_app_service.dify_config") - def test_returns_empty_dict(self, mock_config, mock_factory_class): - mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "builtin" - mock_instance = MagicMock() - mock_instance.get_recommend_app_detail.return_value = {} - mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance) - - result = _recommendation_detail(RecommendedAppService.get_recommend_app_detail("app-empty")) - - assert result == {} - - @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) - @patch("services.recommended_app_service.dify_config") - def test_complex_model_config(self, mock_config, mock_factory_class): - mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" - complex_config = { - "provider": "openai", - "model": "gpt-4", - "parameters": {"temperature": 0.7, "max_tokens": 2000, "top_p": 1.0}, +class TestRecommendedAppServiceGetLearnDifyApps: + def test_returns_database_learn_dify_apps_without_remote_factory(self, monkeypatch: pytest.MonkeyPatch) -> None: + expected_app = RecommendedAppPayload(app_id="app-1", category="Workflow") + mock_database_retrieval = MagicMock() + mock_database_retrieval.fetch_learn_dify_apps_from_db.return_value = { + "recommended_apps": [expected_app], + "categories": ["Workflow"], } - expected = _app_detail( - app_id="complex-app", - name="Complex App", - model_config=complex_config, - workflows=["workflow-1", "workflow-2"], - tools=["tool-1", "tool-2", "tool-3"], + monkeypatch.setattr(service_module, "DatabaseRecommendAppRetrieval", mock_database_retrieval) + monkeypatch.setattr( + service_module.FeatureService, + "get_system_features", + MagicMock(return_value=SimpleNamespace(enable_trial_app=False)), ) - mock_instance = MagicMock() - mock_instance.get_recommend_app_detail.return_value = expected - mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance) + factory_mock = MagicMock() + monkeypatch.setattr(service_module.RecommendAppRetrievalFactory, "get_recommend_app_factory", factory_mock) - result = _recommendation_detail(RecommendedAppService.get_recommend_app_detail("complex-app")) + result = RecommendedAppService.get_learn_dify_apps(db.session, "en-US") - assert result["model_config"] == complex_config - assert len(result["workflows"]) == 2 - assert len(result["tools"]) == 3 + assert result == {"recommended_apps": [expected_app]} + mock_database_retrieval.fetch_learn_dify_apps_from_db.assert_called_once_with("en-US") + factory_mock.assert_not_called() + + def test_sets_can_trial_when_trial_feature_enabled(self, monkeypatch: pytest.MonkeyPatch) -> None: + app = RecommendedAppPayload(app_id="app-1", category="Workflow") + mock_database_retrieval = MagicMock() + mock_database_retrieval.fetch_learn_dify_apps_from_db.return_value = { + "recommended_apps": [app], + "categories": ["Workflow"], + } + monkeypatch.setattr(service_module, "DatabaseRecommendAppRetrieval", mock_database_retrieval) + monkeypatch.setattr( + service_module.FeatureService, + "get_system_features", + MagicMock(return_value=SimpleNamespace(enable_trial_app=True)), + ) + can_trial_mock = MagicMock(return_value=True) + monkeypatch.setattr(RecommendedAppService, "_can_trial_app", can_trial_mock) + + result = RecommendedAppService.get_learn_dify_apps(db.session, "en-US") + + assert result["recommended_apps"][0]["can_trial"] is True + can_trial_mock.assert_called_once_with(db.session, "app-1") # ── Integration tests: trial app features (real DB) ──────────────────── class TestRecommendedAppServiceTrialFeatures: - def test_get_apps_should_not_query_trial_table_when_disabled( - self, db_session_with_containers: Session, monkeypatch: pytest.MonkeyPatch - ): - expected = {"recommended_apps": [{"app_id": "app-1"}], "categories": ["all"]} + def test_get_apps_should_not_query_trial_table_when_disabled(self, monkeypatch: pytest.MonkeyPatch) -> None: + expected = AppsResponse(recommended_apps=[RecommendedAppPayload(app_id="app-1")], categories=["all"]) retrieval_instance, builtin_instance = _mock_factory_for_apps(monkeypatch, mode="remote", result=expected) monkeypatch.setattr( service_module.FeatureService, @@ -280,7 +324,7 @@ class TestRecommendedAppServiceTrialFeatures: MagicMock(return_value=SimpleNamespace(enable_trial_app=False)), ) - result = RecommendedAppService.get_recommended_apps_and_categories("en-US") + result = RecommendedAppService.get_recommended_apps_and_categories(db.session, "en-US") assert result == expected retrieval_instance.get_recommended_apps_and_categories.assert_called_once_with("en-US") @@ -288,7 +332,7 @@ class TestRecommendedAppServiceTrialFeatures: def test_get_apps_should_enrich_can_trial_when_enabled( self, db_session_with_containers: Session, monkeypatch: pytest.MonkeyPatch - ): + ) -> None: app_id_1 = str(uuid.uuid4()) app_id_2 = str(uuid.uuid4()) tenant_id = str(uuid.uuid4()) @@ -297,11 +341,11 @@ class TestRecommendedAppServiceTrialFeatures: db_session_with_containers.add(TrialApp(app_id=app_id_1, tenant_id=tenant_id)) db_session_with_containers.commit() - remote_result = {"recommended_apps": [], "categories": []} - fallback_result = { - "recommended_apps": [{"app_id": app_id_1}, {"app_id": app_id_2}], - "categories": ["all"], - } + remote_result = AppsResponse(recommended_apps=[], categories=[]) + fallback_result = AppsResponse( + recommended_apps=[RecommendedAppPayload(app_id=app_id_1), RecommendedAppPayload(app_id=app_id_2)], + categories=["all"], + ) _, builtin_instance = _mock_factory_for_apps( monkeypatch, mode="remote", result=remote_result, fallback_result=fallback_result ) @@ -311,7 +355,7 @@ class TestRecommendedAppServiceTrialFeatures: MagicMock(return_value=SimpleNamespace(enable_trial_app=True)), ) - result = RecommendedAppService.get_recommended_apps_and_categories("ja-JP") + result = RecommendedAppService.get_recommended_apps_and_categories(db.session, "ja-JP") builtin_instance.fetch_recommended_apps_from_builtin.assert_called_once_with("en-US") assert result["recommended_apps"][0]["can_trial"] is True @@ -323,7 +367,7 @@ class TestRecommendedAppServiceTrialFeatures: db_session_with_containers: Session, monkeypatch: pytest.MonkeyPatch, has_trial_app: bool, - ): + ) -> None: app_id = str(uuid.uuid4()) tenant_id = str(uuid.uuid4()) @@ -331,7 +375,7 @@ class TestRecommendedAppServiceTrialFeatures: db_session_with_containers.add(TrialApp(app_id=app_id, tenant_id=tenant_id)) db_session_with_containers.commit() - detail = {"id": app_id, "name": "Test App"} + detail = RecommendedAppPayload(id=app_id, name="Test App") retrieval_instance = MagicMock() retrieval_instance.get_recommend_app_detail.return_value = detail retrieval_factory = MagicMock(return_value=retrieval_instance) @@ -347,45 +391,41 @@ class TestRecommendedAppServiceTrialFeatures: MagicMock(return_value=SimpleNamespace(enable_trial_app=True)), ) - result = cast(dict[str, Any], RecommendedAppService.get_recommend_app_detail(app_id)) + result = RecommendedAppService.get_recommend_app_detail(db.session, app_id) + assert result is not None + detail_result = cast(RecommendedAppPayload, result) - assert result["id"] == app_id - assert result["can_trial"] is has_trial_app + assert detail_result["id"] == app_id + assert detail_result["can_trial"] is has_trial_app - def test_get_detail_returns_none_when_not_found_and_trial_enabled( + @patch("services.recommended_app_service.FeatureService", autospec=True) + @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) + @patch("services.recommended_app_service.dify_config") + def test_get_detail_returns_none_before_reading_trial_flag( self, - db_session_with_containers: Session, - monkeypatch: pytest.MonkeyPatch, - ): - """Regression: accessing result['id'] when result is None must not crash.""" - retrieval_instance = MagicMock() - retrieval_instance.get_recommend_app_detail.return_value = None - retrieval_factory = MagicMock(return_value=retrieval_instance) - monkeypatch.setattr(service_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "remote", raising=False) - monkeypatch.setattr( - service_module.RecommendAppRetrievalFactory, - "get_recommend_app_factory", - MagicMock(return_value=retrieval_factory), - ) - monkeypatch.setattr( - service_module.FeatureService, - "get_system_features", - MagicMock(return_value=SimpleNamespace(enable_trial_app=True)), - ) + mock_config: MagicMock, + mock_factory_class: MagicMock, + mock_feature_service: MagicMock, + ) -> None: + mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" + mock_instance = MagicMock() + mock_instance.get_recommend_app_detail.return_value = None + mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance) - result = RecommendedAppService.get_recommend_app_detail("nonexistent") + result = RecommendedAppService.get_recommend_app_detail(db.session, "nonexistent") assert result is None - retrieval_instance.get_recommend_app_detail.assert_called_once_with("nonexistent") + mock_instance.get_recommend_app_detail.assert_called_once_with("nonexistent") + mock_feature_service.get_system_features.assert_not_called() - def test_add_trial_app_record_increments_count_for_existing(self, db_session_with_containers: Session): + def test_add_trial_app_record_increments_count_for_existing(self, db_session_with_containers: Session) -> None: app_id = str(uuid.uuid4()) account_id = str(uuid.uuid4()) db_session_with_containers.add(AccountTrialAppRecord(app_id=app_id, account_id=account_id, count=3)) db_session_with_containers.commit() - RecommendedAppService.add_trial_app_record(app_id, account_id) + RecommendedAppService.add_trial_app_record(db.session, app_id, account_id) db_session_with_containers.expire_all() record = db_session_with_containers.scalar( @@ -396,11 +436,11 @@ class TestRecommendedAppServiceTrialFeatures: assert record is not None assert record.count == 4 - def test_add_trial_app_record_creates_new_record(self, db_session_with_containers: Session): + def test_add_trial_app_record_creates_new_record(self, db_session_with_containers: Session) -> None: app_id = str(uuid.uuid4()) account_id = str(uuid.uuid4()) - RecommendedAppService.add_trial_app_record(app_id, account_id) + RecommendedAppService.add_trial_app_record(db.session, app_id, account_id) db_session_with_containers.expire_all() record = db_session_with_containers.scalar( diff --git a/api/tests/test_containers_integration_tests/services/test_tag_service.py b/api/tests/test_containers_integration_tests/services/test_tag_service.py index f4854d1072e..517d5d2ed4c 100644 --- a/api/tests/test_containers_integration_tests/services/test_tag_service.py +++ b/api/tests/test_containers_integration_tests/services/test_tag_service.py @@ -246,7 +246,7 @@ class TestTagService: ) # Act: Execute the method under test - result = TagService.get_tags("knowledge", tenant.id) + result = TagService.get_tags(db_session_with_containers, "knowledge", tenant.id) # Assert: Verify the expected outcomes assert result is not None @@ -299,7 +299,7 @@ class TestTagService: db_session_with_containers.commit() # Act: Execute the method under test with keyword filter - result = TagService.get_tags("app", tenant.id, keyword="development") + result = TagService.get_tags(db_session_with_containers, "app", tenant.id, keyword="development") # Assert: Verify the expected outcomes assert result is not None @@ -310,7 +310,7 @@ class TestTagService: assert "development" in tag_result.name.lower() # Verify no results for non-matching keyword - result_no_match = TagService.get_tags("app", tenant.id, keyword="nonexistent") + result_no_match = TagService.get_tags(db_session_with_containers, "app", tenant.id, keyword="nonexistent") assert len(result_no_match) == 0 def test_get_tags_with_special_characters_in_keyword( @@ -371,22 +371,22 @@ class TestTagService: db_session_with_containers.commit() # Act & Assert: Test 1 - Search with % character - result = TagService.get_tags("app", tenant.id, keyword="50%") + result = TagService.get_tags(db_session_with_containers, "app", tenant.id, keyword="50%") assert len(result) == 1 assert result[0].name == "50% discount" # Test 2 - Search with _ character - result = TagService.get_tags("app", tenant.id, keyword="test_data") + result = TagService.get_tags(db_session_with_containers, "app", tenant.id, keyword="test_data") assert len(result) == 1 assert result[0].name == "test_data_tag" # Test 3 - Search with \ character - result = TagService.get_tags("app", tenant.id, keyword="path\\to\\tag") + result = TagService.get_tags(db_session_with_containers, "app", tenant.id, keyword="path\\to\\tag") assert len(result) == 1 assert result[0].name == "path\\to\\tag" # Test 4 - Search with % should NOT match 100% (verifies escaping works) - result = TagService.get_tags("app", tenant.id, keyword="50%") + result = TagService.get_tags(db_session_with_containers, "app", tenant.id, keyword="50%") assert len(result) == 1 assert all("50%" in item.name for item in result) @@ -405,7 +405,7 @@ class TestTagService: ) # Act: Execute the method under test - result = TagService.get_tags("knowledge", tenant.id) + result = TagService.get_tags(db_session_with_containers, "knowledge", tenant.id) # Assert: Verify the expected outcomes assert result is not None diff --git a/api/tests/test_containers_integration_tests/services/tools/test_tools_transform_service.py b/api/tests/test_containers_integration_tests/services/tools/test_tools_transform_service.py index a174f5d69f5..2cf6bac2b65 100644 --- a/api/tests/test_containers_integration_tests/services/tools/test_tools_transform_service.py +++ b/api/tests/test_containers_integration_tests/services/tools/test_tools_transform_service.py @@ -20,7 +20,7 @@ from core.tools.entities.tool_entities import ( ToolProviderIdentity, ToolProviderType, ) -from models.tools import ApiToolProvider, BuiltinToolProvider, MCPToolProvider, WorkflowToolProvider +from models.tools import ApiToolProvider, WorkflowToolProvider from services.tools.tools_transform_service import ToolTransformService @@ -39,75 +39,6 @@ class TestToolTransformService: "dify_config": mock_dify_config, } - def _create_test_tool_provider( - self, db_session_with_containers: Session, mock_external_service_dependencies, provider_type="api" - ): - """ - Helper method to create a test tool provider for testing. - - Args: - db_session_with_containers: Database session from testcontainers infrastructure - mock_external_service_dependencies: Mock dependencies - provider_type: Type of provider to create - - Returns: - Tool provider instance - """ - fake = Faker() - - if provider_type == "api": - provider = ApiToolProvider( - name=fake.company(), - description=fake.text(max_nb_chars=100), - icon='{"background": "#FF6B6B", "content": "🔧"}', - tenant_id="test_tenant_id", - user_id="test_user_id", - credentials_str='{"auth_type": "api_key_header", "api_key": "test_key"}', - schema="{}", - schema_type_str=ApiProviderSchemaType.OPENAPI, - tools_str="[]", - ) - elif provider_type == "builtin": - provider = BuiltinToolProvider( - name=fake.company(), - tenant_id="test_tenant_id", - user_id="test_user_id", - provider="test_provider", - credential_type="api_key", - encrypted_credentials='{"api_key": "test_key"}', - ) - elif provider_type == "workflow": - provider = WorkflowToolProvider( - name=fake.company(), - description=fake.text(max_nb_chars=100), - icon='{"background": "#FF6B6B", "content": "🔧"}', - tenant_id="test_tenant_id", - user_id="test_user_id", - app_id="test_workflow_id", - label="Test Workflow", - version="1.0.0", - parameter_configuration="[]", - ) - elif provider_type == "mcp": - provider = MCPToolProvider( - name=fake.company(), - icon='{"background": "#FF6B6B", "content": "🔧"}', - tenant_id="test_tenant_id", - user_id="test_user_id", - server_url="https://mcp.example.com", - server_url_hash="test_server_url_hash", - server_identifier="test_server", - tools='[{"name": "test_tool", "description": "Test tool"}]', - authed=True, - ) - else: - raise ValueError(f"Unknown provider type: {provider_type}") - - db_session_with_containers.add(provider) - db_session_with_containers.commit() - - return provider - def test_get_plugin_icon_url_success(self, db_session_with_containers: Session, mock_external_service_dependencies): """ Test successful plugin icon URL generation. diff --git a/api/tests/test_containers_integration_tests/tasks/test_remove_app_and_related_data_task.py b/api/tests/test_containers_integration_tests/tasks/test_remove_app_and_related_data_task.py index 204f5339785..0ec1b87f59c 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_remove_app_and_related_data_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_remove_app_and_related_data_task.py @@ -1,5 +1,6 @@ +import logging import uuid -from unittest.mock import ANY, call, patch +from unittest.mock import call, patch import pytest from sqlalchemy import delete, func, select @@ -146,10 +147,7 @@ class TestDeleteDraftVariablesBatch: assert db_session_with_containers.scalar(select(func.count()).select_from(WorkflowDraftVariable)) == 0 @patch("tasks.remove_app_and_related_data_task._delete_draft_variable_offload_data") - @patch("tasks.remove_app_and_related_data_task.logger") - def test_delete_draft_variables_batch_logs_progress( - self, mock_logger, mock_offload_cleanup, db_session_with_containers - ): + def test_delete_draft_variables_batch_logs_progress(self, mock_offload_cleanup, db_session_with_containers, caplog): """Test that batch deletion logs progress correctly.""" tenant, app = _create_tenant_and_app(db_session_with_containers) offload_data = _create_offload_data(db_session_with_containers, tenant_id=tenant.id, app_id=app.id, count=10) @@ -163,14 +161,15 @@ class TestDeleteDraftVariablesBatch: mock_offload_cleanup.return_value = len(file_id_by_index) - result = delete_draft_variables_batch(app.id, 50) + with caplog.at_level(logging.INFO, logger="tasks.remove_app_and_related_data_task"): + result = delete_draft_variables_batch(app.id, 50) assert result == 30 mock_offload_cleanup.assert_called_once() _, called_file_ids = mock_offload_cleanup.call_args.args assert {str(file_id) for file_id in called_file_ids} == {str(file_id) for file_id in file_id_by_index.values()} - assert mock_logger.info.call_count == 2 - mock_logger.info.assert_any_call(ANY) + info_records = [record for record in caplog.records if record.levelno == logging.INFO] + assert len(info_records) == 2 class TestDeleteDraftVariableOffloadData: @@ -204,10 +203,7 @@ class TestDeleteDraftVariableOffloadData: assert remaining_upload_files_count == 0 @patch("extensions.ext_storage.storage") - @patch("tasks.remove_app_and_related_data_task.logging") - def test_delete_draft_variable_offload_data_storage_failure( - self, mock_logging, mock_storage, db_session_with_containers - ): + def test_delete_draft_variable_offload_data_storage_failure(self, mock_storage, db_session_with_containers, caplog): """Test handling of storage deletion failures.""" tenant, app = _create_tenant_and_app(db_session_with_containers) offload_data = _create_offload_data(db_session_with_containers, tenant_id=tenant.id, app_id=app.id, count=2) @@ -217,11 +213,12 @@ class TestDeleteDraftVariableOffloadData: mock_storage.delete.side_effect = [Exception("Storage error"), None] - with session_factory.create_session() as session, session.begin(): - result = _delete_draft_variable_offload_data(session, file_ids) + with caplog.at_level(logging.ERROR): + with session_factory.create_session() as session, session.begin(): + result = _delete_draft_variable_offload_data(session, file_ids) assert result == 1 - mock_logging.exception.assert_called_once_with("Failed to delete storage object %s", storage_keys[0]) + assert f"Failed to delete storage object {storage_keys[0]}" in caplog.text remaining_var_files_count = db_session_with_containers.scalar( select(func.count()) diff --git a/api/tests/unit_tests/clients/agent_backend/test_cleanup_composition_compositor_integration.py b/api/tests/unit_tests/clients/agent_backend/test_cleanup_composition_compositor_integration.py index d78bfe76536..00e74b1b659 100644 --- a/api/tests/unit_tests/clients/agent_backend/test_cleanup_composition_compositor_integration.py +++ b/api/tests/unit_tests/clients/agent_backend/test_cleanup_composition_compositor_integration.py @@ -20,7 +20,7 @@ from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID from agenton_collections.layers.plain.basic import PromptLayer from agenton_collections.layers.pydantic_ai import PYDANTIC_AI_HISTORY_LAYER_TYPE_ID, PydanticAIHistoryLayer -from clients.agent_backend import AgentBackendRunRequestBuilder, CleanupLayerSpec +from clients.agent_backend import AgentBackendRunRequestBuilder, RuntimeLayerSpec def test_cleanup_request_passes_agenton_snapshot_validation(): @@ -35,12 +35,12 @@ def test_cleanup_request_passes_agenton_snapshot_validation(): # which is purely structural and does not depend on which non-plugin layer # types appear. persisted_specs = [ - CleanupLayerSpec( + RuntimeLayerSpec( name="workflow_node_job_prompt", type=PLAIN_PROMPT_LAYER_TYPE_ID, config={"prefix": "Do the cleanup."}, ), - CleanupLayerSpec(name="history", type=PYDANTIC_AI_HISTORY_LAYER_TYPE_ID), + RuntimeLayerSpec(name="history", type=PYDANTIC_AI_HISTORY_LAYER_TYPE_ID), ] # Saved snapshot still carries the LLM layer entry — cleanup's # ``_filter_snapshot_to_specs`` must drop it so names match. @@ -66,7 +66,7 @@ def test_cleanup_request_passes_agenton_snapshot_validation(): cleanup_request = AgentBackendRunRequestBuilder().build_cleanup_request( session_snapshot=full_snapshot, - composition_layer_specs=persisted_specs, + runtime_layer_specs=persisted_specs, ) # Drive the real agenton compositor through ``from_config`` + ``_create_run`` diff --git a/api/tests/unit_tests/clients/agent_backend/test_event_adapter.py b/api/tests/unit_tests/clients/agent_backend/test_event_adapter.py index 79f7d14d31e..ab88ba19640 100644 --- a/api/tests/unit_tests/clients/agent_backend/test_event_adapter.py +++ b/api/tests/unit_tests/clients/agent_backend/test_event_adapter.py @@ -1,12 +1,12 @@ +import pytest from agenton.compositor import CompositorSessionSnapshot from dify_agent.protocol import ( + DeferredToolCallPayload, PydanticAIStreamRunEvent, RunCancelledEvent, RunCancelledEventData, RunFailedEvent, RunFailedEventData, - RunPausedEvent, - RunPausedEventData, RunStartedEvent, RunSucceededEvent, RunSucceededEventData, @@ -14,11 +14,11 @@ from dify_agent.protocol import ( from pydantic_ai.messages import FinalResultEvent from clients.agent_backend import ( + AgentBackendDeferredToolCallInternalEvent, AgentBackendInternalEventType, AgentBackendRunCancelledInternalEvent, AgentBackendRunEventAdapter, AgentBackendRunFailedInternalEvent, - AgentBackendRunPausedInternalEvent, AgentBackendRunStartedInternalEvent, AgentBackendRunSucceededInternalEvent, AgentBackendStreamInternalEvent, @@ -92,27 +92,102 @@ def test_event_adapter_maps_run_failed_to_failed_result(): ] -def test_event_adapter_maps_run_paused_to_resumable_pause(): +def test_event_adapter_maps_deferred_tool_call_success_to_internal_event(): snapshot = CompositorSessionSnapshot(layers=[]) + deferred_tool_call = DeferredToolCallPayload( + tool_call_id="tool-call-1", + tool_name="ask_human", + args={"question": "Need review"}, + metadata={"layer_type": "dify.ask_human", "schema_version": 1}, + ) adapted = AgentBackendRunEventAdapter().adapt( - RunPausedEvent( + RunSucceededEvent( id="5-0", run_id="run-1", - data=RunPausedEventData(reason="human_handoff", message="Need review", session_snapshot=snapshot), + data=RunSucceededEventData(deferred_tool_call=deferred_tool_call, session_snapshot=snapshot), ) ) assert adapted == [ - AgentBackendRunPausedInternalEvent( + AgentBackendDeferredToolCallInternalEvent( run_id="run-1", source_event_id="5-0", - reason="human_handoff", + deferred_tool_call=deferred_tool_call, message="Need review", session_snapshot=snapshot, ) ] +def test_event_adapter_rejects_deferred_tool_call_success_without_payload(): + snapshot = CompositorSessionSnapshot(layers=[]) + + with pytest.raises(TypeError, match="deferred_tool_call branch is missing payload"): + _ = AgentBackendRunEventAdapter().adapt( + RunSucceededEvent( + id="5-1", + run_id="run-1", + data=RunSucceededEventData(deferred_tool_call=None, session_snapshot=snapshot), + ) + ) + + +def test_event_adapter_uses_deferred_tool_call_title_as_pause_message_fallback(): + snapshot = CompositorSessionSnapshot(layers=[]) + deferred_tool_call = DeferredToolCallPayload( + tool_call_id="tool-call-1", + tool_name="ask_human", + args={"title": "Review required"}, + metadata={}, + ) + + adapted = AgentBackendRunEventAdapter().adapt( + RunSucceededEvent( + id="5-2", + run_id="run-1", + data=RunSucceededEventData(deferred_tool_call=deferred_tool_call, session_snapshot=snapshot), + ) + ) + + assert adapted == [ + AgentBackendDeferredToolCallInternalEvent( + run_id="run-1", + source_event_id="5-2", + deferred_tool_call=deferred_tool_call, + message="Review required", + session_snapshot=snapshot, + ) + ] + + +def test_event_adapter_uses_generic_deferred_tool_call_pause_message_when_args_have_no_label(): + snapshot = CompositorSessionSnapshot(layers=[]) + deferred_tool_call = DeferredToolCallPayload( + tool_call_id="tool-call-1", + tool_name="ask_human", + args={"question": " ", "title": " "}, + metadata={}, + ) + + adapted = AgentBackendRunEventAdapter().adapt( + RunSucceededEvent( + id="5-3", + run_id="run-1", + data=RunSucceededEventData(deferred_tool_call=deferred_tool_call, session_snapshot=snapshot), + ) + ) + + assert adapted == [ + AgentBackendDeferredToolCallInternalEvent( + run_id="run-1", + source_event_id="5-3", + deferred_tool_call=deferred_tool_call, + message="Agent backend requested external input via deferred tool 'ask_human'.", + session_snapshot=snapshot, + ) + ] + + def test_event_adapter_maps_run_cancelled_to_terminal_cancelled(): adapted = AgentBackendRunEventAdapter().adapt( RunCancelledEvent( diff --git a/api/tests/unit_tests/clients/agent_backend/test_fake_client.py b/api/tests/unit_tests/clients/agent_backend/test_fake_client.py index 9b3e206031e..5862117f622 100644 --- a/api/tests/unit_tests/clients/agent_backend/test_fake_client.py +++ b/api/tests/unit_tests/clients/agent_backend/test_fake_client.py @@ -70,18 +70,18 @@ def test_fake_client_cancel_run_returns_cancelled_status(): assert cancelled.status == "cancelled" -def test_fake_client_paused_scenario_returns_paused_status_and_event(): - """The paused scenario exists for HITL-style flows; both ``wait_run`` and - the event stream must report the pause so consumers can branch on it.""" +def test_fake_client_paused_scenario_returns_deferred_tool_call_success_event(): + """The API pause scenario follows the Dify Agent deferred-tool wire shape.""" client = FakeAgentBackendRunClient(scenario=FakeAgentBackendScenario.PAUSED) status = client.wait_run("fake-run-1") events = list(client.stream_events("fake-run-1")) - assert status.status == "paused" + assert status.status == "succeeded" assert status.error is None - assert events[-1].type == "run_paused" - assert events[-1].data.reason == "human_input_required" + assert events[-1].type == "run_succeeded" + assert events[-1].data.deferred_tool_call is not None + assert events[-1].data.deferred_tool_call.tool_name == "ask_human" def test_fake_client_success_wait_run_returns_succeeded_status(): diff --git a/api/tests/unit_tests/clients/agent_backend/test_request_builder.py b/api/tests/unit_tests/clients/agent_backend/test_request_builder.py index 87257475f21..0fa4d3261bc 100644 --- a/api/tests/unit_tests/clients/agent_backend/test_request_builder.py +++ b/api/tests/unit_tests/clients/agent_backend/test_request_builder.py @@ -36,7 +36,8 @@ from clients.agent_backend import ( AgentBackendOutputConfig, AgentBackendRunRequestBuilder, AgentBackendWorkflowNodeRunInput, - CleanupLayerSpec, + RuntimeLayerSpec, + extract_runtime_layer_specs, redact_for_agent_backend_log, ) from clients.agent_backend.request_builder import DIFY_SHELL_LAYER_ID @@ -173,11 +174,11 @@ def test_request_builder_builds_cleanup_request_replays_persisted_layer_specs(): LayerSessionSnapshot(name="llm", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}), ] ) - specs = [CleanupLayerSpec(name="history", type="pydantic_ai.history")] + specs = [RuntimeLayerSpec(name="history", type="pydantic_ai.history")] request = AgentBackendRunRequestBuilder().build_cleanup_request( session_snapshot=session_snapshot, - composition_layer_specs=specs, + runtime_layer_specs=specs, idempotency_key="run-1:node-1:binding-1:agent-session-cleanup", metadata={"workflow_run_id": "run-1"}, ) @@ -190,21 +191,19 @@ def test_request_builder_builds_cleanup_request_replays_persisted_layer_specs(): assert request.metadata["agent_backend_lifecycle"] == "session_cleanup" -def test_request_builder_rejects_empty_composition_layer_specs(): +def test_request_builder_rejects_empty_runtime_layer_specs(): """Empty specs would put us back in the original ``layers=[]`` trap that fails on agenton's snapshot-vs-composition validation.""" - with pytest.raises(ValueError, match="composition_layer_specs"): + with pytest.raises(ValueError, match="runtime_layer_specs"): AgentBackendRunRequestBuilder().build_cleanup_request( session_snapshot=CompositorSessionSnapshot(layers=[]), - composition_layer_specs=[], + runtime_layer_specs=[], ) -def test_extract_cleanup_layer_specs_drops_plugin_layers_keeps_configs(): +def test_extract_runtime_layer_specs_drops_plugin_layers_keeps_configs(): from dify_agent.protocol import RunComposition, RunLayerSpec - from clients.agent_backend import extract_cleanup_layer_specs - composition = RunComposition( layers=[ RunLayerSpec( @@ -228,7 +227,7 @@ def test_extract_cleanup_layer_specs_drops_plugin_layers_keeps_configs(): ] ) - specs = extract_cleanup_layer_specs(composition) + specs = extract_runtime_layer_specs(composition) assert [spec.name for spec in specs] == ["agent_soul_prompt", "history"] # Non-plugin configs are dumped as JSON-compatible dicts so the persisted @@ -304,8 +303,9 @@ def test_workflow_request_builder_adds_shell_layer_when_include_shell(): assert DIFY_SHELL_LAYER_ID in layers shell = layers[DIFY_SHELL_LAYER_ID] assert shell.type == DIFY_SHELL_LAYER_TYPE_ID - # The shell layer declares NoLayerDeps, so the spec must carry no deps. - assert not shell.deps + # The shell layer depends on execution_context so the agent server can mint + # per-command Agent Stub env for sandbox CLI forwarding. + assert shell.deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID} shell_config = cast(DifyShellLayerConfig, shell.config) assert shell_config.env[0].name == "PROJECT_NAME" @@ -324,6 +324,52 @@ def test_agent_app_request_builder_adds_shell_layer_when_include_shell(): assert DIFY_SHELL_LAYER_ID in layers assert layers[DIFY_SHELL_LAYER_ID].type == DIFY_SHELL_LAYER_TYPE_ID - assert not layers[DIFY_SHELL_LAYER_ID].deps + assert layers[DIFY_SHELL_LAYER_ID].deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID} shell_config = cast(DifyShellLayerConfig, layers[DIFY_SHELL_LAYER_ID].config) assert shell_config.env[0].name == "APP_ENV" + + +# ── ENG-635 / ENG-638: ask_human layer injection + deferred_tool_results ───── + + +def test_ask_human_layer_injected_when_configured(): + + from dify_agent.layers.ask_human import DIFY_ASK_HUMAN_LAYER_TYPE_ID, DifyAskHumanLayerConfig + + from clients.agent_backend.request_builder import DIFY_ASK_HUMAN_LAYER_ID + + run_input = _run_input().model_copy(update={"ask_human_config": DifyAskHumanLayerConfig()}) + request = AgentBackendRunRequestBuilder().build_for_workflow_node(run_input) + + layers = {layer.name: layer for layer in request.composition.layers} + assert DIFY_ASK_HUMAN_LAYER_ID in layers + assert layers[DIFY_ASK_HUMAN_LAYER_ID].type == DIFY_ASK_HUMAN_LAYER_TYPE_ID + # the deferred tool needs the history layer to resume, so history must precede it + names = [layer.name for layer in request.composition.layers] + assert names.index(DIFY_AGENT_HISTORY_LAYER_ID) < names.index(DIFY_ASK_HUMAN_LAYER_ID) + + +def test_no_ask_human_layer_when_unconfigured(): + from clients.agent_backend.request_builder import DIFY_ASK_HUMAN_LAYER_ID + + request = AgentBackendRunRequestBuilder().build_for_workflow_node(_run_input()) + assert all(layer.name != DIFY_ASK_HUMAN_LAYER_ID for layer in request.composition.layers) + + +def test_deferred_tool_results_threaded_into_request(): + from dify_agent.protocol import DeferredToolResultsPayload + + payload = DeferredToolResultsPayload( + calls={ + "tool-call-1": { + "status": "submitted", + "action": {"id": "submit", "label": "Submit"}, + "values": {"x": "y"}, + } + } + ) + run_input = _run_input().model_copy(update={"deferred_tool_results": payload}) + request = AgentBackendRunRequestBuilder().build_for_workflow_node(run_input) + + assert request.deferred_tool_results is not None + assert "tool-call-1" in request.deferred_tool_results.calls diff --git a/api/tests/unit_tests/clients/agent_backend/test_workspace_files_client.py b/api/tests/unit_tests/clients/agent_backend/test_workspace_files_client.py deleted file mode 100644 index 65e8ae16454..00000000000 --- a/api/tests/unit_tests/clients/agent_backend/test_workspace_files_client.py +++ /dev/null @@ -1,152 +0,0 @@ -"""Unit tests for the API-side workspace files backend client.""" - -from __future__ import annotations - -import base64 -import json -from collections.abc import Callable - -import httpx -import pytest - -from clients.agent_backend.errors import AgentBackendHTTPError, AgentBackendTransportError -from clients.agent_backend.workspace_files_client import WorkspaceFilesBackendClient - - -def _client(handler: Callable[[httpx.Request], httpx.Response]) -> WorkspaceFilesBackendClient: - return WorkspaceFilesBackendClient("http://backend", transport=httpx.MockTransport(handler)) - - -def test_list_files_parses_entries() -> None: - def handler(request: httpx.Request) -> httpx.Response: - assert request.url.path == "/workspaces/abc1234/files" - assert request.url.params.get("path") == "sub" - return httpx.Response( - 200, - json={ - "path": "sub", - "entries": [{"name": "a.txt", "type": "file", "size": 3, "mtime": 10}], - "truncated": False, - }, - ) - - result = _client(handler).list_files("abc1234", "sub") - - assert result.path == "sub" - assert result.entries[0].name == "a.txt" - assert result.entries[0].type == "file" - assert result.truncated is False - - -def test_preview_parses_text() -> None: - def handler(request: httpx.Request) -> httpx.Response: - assert request.url.path == "/workspaces/abc1234/files/preview" - return httpx.Response( - 200, json={"path": "n.txt", "size": 5, "truncated": False, "binary": False, "text": "hello"} - ) - - result = _client(handler).preview("abc1234", "n.txt") - - assert result.binary is False - assert result.text == "hello" - - -def test_download_decodes_base64_to_bytes() -> None: - raw = bytes(range(64)) - - def handler(request: httpx.Request) -> httpx.Response: - assert request.url.path == "/workspaces/abc1234/files/download" - return httpx.Response( - 200, - json={ - "path": "b.bin", - "size": len(raw), - "truncated": False, - "content_base64": base64.b64encode(raw).decode(), - }, - ) - - result = _client(handler).download("abc1234", "b.bin") - - assert result.content == raw - assert result.size == 64 - - -def test_http_error_preserves_status_and_detail() -> None: - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(404, json={"detail": {"code": "not_found", "message": "path not found in workspace"}}) - - with pytest.raises(AgentBackendHTTPError) as exc_info: - _client(handler).list_files("abc1234", "missing") - - assert exc_info.value.status_code == 404 - assert exc_info.value.detail == {"code": "not_found", "message": "path not found in workspace"} - - -def test_http_error_with_non_json_body_uses_response_text() -> None: - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(500, text="backend exploded") - - with pytest.raises(AgentBackendHTTPError) as exc_info: - _client(handler).preview("abc1234", "note.txt") - - assert exc_info.value.status_code == 500 - assert exc_info.value.detail == "backend exploded" - - -def test_transport_failure_becomes_transport_error() -> None: - def handler(request: httpx.Request) -> httpx.Response: - raise httpx.ConnectError("connection refused") - - with pytest.raises(AgentBackendTransportError): - _client(handler).list_files("abc1234", ".") - - -def test_download_without_content_is_502() -> None: - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json={"path": "b.bin", "size": 0, "truncated": False}) - - with pytest.raises(AgentBackendHTTPError) as exc_info: - _client(handler).download("abc1234", "b.bin") - - assert exc_info.value.status_code == 502 - - -def test_download_with_invalid_base64_is_502() -> None: - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json={"path": "b.bin", "size": 3, "truncated": False, "content_base64": "not-@@@"}) - - with pytest.raises(AgentBackendHTTPError) as exc_info: - _client(handler).download("abc1234", "b.bin") - - assert exc_info.value.status_code == 502 - - -def test_download_uses_decoded_size_when_backend_size_is_invalid() -> None: - raw = b"abc" - - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response( - 200, - json={ - "path": "b.bin", - "size": "unknown", - "truncated": True, - "content_base64": base64.b64encode(raw).decode(), - }, - ) - - result = _client(handler).download("abc1234", "b.bin") - - assert result.size == len(raw) - assert result.truncated is True - - -def test_non_object_body_is_502() -> None: - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, content=json.dumps([1, 2, 3]), headers={"content-type": "application/json"}) - - with pytest.raises(AgentBackendHTTPError) as exc_info: - _client(handler).list_files("abc1234", ".") - - assert exc_info.value.status_code == 502 diff --git a/api/tests/unit_tests/commands/test_generate_swagger_markdown_docs.py b/api/tests/unit_tests/commands/test_generate_swagger_markdown_docs.py index 4da03b2a883..8444af741fb 100644 --- a/api/tests/unit_tests/commands/test_generate_swagger_markdown_docs.py +++ b/api/tests/unit_tests/commands/test_generate_swagger_markdown_docs.py @@ -22,7 +22,7 @@ def _load_generate_swagger_markdown_docs_module(): def test_generate_markdown_docs_keeps_split_docs_and_merges_fastopenapi_into_console(tmp_path, monkeypatch): module = _load_generate_swagger_markdown_docs_module() - swagger_dir = tmp_path / "openapi" + openapi_dir = tmp_path / "openapi" markdown_dir = tmp_path / "markdown" stale_combined_doc = markdown_dir / "api-reference.md" markdown_dir.mkdir() @@ -50,23 +50,23 @@ def test_generate_markdown_docs_keeps_split_docs_and_merges_fastopenapi_into_con monkeypatch.setattr(module, "generate_fastopenapi_specs", write_fastopenapi_specs) monkeypatch.setattr(module, "_convert_spec_to_markdown", convert_spec_to_markdown) - written_paths = module.generate_markdown_docs(swagger_dir, markdown_dir) + written_paths = module.generate_markdown_docs(openapi_dir, markdown_dir) assert [path.name for path in written_paths] == [ - "console-swagger.md", - "web-swagger.md", - "service-swagger.md", - "openapi-swagger.md", + "console-openapi.md", + "web-openapi.md", + "service-openapi.md", + "openapi-openapi.md", ] assert not stale_combined_doc.exists() - assert not list(swagger_dir.glob("*.json")) + assert not list(openapi_dir.glob("*.json")) - console_markdown = (markdown_dir / "console-swagger.md").read_text(encoding="utf-8") - assert "## FastOpenAPI Preview (OpenAPI 3.0)" in console_markdown + console_markdown = (markdown_dir / "console-openapi.md").read_text(encoding="utf-8") + assert "## FastOpenAPI Preview (OpenAPI 3.1)" in console_markdown assert "### fastopenapi-console-openapi" in console_markdown assert "#### Routes" in console_markdown - assert "FastOpenAPI Preview" not in (markdown_dir / "web-swagger.md").read_text(encoding="utf-8") - assert "FastOpenAPI Preview" not in (markdown_dir / "service-swagger.md").read_text(encoding="utf-8") + assert "FastOpenAPI Preview" not in (markdown_dir / "web-openapi.md").read_text(encoding="utf-8") + assert "FastOpenAPI Preview" not in (markdown_dir / "service-openapi.md").read_text(encoding="utf-8") def test_generate_markdown_docs_only_removes_generated_specs_from_separate_swagger_dir(tmp_path, monkeypatch): @@ -107,39 +107,41 @@ def test_generate_markdown_docs_only_removes_generated_specs_from_separate_swagg def test_patch_union_schema_markdown_fills_converter_blank_schema_types(tmp_path): module = _load_generate_swagger_markdown_docs_module() - spec_path = tmp_path / "console-swagger.json" + spec_path = tmp_path / "console-openapi.json" spec_path.write_text( json.dumps( { - "definitions": { - "FormInputConfig": { - "oneOf": [ - {"$ref": "#/definitions/ParagraphInputConfig"}, - {"$ref": "#/definitions/SelectInputConfig"}, - {"$ref": "#/definitions/FileInputConfig"}, - ], - }, - "ParagraphInputConfig": { - "properties": { - "default": { - "anyOf": [ - {"$ref": "#/definitions/StringSource"}, - {"type": "null"}, - ], + "components": { + "schemas": { + "FormInputConfig": { + "oneOf": [ + {"$ref": "#/components/schemas/ParagraphInputConfig"}, + {"$ref": "#/components/schemas/SelectInputConfig"}, + {"$ref": "#/components/schemas/FileInputConfig"}, + ], + }, + "ParagraphInputConfig": { + "properties": { + "default": { + "anyOf": [ + {"$ref": "#/components/schemas/StringSource"}, + {"type": "null"}, + ], + }, + "output_variable_name": {"type": "string"}, }, - "output_variable_name": {"type": "string"}, }, - }, - "SelectInputConfig": { - "properties": { - "option_source": {"$ref": "#/definitions/StringListSource"}, + "SelectInputConfig": { + "properties": { + "option_source": {"$ref": "#/components/schemas/StringListSource"}, + }, }, - }, - "FileInputConfig": { - "properties": { - "allowed_file_types": { - "type": "array", - "items": {"$ref": "#/definitions/FileType"}, + "FileInputConfig": { + "properties": { + "allowed_file_types": { + "type": "array", + "items": {"$ref": "#/components/schemas/FileType"}, + }, }, }, }, @@ -188,24 +190,26 @@ def test_patch_union_schema_markdown_fills_converter_blank_schema_types(tmp_path assert "| allowed_file_types | [ [FileType](#filetype) ] | | No |" in patched -def test_patch_union_schema_markdown_fills_regular_definition_union_property(tmp_path): +def test_patch_union_schema_markdown_fills_regular_schema_union_property(tmp_path): module = _load_generate_swagger_markdown_docs_module() - spec_path = tmp_path / "service-swagger.json" + spec_path = tmp_path / "service-openapi.json" spec_path.write_text( json.dumps( { - "definitions": { - "DocumentMetadataResponse": { - "properties": { - "id": {"type": "string"}, - "value": { - "anyOf": [ - {"type": "string"}, - {"type": "integer"}, - {"type": "number"}, - {"type": "boolean"}, - {"type": "null"}, - ], + "components": { + "schemas": { + "DocumentMetadataResponse": { + "properties": { + "id": {"type": "string"}, + "value": { + "anyOf": [ + {"type": "string"}, + {"type": "integer"}, + {"type": "number"}, + {"type": "boolean"}, + {"type": "null"}, + ], + }, }, }, }, @@ -227,9 +231,9 @@ def test_patch_union_schema_markdown_fills_regular_definition_union_property(tmp assert "| value | string
integer
number
boolean | | No |" in patched -def test_patch_union_schema_markdown_ignores_specs_without_definitions(tmp_path): +def test_patch_union_schema_markdown_ignores_specs_without_schemas(tmp_path): module = _load_generate_swagger_markdown_docs_module() - spec_path = tmp_path / "console-swagger.json" + spec_path = tmp_path / "console-openapi.json" spec_path.write_text("{}", encoding="utf-8") assert module._patch_union_schema_markdown("unchanged", spec_path) == "unchanged" @@ -237,27 +241,29 @@ def test_patch_union_schema_markdown_ignores_specs_without_definitions(tmp_path) def test_patch_union_schema_markdown_ignores_unrenderable_shapes(tmp_path): module = _load_generate_swagger_markdown_docs_module() - spec_path = tmp_path / "console-swagger.json" + spec_path = tmp_path / "console-openapi.json" spec_path.write_text( json.dumps( { - "definitions": { - "NotAMapping": [], - "BrokenUnion": { - "oneOf": [ - {}, - {"$ref": "#/definitions/Missing"}, - {"$ref": "#/definitions/NoPropertyMapping"}, - ], + "components": { + "schemas": { + "NotAMapping": [], + "BrokenUnion": { + "oneOf": [ + {}, + {"$ref": "#/components/schemas/Missing"}, + {"$ref": "#/components/schemas/NoPropertyMapping"}, + ], + }, + "NoPropertyMapping": {"properties": []}, }, - "NoPropertyMapping": {"properties": []}, } } ), encoding="utf-8", ) - assert module._definition_ref_name(None) is None + assert module._schema_ref_name(None) is None assert module._schema_markdown_type(None) == "" assert module._schema_markdown_type({"anyOf": [{"type": "null"}]}) == "" assert module._replace_schema_table_type("unchanged", "Definition", "field", "") == "unchanged" @@ -280,24 +286,26 @@ def test_patch_union_schema_markdown_ignores_unrenderable_shapes(tmp_path): def test_convert_spec_to_markdown_patches_generated_union_tables(tmp_path, monkeypatch): module = _load_generate_swagger_markdown_docs_module() - spec_path = tmp_path / "console-swagger.json" - output_path = tmp_path / "console-swagger.md" + spec_path = tmp_path / "console-openapi.json" + output_path = tmp_path / "console-openapi.md" spec_path.write_text( json.dumps( { - "definitions": { - "FormInputConfig": { - "oneOf": [ - {"$ref": "#/definitions/ParagraphInputConfig"}, - ], - }, - "ParagraphInputConfig": { - "properties": { - "default": { - "anyOf": [ - {"$ref": "#/definitions/StringSource"}, - {"type": "null"}, - ], + "components": { + "schemas": { + "FormInputConfig": { + "oneOf": [ + {"$ref": "#/components/schemas/ParagraphInputConfig"}, + ], + }, + "ParagraphInputConfig": { + "properties": { + "default": { + "anyOf": [ + {"$ref": "#/components/schemas/StringSource"}, + {"type": "null"}, + ], + }, }, }, }, diff --git a/api/tests/unit_tests/commands/test_generate_swagger_specs.py b/api/tests/unit_tests/commands/test_generate_swagger_specs.py index 7b2ed78f56f..03af7643f3c 100644 --- a/api/tests/unit_tests/commands/test_generate_swagger_specs.py +++ b/api/tests/unit_tests/commands/test_generate_swagger_specs.py @@ -1,4 +1,4 @@ -"""Unit tests for the standalone Swagger export helper.""" +"""Unit tests for the standalone OpenAPI export helper.""" import importlib.util import json @@ -30,42 +30,82 @@ def _load_generate_swagger_specs_module(): return module -def test_generate_specs_writes_console_web_and_service_swagger_files(tmp_path): +def _operation_ids(payload): + methods = {"delete", "get", "head", "options", "patch", "post", "put", "trace"} + for path_item in payload["paths"].values(): + for method, operation in path_item.items(): + if method in methods and isinstance(operation, dict) and "operationId" in operation: + yield operation["operationId"] + + +def _get_operations(payload): + for path_item in payload["paths"].values(): + operation = path_item.get("get") + if isinstance(operation, dict): + yield operation + + +def test_generate_specs_writes_console_web_and_service_openapi_files(tmp_path): module = _load_generate_swagger_specs_module() written_paths = module.generate_specs(tmp_path) assert [path.name for path in written_paths] == [ - "console-swagger.json", - "web-swagger.json", - "service-swagger.json", - "openapi-swagger.json", + "console-openapi.json", + "web-openapi.json", + "service-openapi.json", + "openapi-openapi.json", ] for path in written_paths: payload = json.loads(path.read_text(encoding="utf-8")) - assert payload["swagger"] == "2.0" + assert payload["openapi"].startswith("3.") assert "paths" in payload -def test_generate_specs_writes_swagger_with_resolvable_references_and_no_nulls(tmp_path): +def test_generate_specs_writes_openapi_with_resolvable_references_and_no_nulls(tmp_path): module = _load_generate_swagger_specs_module() written_paths = module.generate_specs(tmp_path) for path in written_paths: payload = json.loads(path.read_text(encoding="utf-8")) - definitions = payload["definitions"] + schemas = payload["components"]["schemas"] refs = { - item["$ref"].removeprefix("#/definitions/") + item["$ref"].removeprefix("#/components/schemas/") for item in _walk_values(payload) - if isinstance(item, dict) and isinstance(item.get("$ref"), str) + if isinstance(item, dict) + and isinstance(item.get("$ref"), str) + and item["$ref"].startswith("#/components/schemas/") } - assert refs <= set(definitions) + assert refs <= set(schemas) assert all(value is not None for value in _walk_values(payload)) +def test_generate_specs_writes_unique_operation_ids(tmp_path): + module = _load_generate_swagger_specs_module() + + written_paths = module.generate_specs(tmp_path) + + for path in written_paths: + payload = json.loads(path.read_text(encoding="utf-8")) + operation_ids = list(_operation_ids(payload)) + + assert len(operation_ids) == len(set(operation_ids)) + + +def test_generate_specs_writes_get_operations_without_request_bodies(tmp_path): + module = _load_generate_swagger_specs_module() + + written_paths = module.generate_specs(tmp_path) + + for path in written_paths: + payload = json.loads(path.read_text(encoding="utf-8")) + + assert all("requestBody" not in operation for operation in _get_operations(payload)) + + def test_generate_specs_is_idempotent(tmp_path): module = _load_generate_swagger_specs_module() diff --git a/api/tests/unit_tests/configs/test_nacos_http_client.py b/api/tests/unit_tests/configs/test_nacos_http_client.py new file mode 100644 index 00000000000..855a1a8acc1 --- /dev/null +++ b/api/tests/unit_tests/configs/test_nacos_http_client.py @@ -0,0 +1,51 @@ +from unittest.mock import MagicMock, patch + +import httpx + +from configs.remote_settings_sources.nacos.http_request import NacosHttpClient + + +def _ok_response(text: str = "ok", json_data: dict | None = None) -> MagicMock: + response = MagicMock() + response.text = text + response.raise_for_status.return_value = None + if json_data is not None: + response.json.return_value = json_data + return response + + +def test_http_request_passes_bounded_timeout(): + client = NacosHttpClient() + with patch("configs.remote_settings_sources.nacos.http_request.httpx.request") as mock_request: + mock_request.return_value = _ok_response() + client.http_request("/nacos/v1/cs/configs") + + timeout = mock_request.call_args.kwargs["timeout"] + assert isinstance(timeout, httpx.Timeout) + assert timeout.read is not None + assert timeout.connect is not None + + +def test_http_request_returns_graceful_message_on_timeout(): + client = NacosHttpClient() + with patch( + "configs.remote_settings_sources.nacos.http_request.httpx.request", + side_effect=httpx.ConnectTimeout("connection timed out"), + ): + result = client.http_request("/nacos/v1/cs/configs") + + assert "Nacos" in result + assert "timed out" in result.lower() + + +def test_get_access_token_passes_bounded_timeout(): + client = NacosHttpClient() + client.username = "user" + client.password = "pass" + with patch("configs.remote_settings_sources.nacos.http_request.httpx.request") as mock_request: + mock_request.return_value = _ok_response(json_data={"accessToken": "tok", "tokenTtl": 100}) + token = client.get_access_token(force_refresh=True) + + assert token == "tok" + timeout = mock_request.call_args.kwargs["timeout"] + assert isinstance(timeout, httpx.Timeout) diff --git a/api/tests/unit_tests/controllers/common/test_schema.py b/api/tests/unit_tests/controllers/common/test_schema.py index c5da7093a02..b8d99327872 100644 --- a/api/tests/unit_tests/controllers/common/test_schema.py +++ b/api/tests/unit_tests/controllers/common/test_schema.py @@ -1,4 +1,5 @@ import sys +from datetime import datetime from enum import StrEnum from typing import Literal from unittest.mock import MagicMock, patch @@ -43,6 +44,8 @@ class QueryModel(BaseModel): page: int = Field(default=1, ge=1, le=100, description="Page number") keyword: str | None = Field(default=None, min_length=1, max_length=50, description="Search keyword") status: Literal["active", "inactive"] | None = Field(default=None, description="Status filter") + enum_status: StatusEnum | None = Field(default=None, description="Enum status filter") + created_at: datetime | None = Field(default=None, description="Creation time") app_id: str = Field(..., alias="appId", description="Application ID") tag_ids: list[str] = Field(default_factory=list, min_length=1, max_length=3, description="Tag IDs") ambiguous: int | str | None = Field(default=None, description="Ambiguous query parameter") @@ -78,9 +81,9 @@ def mock_console_ns(): def test_default_ref_template_value(): - from controllers.common.schema import DEFAULT_REF_TEMPLATE_SWAGGER_2_0 + from controllers.common.schema import DEFAULT_REF_TEMPLATE_OPENAPI_3_0 - assert DEFAULT_REF_TEMPLATE_SWAGGER_2_0 == "#/definitions/{model}" + assert DEFAULT_REF_TEMPLATE_OPENAPI_3_0 == "#/components/schemas/{model}" def test_register_schema_model_calls_namespace_schema_model(): @@ -100,7 +103,7 @@ def test_register_schema_model_calls_namespace_schema_model(): def test_register_schema_model_passes_schema_from_pydantic(): - from controllers.common.schema import DEFAULT_REF_TEMPLATE_SWAGGER_2_0, register_schema_model + from controllers.common.schema import DEFAULT_REF_TEMPLATE_OPENAPI_3_0, register_schema_model namespace = MagicMock(spec=Namespace) @@ -108,24 +111,24 @@ def test_register_schema_model_passes_schema_from_pydantic(): schema = namespace.schema_model.call_args.args[1] - expected_schema = UserModel.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) + expected_schema = UserModel.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0) assert schema == expected_schema def test_register_schema_model_promotes_nested_pydantic_definitions(): - from controllers.common.schema import DEFAULT_REF_TEMPLATE_SWAGGER_2_0, register_schema_model + from controllers.common.schema import DEFAULT_REF_TEMPLATE_OPENAPI_3_0, register_schema_model namespace = MagicMock(spec=Namespace) register_schema_model(namespace, ParentModel) called_schemas = {call.args[0]: call.args[1] for call in namespace.schema_model.call_args_list} - parent_schema = ParentModel.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) + parent_schema = ParentModel.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0) assert set(called_schemas) == {"ParentModel", "ChildModel"} assert "$defs" not in called_schemas["ParentModel"] - assert called_schemas["ParentModel"]["properties"]["child"]["$ref"] == "#/definitions/ChildModel" + assert called_schemas["ParentModel"]["properties"]["child"]["$ref"] == "#/components/schemas/ChildModel" assert called_schemas["ChildModel"] == parent_schema["$defs"]["ChildModel"] @@ -179,7 +182,7 @@ def test_register_response_schema_model_uses_serialized_field_names(): assert "internal_name" not in schema["properties"] -def test_register_schema_model_flattens_simple_nullable_any_of_for_swagger_2(): +def test_register_schema_model_preserves_openapi_nullable_unions(): from controllers.common.schema import register_schema_model namespace = MagicMock(spec=Namespace) @@ -189,14 +192,9 @@ def test_register_schema_model_flattens_simple_nullable_any_of_for_swagger_2(): called_schemas = {call.args[0]: call.args[1] for call in namespace.schema_model.call_args_list} properties = called_schemas["NullableSchemaModel"]["properties"] - assert properties["name"]["type"] == "string" - assert properties["name"]["x-nullable"] is True - assert "anyOf" not in properties["name"] - assert properties["tags"]["type"] == "array" - assert properties["tags"]["items"] == {"type": "string"} - assert properties["tags"]["x-nullable"] is True - assert properties["owner"]["$ref"] == "#/definitions/UserModel" - assert properties["owner"]["x-nullable"] is True + assert properties["name"]["anyOf"] == [{"type": "string"}, {"type": "null"}] + assert properties["tags"]["anyOf"] == [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}] + assert properties["owner"]["anyOf"] == [{"$ref": "#/components/schemas/UserModel"}, {"type": "null"}] assert "anyOf" in properties["ambiguous"] @@ -308,6 +306,20 @@ def test_query_params_from_model_builds_flask_restx_doc_params(): "type": "string", "enum": ["active", "inactive"], } + assert params["enum_status"] == { + "in": "query", + "required": False, + "description": "Enum status filter", + "type": "string", + "enum": ["active", "inactive"], + } + assert params["created_at"] == { + "in": "query", + "required": False, + "description": "Creation time", + "type": "string", + "format": "date-time", + } assert params["appId"] == { "in": "query", "required": True, diff --git a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py index c1f3f523956..a6ae9e6933b 100644 --- a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py +++ b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py @@ -1,16 +1,18 @@ from inspect import unwrap from types import SimpleNamespace -from typing import Protocol, cast +from typing import Any, cast import pytest from flask import Flask +from werkzeug.exceptions import InternalServerError, NotFound +from controllers.console import console_ns from controllers.console.agent import composer as composer_controller from controllers.console.agent import roster as roster_controller from controllers.console.agent.composer import ( - AgentAppComposerApi, - AgentAppComposerCandidatesApi, - AgentAppComposerValidateApi, + AgentComposerApi, + AgentComposerCandidatesApi, + AgentComposerValidateApi, WorkflowAgentComposerApi, WorkflowAgentComposerCandidatesApi, WorkflowAgentComposerImpactApi, @@ -18,42 +20,26 @@ from controllers.console.agent.composer import ( WorkflowAgentComposerValidateApi, ) from controllers.console.agent.roster import ( + AgentAppApi, + AgentAppListApi, AgentInviteOptionsApi, - AgentRosterDetailApi, - AgentRosterListApi, + AgentLogsApi, AgentRosterVersionDetailApi, AgentRosterVersionsApi, + AgentStatisticsSummaryApi, +) +from controllers.console.app import completion as completion_controller +from controllers.console.app import message as message_controller +from controllers.console.app.completion import AgentChatMessageApi, AgentChatMessageStopApi +from controllers.console.app.message import ( + AgentChatMessageListApi, + AgentMessageApi, + AgentMessageFeedbackApi, + AgentMessageSuggestedQuestionApi, ) -from models.model import AppMode from services.entities.agent_entities import ComposerSaveStrategy, ComposerVariant -def _agent_response(agent_id: str = "agent-1") -> dict: - return { - "id": agent_id, - "name": "Analyst", - "description": "", - "icon_type": None, - "icon": None, - "icon_background": None, - "agent_kind": "dify_agent", - "scope": "roster", - "source": "agent_app", - "app_id": None, - "workflow_id": None, - "workflow_node_id": None, - "active_config_snapshot_id": "version-1", - "active_config_snapshot": _version_response(), - "status": "active", - "created_by": "account-1", - "updated_by": "account-1", - "archived_by": None, - "archived_at": None, - "created_at": None, - "updated_at": None, - } - - def _version_response(version_id: str = "version-1") -> dict: return { "id": version_id, @@ -103,6 +89,38 @@ def _agent_app_composer_response() -> dict: } +def _app_detail_obj(**overrides): + data = { + "id": "app-1", + "name": "Iris", + "description": "Agent app", + "mode_compatible_with_agent": "agent", + "icon_type": "emoji", + "icon": "robot", + "icon_background": "#fff", + "enable_site": False, + "enable_api": False, + "app_model_config": None, + "workflow": None, + "tracing": None, + "use_icon_as_answer_icon": False, + "created_by": "account-1", + "created_at": None, + "updated_by": "account-1", + "updated_at": None, + "access_mode": None, + "tags": [], + "api_base_url": None, + "max_active_requests": 0, + "deleted_tools": [], + "site": None, + "bound_agent_id": "00000000-0000-0000-0000-000000000001", + "tenant_id": "tenant-1", + } + data.update(overrides) + return SimpleNamespace(**data) + + def _candidates_response(variant: str) -> dict: return { "variant": variant, @@ -112,24 +130,45 @@ def _candidates_response(variant: str) -> dict: } -def _get_app_model_modes(view) -> list[AppMode]: - current = view - while current is not None: - closure = getattr(current, "__closure__", None) - if closure is not None: - for cell in closure: - try: - value = cell.cell_contents - except ValueError: - continue - if isinstance(value, list) and all(isinstance(item, AppMode) for item in value): - return value - current = getattr(current, "__wrapped__", None) - return [] +def test_agent_v2_console_routes_are_agent_id_first() -> None: + paths = {route for item in console_ns.resources for route in item.urls} + for route in ( + "/agent", + "/agent/", + "/agent//composer", + "/agent//composer/validate", + "/agent//composer/candidates", + "/agent//features", + "/agent//referencing-workflows", + "/agent//drive/files", + "/agent//sandbox/files", + "/agent//skills/upload", + "/agent//files", + "/agent//chat-messages", + "/agent//chat-messages//stop", + "/agent//feedbacks", + "/agent//chat-messages//suggested-questions", + "/agent//messages/", + "/agent//logs", + "/agent//statistics/summary", + "/agent/invite-options", + ): + assert route in paths -class _PayloadWithDescription(Protocol): - description: object + for route in ( + "/agents", + "/agents/invite-options", + "/agents/", + "/agents//versions", + "/apps//agent-composer", + "/apps//agent-composer/validate", + "/apps//agent-composer/candidates", + "/apps//agent-features", + "/apps//agent-referencing-workflows", + "/apps//agent-sandbox/files", + ): + assert route not in paths @pytest.fixture @@ -137,43 +176,175 @@ def account_id() -> str: return "account-1" -def test_roster_list_get_parses_query_and_calls_service(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: - captured: dict[str, object] = {} - - def list_roster_agents(_self: object, **kwargs: object) -> dict[str, object]: - captured.update(kwargs) - return {"data": [], "page": kwargs["page"], "limit": kwargs["limit"], "total": 0, "has_more": False} - - monkeypatch.setattr(roster_controller.AgentRosterService, "list_roster_agents", list_roster_agents) - - with app.test_request_context("/console/api/agents?page=2&limit=5&keyword=analyst"): - result = unwrap(AgentRosterListApi.get)(AgentRosterListApi(), "tenant-1") - - assert result["page"] == 2 - assert captured == {"tenant_id": "tenant-1", "page": 2, "limit": 5, "keyword": "analyst"} - - -def test_roster_list_post_creates_agent_and_returns_detail( +def test_agent_app_list_and_create_use_agent_route( app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str ) -> None: - created_agent = SimpleNamespace(id="agent-1") + captured: dict[str, object] = {} + + class FakeAppService: + def get_app(self, app_obj: object) -> object: + return app_obj + + def get_paginate_apps(self, user_id: str, tenant_id: str, params) -> object: + captured["list"] = {"user_id": user_id, "tenant_id": tenant_id, "params": params} + return SimpleNamespace( + page=1, + per_page=10, + total=1, + has_next=False, + items=[_app_detail_obj(id="app-list", bound_agent_id="agent-list")], + ) + + def create_app(self, tenant_id: str, params, current_user: object) -> object: + captured["create"] = {"tenant_id": tenant_id, "params": params, "current_user": current_user} + return _app_detail_obj(id="app-created", bound_agent_id="agent-created") + + monkeypatch.setattr(roster_controller, "AppService", FakeAppService) monkeypatch.setattr( roster_controller.AgentRosterService, - "create_roster_agent", - lambda _self, **kwargs: created_agent, + "load_app_backing_agents_by_app_id", + lambda _self, **kwargs: { + "app-list": SimpleNamespace(id="agent-list", role="List role", active_config_snapshot_id=None) + }, ) monkeypatch.setattr( roster_controller.AgentRosterService, - "get_roster_agent_detail", - lambda _self, **kwargs: _agent_response(kwargs["agent_id"]), + "get_app_backing_agent", + lambda _self, **kwargs: SimpleNamespace( + id="agent-created", role="Created role", active_config_snapshot_id=None + ), + ) + monkeypatch.setattr( + roster_controller.AgentRosterService, + "load_published_references_by_agent_id", + lambda _self, **kwargs: { + "agent-list": [ + { + "app_id": "workflow-app-id", + "app_name": "RFP Review Flow", + "app_icon_type": "emoji", + "app_icon": "A", + "app_icon_background": "#fff", + "app_mode": "workflow", + "app_updated_at": 1781660000, + "workflow_id": "workflow-1", + "workflow_version": "v1", + "node_ids": ["node-1", "node-2"], + } + ] + }, + ) + monkeypatch.setattr( + roster_controller.FeatureService, + "get_system_features", + lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), ) - with app.test_request_context(json={"name": "Analyst", "agent_soul": {"prompt": {"system_prompt": "x"}}}): - result, status = unwrap(AgentRosterListApi.post)(AgentRosterListApi(), "tenant-1", account_id) + with app.test_request_context("/console/api/agent?page=1&limit=10&mode=workflow"): + listed = unwrap(AgentAppListApi.get)(AgentAppListApi(), "tenant-1", SimpleNamespace(id=account_id)) + + assert listed["page"] == 1 + assert listed["limit"] == 10 + assert listed["total"] == 1 + assert listed["data"][0]["id"] == "agent-list" + assert listed["data"][0]["app_id"] == "app-list" + assert listed["data"][0]["role"] == "List role" + assert listed["data"][0]["active_config_is_published"] is False + assert listed["data"][0]["published_reference_count"] == 1 + assert listed["data"][0]["published_references"] == [ + { + "app_id": "workflow-app-id", + "app_name": "RFP Review Flow", + "app_icon_type": "emoji", + "app_icon": "A", + "app_icon_background": "#fff", + } + ] + assert "bound_agent_id" not in listed["data"][0] + list_call = cast(dict[str, object], captured["list"]) + list_params = cast(Any, list_call["params"]) + assert list_params.mode == "agent" + assert list_params.status == "normal" + + with app.test_request_context( + "/console/api/agent", + json={"name": "Iris", "description": "Agent app", "icon_type": "emoji", "icon": "robot"}, + ): + created, status = unwrap(AgentAppListApi.post)(AgentAppListApi(), "tenant-1", SimpleNamespace(id=account_id)) assert status == 201 - assert result["id"] == "agent-1" - assert result["agent_kind"] == "dify_agent" + assert created["id"] == "agent-created" + assert created["app_id"] == "app-created" + assert created["role"] == "Created role" + assert created["active_config_is_published"] is False + assert "bound_agent_id" not in created + create_call = cast(dict[str, object], captured["create"]) + create_params = cast(Any, create_call["params"]) + assert create_params.mode == "agent" + + +def test_agent_app_detail_update_delete_resolve_app_from_agent_id( + app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str +) -> None: + agent_id = "00000000-0000-0000-0000-000000000001" + app_model = _app_detail_obj(id="app-1", bound_agent_id=agent_id) + captured: dict[str, object] = {} + + monkeypatch.setattr( + roster_controller.AgentRosterService, + "get_agent_app_model", + lambda _self, **kwargs: app_model, + ) + monkeypatch.setattr( + roster_controller.AgentRosterService, + "get_app_backing_agent", + lambda _self, **kwargs: SimpleNamespace(id=agent_id, role="Resolved role", active_config_snapshot_id=None), + ) + monkeypatch.setattr( + roster_controller.FeatureService, + "get_system_features", + lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), + ) + + class FakeAppService: + def get_app(self, app_obj: object) -> object: + captured["get_app"] = app_obj + return app_obj + + def update_app(self, app_obj: object, args: dict[str, object]) -> object: + captured["update"] = {"app": app_obj, "args": args} + return _app_detail_obj(id="app-1", name=args["name"], bound_agent_id=agent_id) + + def delete_app(self, app_obj: object) -> None: + captured["delete"] = app_obj + + monkeypatch.setattr(roster_controller, "AppService", FakeAppService) + + detail = unwrap(AgentAppApi.get)(AgentAppApi(), "tenant-1", agent_id) + assert detail["id"] == agent_id + assert detail["app_id"] == "app-1" + assert detail["role"] == "Resolved role" + assert detail["active_config_is_published"] is False + assert "bound_agent_id" not in detail + + with app.test_request_context( + "/console/api/agent/00000000-0000-0000-0000-000000000001", + json={"name": "Renamed", "description": "", "icon_type": "emoji", "icon": "R"}, + ): + updated = unwrap(AgentAppApi.put)(AgentAppApi(), "tenant-1", agent_id) + + assert updated["name"] == "Renamed" + assert updated["id"] == agent_id + assert updated["app_id"] == "app-1" + assert updated["role"] == "Resolved role" + assert updated["active_config_is_published"] is False + assert "bound_agent_id" not in updated + update_call = cast(dict[str, object], captured["update"]) + assert update_call["app"] is app_model + + deleted, status = unwrap(AgentAppApi.delete)(AgentAppApi(), "tenant-1", agent_id) + assert (deleted, status) == ("", 204) + assert captured["delete"] is app_model def test_invite_options_get_parses_app_id(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: @@ -185,37 +356,16 @@ def test_invite_options_get_parses_app_id(app: Flask, monkeypatch: pytest.Monkey monkeypatch.setattr(roster_controller.AgentRosterService, "list_invite_options", list_invite_options) - with app.test_request_context("/console/api/agents/invite-options?page=1&limit=10&app_id=app-1"): + with app.test_request_context("/console/api/agent/invite-options?page=1&limit=10&app_id=app-1"): result = unwrap(AgentInviteOptionsApi.get)(AgentInviteOptionsApi(), "tenant-1") assert result == {"data": [], "page": 1, "limit": 10, "total": 0, "has_more": False} assert captured == {"tenant_id": "tenant-1", "page": 1, "limit": 10, "keyword": None, "app_id": "app-1"} -def test_roster_detail_patch_delete_and_versions_call_services( - app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str -) -> None: +def test_agent_versions_call_services(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: agent_id = "00000000-0000-0000-0000-000000000001" version_id = "00000000-0000-0000-0000-000000000002" - archived: dict[str, object] = {} - monkeypatch.setattr( - roster_controller.AgentRosterService, - "get_roster_agent_detail", - lambda _self, **kwargs: _agent_response(cast(str, kwargs["agent_id"])), - ) - monkeypatch.setattr( - roster_controller.AgentRosterService, - "update_roster_agent", - lambda _self, **kwargs: { - **_agent_response(cast(str, kwargs["agent_id"])), - "description": cast(_PayloadWithDescription, kwargs["payload"]).description, - }, - ) - monkeypatch.setattr( - roster_controller.AgentRosterService, - "archive_roster_agent", - lambda _self, **kwargs: archived.update(kwargs), - ) monkeypatch.setattr( roster_controller.AgentRosterService, "list_agent_versions", @@ -244,14 +394,6 @@ def test_roster_detail_patch_delete_and_versions_call_services( }, ) - assert unwrap(AgentRosterDetailApi.get)(AgentRosterDetailApi(), "tenant-1", agent_id)["id"] == agent_id - with app.test_request_context(json={"description": "updated"}): - assert ( - unwrap(AgentRosterDetailApi.patch)(AgentRosterDetailApi(), "tenant-1", account_id, agent_id)["description"] - == "updated" - ) - assert unwrap(AgentRosterDetailApi.delete)(AgentRosterDetailApi(), "tenant-1", account_id, agent_id) == ("", 204) - assert archived["account_id"] == "account-1" assert ( unwrap(AgentRosterVersionsApi.get)(AgentRosterVersionsApi(), "tenant-1", agent_id)["data"][0]["id"] == "version-1" @@ -263,6 +405,108 @@ def test_roster_detail_patch_delete_and_versions_call_services( assert version_detail["agent_id"] == agent_id +def test_agent_observability_routes_resolve_app_from_agent_id( + app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str +) -> None: + agent_id = "00000000-0000-0000-0000-000000000001" + app_model = SimpleNamespace(id="app-1") + captured: dict[str, object] = {} + + class FakeObservabilityService: + def list_logs(self, *, app, params): + captured["logs"] = {"app": app, "params": params} + return { + "data": [ + { + "id": "message-1", + "message_id": "message-1", + "conversation_id": "conversation-1", + "conversation_name": "Debug", + "query": "hello", + "answer": "hi", + "status": "success", + "error": None, + "source": "explore", + "from_source": "console", + "from_end_user_id": None, + "from_account_id": account_id, + "message_tokens": 1, + "answer_tokens": 2, + "total_tokens": 3, + "total_price": "0", + "currency": "USD", + "latency": 1.2, + "created_at": 1, + "updated_at": 2, + } + ], + "page": 2, + "limit": 5, + "total": 6, + "has_more": False, + } + + def get_statistics_summary(self, *, app, params): + captured["statistics"] = {"app": app, "params": params} + return { + "source": "all", + "summary": { + "total_messages": 1, + "total_conversations": 1, + "total_end_users": 1, + "total_tokens": 3, + "total_price": "0", + "currency": "USD", + "average_session_interactions": 1, + "average_response_time": 1200, + "tokens_per_second": 2, + "user_satisfaction_rate": 100, + }, + "charts": { + "daily_messages": [{"date": "2026-06-17", "message_count": 1}], + "daily_conversations": [{"date": "2026-06-17", "conversation_count": 1}], + "daily_end_users": [{"date": "2026-06-17", "terminal_count": 1}], + "token_usage": [{"date": "2026-06-17", "token_count": 3, "total_price": "0", "currency": "USD"}], + "average_session_interactions": [{"date": "2026-06-17", "interactions": 1}], + "average_response_time": [{"date": "2026-06-17", "latency": 1200}], + "tokens_per_second": [{"date": "2026-06-17", "tps": 2}], + "user_satisfaction_rate": [{"date": "2026-06-17", "rate": 100}], + }, + } + + monkeypatch.setattr(roster_controller, "_resolve_agent_app_model", lambda **kwargs: app_model) + monkeypatch.setattr(roster_controller, "_agent_observability_service", lambda: FakeObservabilityService()) + + account = SimpleNamespace(id=account_id, timezone="UTC") + with app.test_request_context( + "/console/api/agent/00000000-0000-0000-0000-000000000001/logs" + "?page=2&limit=5&keyword=hello&status=success&source=console" + ): + logs = unwrap(AgentLogsApi.get)(AgentLogsApi(), "tenant-1", account, agent_id) + + assert logs["data"][0]["id"] == "message-1" + logs_call = cast(dict[str, object], captured["logs"]) + assert logs_call["app"] is app_model + logs_params = cast(Any, logs_call["params"]) + assert logs_params.page == 2 + assert logs_params.limit == 5 + assert logs_params.keyword == "hello" + assert logs_params.status == "success" + assert logs_params.source == "console" + + with app.test_request_context( + "/console/api/agent/00000000-0000-0000-0000-000000000001/statistics/summary?source=api" + ): + statistics = unwrap(AgentStatisticsSummaryApi.get)(AgentStatisticsSummaryApi(), "tenant-1", account, agent_id) + + assert statistics["summary"]["total_messages"] == 1 + stats_call = cast(dict[str, object], captured["statistics"]) + assert stats_call["app"] is app_model + stats_params = cast(Any, stats_call["params"]) + assert stats_params.source == "api" + assert stats_params.timezone == "UTC" + + def test_workflow_composer_get_put_validate_candidates_impact_and_save( app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str ) -> None: @@ -283,6 +527,10 @@ def test_workflow_composer_get_put_validate_candidates_impact_and_save( lambda **kwargs: _workflow_composer_response(save_options=[kwargs["payload"].save_strategy.value]), ) monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_save_payload", lambda payload: None) + monkeypatch.setattr( + composer_controller.AgentComposerService, "resolve_workflow_node_agent_id", lambda **kwargs: None + ) + monkeypatch.setattr(composer_controller.AgentComposerService, "resolve_bound_agent_id", lambda **kwargs: None) monkeypatch.setattr( composer_controller.AgentComposerService, "get_workflow_candidates", @@ -334,52 +582,358 @@ def test_workflow_impact_returns_empty_without_version(app: Flask) -> None: assert result == {"current_snapshot_id": None, "workflow_node_count": 0, "bindings": []} -def test_agent_app_composer_get_put_validate_and_candidates( +def test_agent_composer_routes_resolve_app_from_agent_id( app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str ) -> None: - app_model = SimpleNamespace(id="app-1") + agent_id = "00000000-0000-0000-0000-000000000001" + captured: dict[str, object] = {} payload = { "variant": ComposerVariant.AGENT_APP.value, "save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value, "agent_soul": {"prompt": {"system_prompt": "x"}}, } + + monkeypatch.setattr(composer_controller, "resolve_agent_app_model", lambda **kwargs: SimpleNamespace(id="app-1")) + + def load_agent_app_composer(**kwargs: object) -> dict: + captured["load"] = kwargs + return _agent_app_composer_response() + + def save_agent_app_composer(**kwargs: object) -> dict: + captured["save"] = kwargs + return _agent_app_composer_response() + + def collect_validation_findings(**kwargs: object) -> dict: + captured["validate"] = kwargs + return {"warnings": [], "knowledge_retrieval_placeholder": []} + + def get_agent_app_candidates(**kwargs: object) -> dict: + captured["candidates"] = kwargs + return _candidates_response("agent_app") + monkeypatch.setattr( composer_controller.AgentComposerService, "load_agent_app_composer", - lambda **kwargs: _agent_app_composer_response(), + load_agent_app_composer, ) monkeypatch.setattr( composer_controller.AgentComposerService, "save_agent_app_composer", - lambda **kwargs: _agent_app_composer_response(), + save_agent_app_composer, ) monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_save_payload", lambda payload: None) + monkeypatch.setattr( + composer_controller.AgentComposerService, + "collect_validation_findings", + collect_validation_findings, + ) monkeypatch.setattr( composer_controller.AgentComposerService, "get_agent_app_candidates", - lambda **kwargs: _candidates_response("agent_app"), + get_agent_app_candidates, ) - assert unwrap(AgentAppComposerApi.get)(AgentAppComposerApi(), "tenant-1", app_model)["variant"] == "agent_app" + assert unwrap(AgentComposerApi.get)(AgentComposerApi(), "tenant-1", agent_id)["variant"] == "agent_app" + assert cast(dict[str, object], captured["load"])["app_id"] == "app-1" + with app.test_request_context(json=payload): assert ( - unwrap(AgentAppComposerApi.put)(AgentAppComposerApi(), "tenant-1", account_id, app_model)["variant"] - == "agent_app" + unwrap(AgentComposerApi.put)(AgentComposerApi(), "tenant-1", account_id, agent_id)["variant"] == "agent_app" ) - assert unwrap(AgentAppComposerValidateApi.post)(AgentAppComposerValidateApi(), "tenant-1", app_model) == { + assert cast(dict[str, object], captured["save"])["app_id"] == "app-1" + assert unwrap(AgentComposerValidateApi.post)(AgentComposerValidateApi(), "tenant-1", agent_id) == { "result": "success", "errors": [], "warnings": [], "knowledge_retrieval_placeholder": [], } - agent_app_candidates = unwrap(AgentAppComposerCandidatesApi.get)( - AgentAppComposerCandidatesApi(), "tenant-1", account_id, app_model + assert cast(dict[str, object], captured["validate"])["agent_id"] == agent_id + + candidates = unwrap(AgentComposerCandidatesApi.get)(AgentComposerCandidatesApi(), "tenant-1", account_id, agent_id) + assert candidates["variant"] == "agent_app" + assert cast(dict[str, object], captured["candidates"])["app_id"] == "app-1" + + +def test_agent_chat_generate_and_stop_routes_resolve_app_from_agent_id( + app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str +) -> None: + agent_id = "00000000-0000-0000-0000-000000000001" + app_model = SimpleNamespace(id="app-1", mode="agent") + captured: dict[str, object] = {} + + def resolve_agent_app_model(**kwargs: object) -> object: + captured["resolve"] = kwargs + return app_model + + def create_chat_message(**kwargs: object) -> dict[str, object]: + captured["create"] = kwargs + return {"result": "generated"} + + def stop_chat_message(**kwargs: object) -> tuple[dict[str, object], int]: + captured["stop"] = kwargs + return {"result": "success"}, 200 + + monkeypatch.setattr(completion_controller, "resolve_agent_app_model", resolve_agent_app_model) + monkeypatch.setattr(completion_controller, "_create_chat_message", create_chat_message) + monkeypatch.setattr(completion_controller, "_stop_chat_message", stop_chat_message) + + with app.test_request_context(json={"inputs": {}, "query": "hello"}): + assert unwrap(AgentChatMessageApi.post)( + AgentChatMessageApi(), "tenant-1", SimpleNamespace(id=account_id), agent_id + ) == {"result": "generated"} + + assert cast(dict[str, object], captured["resolve"]) == {"tenant_id": "tenant-1", "agent_id": agent_id} + create_call = cast(dict[str, object], captured["create"]) + assert create_call["app_model"] is app_model + assert cast(SimpleNamespace, create_call["current_user"]).id == account_id + + assert unwrap(AgentChatMessageStopApi.post)( + AgentChatMessageStopApi(), "tenant-1", account_id, agent_id, "task-1" + ) == ({"result": "success"}, 200) + stop_call = cast(dict[str, object], captured["stop"]) + assert stop_call == {"current_user_id": account_id, "app_model": app_model, "task_id": "task-1"} + + +def test_agent_chat_helper_forces_agent_streaming_and_external_trace( + app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str +) -> None: + app_model = SimpleNamespace(id="app-1", mode="agent") + current_user = SimpleNamespace(id=account_id) + captured: dict[str, object] = {} + + def generate(**kwargs: object) -> dict[str, object]: + captured.update(kwargs) + return {"answer": "ok"} + + monkeypatch.setattr(completion_controller.AppGenerateService, "generate", generate) + monkeypatch.setattr( + completion_controller.helper, + "compact_generate_response", + lambda response: {"response": response}, ) - assert agent_app_candidates["variant"] == "agent_app" + + with app.test_request_context( + json={"inputs": {}, "query": "hello", "response_mode": "streaming"}, + headers={"X-Trace-Id": "trace-1"}, + ): + result = completion_controller._create_chat_message(current_user=current_user, app_model=app_model) + + assert result == {"response": {"answer": "ok"}} + assert captured["app_model"] is app_model + assert captured["user"] is current_user + assert captured["streaming"] is True + args = cast(dict[str, object], captured["args"]) + assert args["response_mode"] == "streaming" + assert args["auto_generate_name"] is False + assert args["external_trace_id"] == "trace-1" -def test_agent_app_composer_routes_are_agent_mode_only() -> None: - assert _get_app_model_modes(AgentAppComposerApi.get) == [AppMode.AGENT] - assert _get_app_model_modes(AgentAppComposerApi.put) == [AppMode.AGENT] - assert _get_app_model_modes(AgentAppComposerValidateApi.post) == [AppMode.AGENT] - assert _get_app_model_modes(AgentAppComposerCandidatesApi.get) == [AppMode.AGENT] +@pytest.mark.parametrize( + ("error", "expected"), + [ + (completion_controller.services.errors.conversation.ConversationNotExistsError(), NotFound), + ( + completion_controller.services.errors.conversation.ConversationCompletedError(), + completion_controller.ConversationCompletedError, + ), + ( + completion_controller.services.errors.app_model_config.AppModelConfigBrokenError(), + completion_controller.AppUnavailableError, + ), + ( + completion_controller.ProviderTokenNotInitError("not initialized"), + completion_controller.ProviderNotInitializeError, + ), + (completion_controller.QuotaExceededError(), completion_controller.ProviderQuotaExceededError), + ( + completion_controller.ModelCurrentlyNotSupportError(), + completion_controller.ProviderModelCurrentlyNotSupportError, + ), + (completion_controller.InvokeRateLimitError("rate limited"), completion_controller.InvokeRateLimitHttpError), + (completion_controller.InvokeError("invoke failed"), completion_controller.CompletionRequestError), + (RuntimeError("unexpected"), InternalServerError), + ], +) +def test_agent_chat_helper_maps_generation_errors( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + error: Exception, + expected: type[Exception], +) -> None: + app_model = SimpleNamespace(id="app-1", mode="chat") + monkeypatch.setattr(completion_controller.AppGenerateService, "generate", lambda **_: (_ for _ in ()).throw(error)) + + with app.test_request_context(json={"inputs": {}, "query": "hello"}): + with pytest.raises(expected): + completion_controller._create_chat_message( + current_user=SimpleNamespace(id="account-1"), + app_model=app_model, + ) + + +def test_agent_chat_message_routes_resolve_app_from_agent_id(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + agent_id = "00000000-0000-0000-0000-000000000001" + message_id = "00000000-0000-0000-0000-000000000002" + app_model = SimpleNamespace(id="app-1") + current_user = SimpleNamespace(id="account-1") + captured: dict[str, object] = {} + + def resolve_agent_app_model(**kwargs: object) -> object: + captured["resolve"] = kwargs + return app_model + + def list_chat_messages(**kwargs: object) -> dict[str, object]: + captured["list"] = kwargs + return {"data": []} + + def update_message_feedback(**kwargs: object) -> dict[str, object]: + captured["feedback"] = kwargs + return {"result": "success"} + + def get_message_suggested_questions(**kwargs: object) -> dict[str, object]: + captured["suggested"] = kwargs + return {"data": ["next"]} + + def get_message_detail(**kwargs: object) -> dict[str, object]: + captured["detail"] = kwargs + return {"id": message_id} + + monkeypatch.setattr(message_controller, "resolve_agent_app_model", resolve_agent_app_model) + monkeypatch.setattr(message_controller, "_list_chat_messages", list_chat_messages) + monkeypatch.setattr(message_controller, "_update_message_feedback", update_message_feedback) + monkeypatch.setattr(message_controller, "_get_message_suggested_questions", get_message_suggested_questions) + monkeypatch.setattr(message_controller, "_get_message_detail", get_message_detail) + + assert unwrap(AgentChatMessageListApi.get)(AgentChatMessageListApi(), "tenant-1", agent_id) == {"data": []} + assert cast(dict[str, object], captured["list"])["app_model"] is app_model + + with app.test_request_context(json={"message_id": message_id, "rating": "like"}): + assert unwrap(AgentMessageFeedbackApi.post)(AgentMessageFeedbackApi(), "tenant-1", current_user, agent_id) == { + "result": "success" + } + feedback_call = cast(dict[str, object], captured["feedback"]) + assert feedback_call["app_model"] is app_model + assert feedback_call["current_user"] is current_user + + assert unwrap(AgentMessageSuggestedQuestionApi.get)( + AgentMessageSuggestedQuestionApi(), "tenant-1", current_user, agent_id, message_id + ) == {"data": ["next"]} + suggested_call = cast(dict[str, object], captured["suggested"]) + assert suggested_call["app_model"] is app_model + assert suggested_call["current_user"] is current_user + assert suggested_call["message_id"] == message_id + + assert unwrap(AgentMessageApi.get)(AgentMessageApi(), "tenant-1", agent_id, message_id) == {"id": message_id} + detail_call = cast(dict[str, object], captured["detail"]) + assert detail_call == {"app_model": app_model, "message_id": message_id} + + +def test_list_chat_messages_supports_first_id_pagination(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + conversation_id = "00000000-0000-0000-0000-000000000010" + first_message_id = "00000000-0000-0000-0000-000000000011" + older_message_id = "00000000-0000-0000-0000-000000000012" + conversation = SimpleNamespace(id=conversation_id) + first_message = SimpleNamespace(id=first_message_id, created_at=2) + older_message = SimpleNamespace(id=older_message_id, created_at=1) + scalar_values = iter([conversation, first_message, True]) + scalars_result = SimpleNamespace(all=lambda: [older_message]) + session = SimpleNamespace( + scalar=lambda _stmt: next(scalar_values), + scalars=lambda _stmt: scalars_result, + ) + + class FakeMessagePaginationResponse: + @classmethod + def model_validate(cls, pagination: object, from_attributes: bool = False) -> object: + return SimpleNamespace( + model_dump=lambda mode: { + "data": [item.id for item in pagination.data], + "limit": pagination.limit, + "has_more": pagination.has_more, + } + ) + + monkeypatch.setattr(message_controller, "db", SimpleNamespace(session=session)) + monkeypatch.setattr(message_controller, "attach_message_extra_contents", lambda messages: None) + monkeypatch.setattr(message_controller, "MessageInfiniteScrollPaginationResponse", FakeMessagePaginationResponse) + + with app.test_request_context( + "/console/api/agent/agent-1/chat-messages" + f"?conversation_id={conversation_id}&first_id={first_message_id}&limit=1" + ): + result = message_controller._list_chat_messages(app_model=SimpleNamespace(id="app-1")) + + assert result == {"data": [older_message_id], "limit": 1, "has_more": True} + + +def test_update_message_feedback_rejects_empty_rating_without_existing_feedback( + app: Flask, monkeypatch: pytest.MonkeyPatch +) -> None: + message_id = "00000000-0000-0000-0000-000000000002" + message = SimpleNamespace(id=message_id, app_id="app-1", admin_feedback=None) + session = SimpleNamespace(scalar=lambda _stmt: message) + monkeypatch.setattr(message_controller, "db", SimpleNamespace(session=session)) + + with app.test_request_context(json={"message_id": message_id, "rating": None}): + with pytest.raises(ValueError, match="rating cannot be None"): + message_controller._update_message_feedback( + current_user=SimpleNamespace(id="account-1"), + app_model=SimpleNamespace(id="app-1"), + ) + + +@pytest.mark.parametrize( + ("error", "expected"), + [ + (message_controller.MessageNotExistsError(), NotFound), + (message_controller.ConversationNotExistsError(), NotFound), + ( + message_controller.ProviderTokenNotInitError("not initialized"), + message_controller.ProviderNotInitializeError, + ), + (message_controller.QuotaExceededError(), message_controller.ProviderQuotaExceededError), + (message_controller.ModelCurrentlyNotSupportError(), message_controller.ProviderModelCurrentlyNotSupportError), + (message_controller.InvokeError("invoke failed"), message_controller.CompletionRequestError), + ( + message_controller.SuggestedQuestionsAfterAnswerDisabledError(), + message_controller.AppSuggestedQuestionsAfterAnswerDisabledError, + ), + (RuntimeError("unexpected"), InternalServerError), + ], +) +def test_get_message_suggested_questions_maps_service_errors( + monkeypatch: pytest.MonkeyPatch, + error: Exception, + expected: type[Exception], +) -> None: + monkeypatch.setattr( + message_controller.MessageService, + "get_suggested_questions_after_answer", + lambda **_: (_ for _ in ()).throw(error), + ) + + with pytest.raises(expected): + message_controller._get_message_suggested_questions( + current_user=SimpleNamespace(id="account-1"), + app_model=SimpleNamespace(id="app-1"), + message_id="00000000-0000-0000-0000-000000000002", + ) + + +def test_dify_tool_candidate_response_keeps_granularity_fields(): + """Both selection granularities must survive the fields-layer model — + the frontend needs granularity/tools_count to render the Tools menu.""" + from fields.agent_fields import AgentComposerDifyToolCandidateResponse + + provider_entry = AgentComposerDifyToolCandidateResponse.model_validate( + { + "id": "duckduckgo/*", + "granularity": "provider", + "name": "DuckDuckGo", + "provider": "duckduckgo", + "plugin_id": "langgenius/duckduckgo", + "tools_count": 2, + } + ).model_dump(exclude_none=True) + assert provider_entry["granularity"] == "provider" + assert provider_entry["tools_count"] == 2 diff --git a/api/tests/unit_tests/controllers/console/app/test_agent_app_sandbox.py b/api/tests/unit_tests/controllers/console/app/test_agent_app_sandbox.py new file mode 100644 index 00000000000..3cda0a34332 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/app/test_agent_app_sandbox.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +from inspect import unwrap +from types import SimpleNamespace + +import pytest +from dify_agent.client import DifyAgentClientError, DifyAgentHTTPError, DifyAgentTimeoutError +from dify_agent.protocol import SandboxListResponse, SandboxReadResponse, SandboxUploadResponse + +from controllers.console import agent_app_sandbox as module +from models.model import App, AppMode, IconType +from services.agent_app_sandbox_service import AgentSandboxInspectorError + + +class _AgentAppService: + def __init__(self) -> None: + self.calls: list[tuple[str, str, str, str, str]] = [] + + def list_files(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str) -> SandboxListResponse: + self.calls.append(("list", tenant_id, app_id, conversation_id, path)) + return SandboxListResponse(path=path, entries=[], truncated=False) + + def read_file(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str) -> SandboxReadResponse: + self.calls.append(("read", tenant_id, app_id, conversation_id, path)) + return SandboxReadResponse(path=path, size=5, truncated=False, binary=False, text="hello") + + def upload_file(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str) -> SandboxUploadResponse: + self.calls.append(("upload", tenant_id, app_id, conversation_id, path)) + return SandboxUploadResponse( + path=path, file={"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"} + ) + + +class _WorkflowService: + def __init__(self) -> None: + self.calls: list[tuple[str, str, str, str, str, str | None, str]] = [] + + def list_files( + self, + *, + tenant_id: str, + app_id: str, + workflow_run_id: str, + node_id: str, + node_execution_id: str | None, + path: str, + ) -> SandboxListResponse: + self.calls.append(("list", tenant_id, app_id, workflow_run_id, node_id, node_execution_id, path)) + return SandboxListResponse(path=path, entries=[], truncated=False) + + def read_file( + self, + *, + tenant_id: str, + app_id: str, + workflow_run_id: str, + node_id: str, + node_execution_id: str | None, + path: str, + ) -> SandboxReadResponse: + self.calls.append(("read", tenant_id, app_id, workflow_run_id, node_id, node_execution_id, path)) + return SandboxReadResponse(path=path, size=5, truncated=False, binary=False, text="hello") + + def upload_file( + self, + *, + tenant_id: str, + app_id: str, + workflow_run_id: str, + node_id: str, + node_execution_id: str | None, + path: str, + ) -> SandboxUploadResponse: + self.calls.append(("upload", tenant_id, app_id, workflow_run_id, node_id, node_execution_id, path)) + return SandboxUploadResponse( + path=path, file={"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"} + ) + + +def _app_model(app_id: str = "app-1") -> App: + return App( + id=app_id, + tenant_id="tenant-1", + name="App", + mode=AppMode.AGENT, + icon_type=IconType.EMOJI, + icon="bot", + icon_background="#fff", + enable_site=False, + enable_api=False, + ) + + +def test_handle_maps_sandbox_and_agent_backend_errors() -> None: + assert module._handle(AgentSandboxInspectorError("no_sandbox", "no sandbox", status_code=404)) == ( + {"code": "no_sandbox", "message": "no sandbox"}, + 404, + ) + assert module._handle(DifyAgentHTTPError(404, {"code": "sandbox_path_not_found", "message": "missing"})) == ( + {"code": "sandbox_path_not_found", "message": "missing"}, + 404, + ) + assert module._handle(DifyAgentHTTPError(500, "backend exploded")) == ( + {"code": "agent_backend_error", "message": "backend exploded"}, + 500, + ) + assert module._handle(DifyAgentTimeoutError("connection refused")) == ( + {"code": "agent_backend_unreachable", "message": "connection refused"}, + 502, + ) + assert module._handle(DifyAgentClientError("transport failed")) == ( + {"code": "agent_backend_unreachable", "message": "transport failed"}, + 502, + ) + with pytest.raises(RuntimeError): + module._handle(RuntimeError("boom")) + + +def test_agent_app_sandbox_resources_proxy_service(monkeypatch: pytest.MonkeyPatch) -> None: + service = _AgentAppService() + monkeypatch.setattr(module, "AgentAppSandboxService", lambda: service) + monkeypatch.setattr(module, "resolve_agent_app_model", lambda *, tenant_id, agent_id: _app_model()) + monkeypatch.setattr( + module, + "query_params_from_request", + lambda model: SimpleNamespace(conversation_id="conv-1", path="sub/report.txt"), + ) + monkeypatch.setattr( + module, + "request", + SimpleNamespace(get_json=lambda silent=True: {"conversation_id": "conv-1", "path": "report.txt"}), + ) + + listing = unwrap(module.AgentAppSandboxListResource.get)(object(), "tenant-1", "agent-1") + preview = unwrap(module.AgentAppSandboxReadResource.get)(object(), "tenant-1", "agent-1") + upload = unwrap(module.AgentAppSandboxUploadResource.post)(object(), "tenant-1", "agent-1") + + assert listing["path"] == "sub/report.txt" + assert preview["text"] == "hello" + assert upload["file"]["reference"] == "dify-file-ref:file-1" + assert service.calls == [ + ("list", "tenant-1", "app-1", "conv-1", "sub/report.txt"), + ("read", "tenant-1", "app-1", "conv-1", "sub/report.txt"), + ("upload", "tenant-1", "app-1", "conv-1", "report.txt"), + ] + + +def test_agent_app_sandbox_resource_returns_normalized_errors(monkeypatch: pytest.MonkeyPatch) -> None: + class FailingService: + def list_files(self, **kwargs): + raise AgentSandboxInspectorError("no_active_session", "no active session", status_code=404) + + monkeypatch.setattr(module, "AgentAppSandboxService", FailingService) + monkeypatch.setattr(module, "resolve_agent_app_model", lambda *, tenant_id, agent_id: _app_model()) + monkeypatch.setattr( + module, "query_params_from_request", lambda model: SimpleNamespace(conversation_id="conv-1", path=".") + ) + + assert unwrap(module.AgentAppSandboxListResource.get)(object(), "tenant-1", "agent-1") == ( + {"code": "no_active_session", "message": "no active session"}, + 404, + ) + + +def test_workflow_agent_sandbox_resources_proxy_service(monkeypatch: pytest.MonkeyPatch) -> None: + service = _WorkflowService() + monkeypatch.setattr(module, "WorkflowAgentSandboxService", lambda: service) + monkeypatch.setattr( + module, + "query_params_from_request", + lambda model: SimpleNamespace(node_execution_id="exec-1", path="out.txt"), + ) + monkeypatch.setattr( + module, + "request", + SimpleNamespace(get_json=lambda silent=True: {"node_execution_id": "exec-1", "path": "upload.txt"}), + ) + app_model = _app_model() + + listing = unwrap(module.WorkflowAgentSandboxListResource.get)( + object(), "tenant-1", app_model, "run-1", "agent-node" + ) + preview = unwrap(module.WorkflowAgentSandboxReadResource.get)( + object(), "tenant-1", app_model, "run-1", "agent-node" + ) + upload = unwrap(module.WorkflowAgentSandboxUploadResource.post)( + object(), "tenant-1", app_model, "run-1", "agent-node" + ) + + assert listing["path"] == "out.txt" + assert preview["text"] == "hello" + assert upload["file"]["reference"] == "dify-file-ref:file-1" + assert service.calls == [ + ("list", "tenant-1", "app-1", "run-1", "agent-node", "exec-1", "out.txt"), + ("read", "tenant-1", "app-1", "run-1", "agent-node", "exec-1", "out.txt"), + ("upload", "tenant-1", "app-1", "run-1", "agent-node", "exec-1", "upload.txt"), + ] diff --git a/api/tests/unit_tests/controllers/console/app/test_agent_app_workspace.py b/api/tests/unit_tests/controllers/console/app/test_agent_app_workspace.py deleted file mode 100644 index e31beb100bf..00000000000 --- a/api/tests/unit_tests/controllers/console/app/test_agent_app_workspace.py +++ /dev/null @@ -1,216 +0,0 @@ -from __future__ import annotations - -from inspect import unwrap -from types import SimpleNamespace -from typing import cast - -import pytest -from flask import Response - -from clients.agent_backend.errors import AgentBackendHTTPError, AgentBackendTransportError -from clients.agent_backend.workspace_files_client import ( - WorkspaceDownloadResult, - WorkspaceFileEntry, - WorkspaceListResult, - WorkspacePreviewResult, -) -from controllers.console import agent_app_workspace as module -from models.model import App, AppMode, IconType -from services.agent_app_workspace_service import AgentWorkspaceInspectorError - - -class _AgentAppService: - def __init__(self) -> None: - self.calls: list[tuple[str, str, str, str, str]] = [] - - def list_files(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str) -> WorkspaceListResult: - self.calls.append(("list", tenant_id, app_id, conversation_id, path)) - return WorkspaceListResult( - path=path, - entries=[WorkspaceFileEntry(name="a.txt", type="file", size=3, mtime=10)], - truncated=False, - ) - - def preview(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str) -> WorkspacePreviewResult: - self.calls.append(("preview", tenant_id, app_id, conversation_id, path)) - return WorkspacePreviewResult(path=path, size=5, truncated=False, binary=False, text="hello") - - def download(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str) -> WorkspaceDownloadResult: - self.calls.append(("download", tenant_id, app_id, conversation_id, path)) - return WorkspaceDownloadResult(path=path, size=3, truncated=False, content=b"abc") - - -class _WorkflowService: - def __init__(self) -> None: - self.calls: list[tuple[str, str, str, str, str, str | None, str]] = [] - - def list_files( - self, - *, - tenant_id: str, - app_id: str, - workflow_run_id: str, - node_id: str, - node_execution_id: str | None, - path: str, - ) -> WorkspaceListResult: - self.calls.append(("list", tenant_id, app_id, workflow_run_id, node_id, node_execution_id, path)) - return WorkspaceListResult(path=path, entries=[], truncated=False) - - def preview( - self, - *, - tenant_id: str, - app_id: str, - workflow_run_id: str, - node_id: str, - node_execution_id: str | None, - path: str, - ) -> WorkspacePreviewResult: - self.calls.append(("preview", tenant_id, app_id, workflow_run_id, node_id, node_execution_id, path)) - return WorkspacePreviewResult(path=path, size=5, truncated=False, binary=False, text="hello") - - def download( - self, - *, - tenant_id: str, - app_id: str, - workflow_run_id: str, - node_id: str, - node_execution_id: str | None, - path: str, - ) -> WorkspaceDownloadResult: - self.calls.append(("download", tenant_id, app_id, workflow_run_id, node_id, node_execution_id, path)) - return WorkspaceDownloadResult(path=path, size=3, truncated=False, content=b"abc") - - -def _app_model(app_id: str = "app-1") -> App: - return App( - id=app_id, - tenant_id="tenant-1", - name="App", - mode=AppMode.AGENT, - icon_type=IconType.EMOJI, - icon="bot", - icon_background="#fff", - enable_site=False, - enable_api=False, - ) - - -def test_handle_maps_workspace_and_agent_backend_errors() -> None: - assert module._handle(AgentWorkspaceInspectorError("no_sandbox", "no sandbox", status_code=404)) == ( - {"code": "no_sandbox", "message": "no sandbox"}, - 404, - ) - assert module._handle( - AgentBackendHTTPError("not found", status_code=404, detail={"code": "not_found", "message": "missing"}) - ) == ({"code": "not_found", "message": "missing"}, 404) - assert module._handle(AgentBackendHTTPError("bad", status_code=500, detail="backend exploded")) == ( - {"code": "agent_backend_error", "message": "backend exploded"}, - 500, - ) - assert module._handle(AgentBackendTransportError("connection refused")) == ( - {"code": "agent_backend_unreachable", "message": "connection refused"}, - 502, - ) - with pytest.raises(RuntimeError): - module._handle(RuntimeError("boom")) - - -def test_download_response_returns_binary_or_too_large_error() -> None: - response = cast( - Response, - module._download_response( - WorkspaceDownloadResult(path="dir/report.txt", size=3, truncated=False, content=b"abc") - ), - ) - - assert response.status_code == 200 - assert response.data == b"abc" - assert response.headers["Content-Disposition"] == 'attachment; filename="report.txt"' - assert response.headers["Content-Length"] == "3" - assert response.headers["X-Workspace-File-Size"] == "3" - - assert module._download_response(WorkspaceDownloadResult(path="", size=10, truncated=True, content=b"")) == ( - { - "code": "workspace_file_too_large", - "message": ( - "file exceeds the workspace download limit; use preview for partial text or download a smaller file" - ), - "size": 10, - }, - 413, - ) - - -def test_agent_app_workspace_resources_proxy_service(monkeypatch: pytest.MonkeyPatch) -> None: - service = _AgentAppService() - monkeypatch.setattr(module, "AgentAppWorkspaceService", lambda: service) - monkeypatch.setattr( - module, - "query_params_from_request", - lambda model: SimpleNamespace(conversation_id="conv-1", path="sub/report.txt"), - ) - app_model = _app_model() - - listing = unwrap(module.AgentAppWorkspaceListResource.get)(object(), "tenant-1", app_model) - preview = unwrap(module.AgentAppWorkspacePreviewResource.get)(object(), "tenant-1", app_model) - download = unwrap(module.AgentAppWorkspaceDownloadResource.get)(object(), "tenant-1", app_model) - - assert listing["entries"][0]["name"] == "a.txt" - assert preview["text"] == "hello" - assert download.data == b"abc" - assert service.calls == [ - ("list", "tenant-1", "app-1", "conv-1", "sub/report.txt"), - ("preview", "tenant-1", "app-1", "conv-1", "sub/report.txt"), - ("download", "tenant-1", "app-1", "conv-1", "sub/report.txt"), - ] - - -def test_agent_app_workspace_resource_returns_normalized_errors(monkeypatch: pytest.MonkeyPatch) -> None: - class FailingService: - def list_files(self, **kwargs): - raise AgentWorkspaceInspectorError("no_active_session", "no active session", status_code=404) - - monkeypatch.setattr(module, "AgentAppWorkspaceService", FailingService) - monkeypatch.setattr( - module, - "query_params_from_request", - lambda model: SimpleNamespace(conversation_id="conv-1", path="."), - ) - - assert unwrap(module.AgentAppWorkspaceListResource.get)(object(), "tenant-1", _app_model()) == ( - {"code": "no_active_session", "message": "no active session"}, - 404, - ) - - -def test_workflow_agent_workspace_resources_proxy_service(monkeypatch: pytest.MonkeyPatch) -> None: - service = _WorkflowService() - monkeypatch.setattr(module, "WorkflowAgentWorkspaceService", lambda: service) - monkeypatch.setattr( - module, - "query_params_from_request", - lambda model: SimpleNamespace(node_execution_id="exec-1", path="out.txt"), - ) - app_model = _app_model() - - listing = unwrap(module.WorkflowAgentWorkspaceListResource.get)( - object(), "tenant-1", app_model, "run-1", "agent-node" - ) - preview = unwrap(module.WorkflowAgentWorkspacePreviewResource.get)( - object(), "tenant-1", app_model, "run-1", "agent-node" - ) - download = unwrap(module.WorkflowAgentWorkspaceDownloadResource.get)( - object(), "tenant-1", app_model, "run-1", "agent-node" - ) - - assert listing["path"] == "out.txt" - assert preview["text"] == "hello" - assert download.data == b"abc" - assert service.calls == [ - ("list", "tenant-1", "app-1", "run-1", "agent-node", "exec-1", "out.txt"), - ("preview", "tenant-1", "app-1", "run-1", "agent-node", "exec-1", "out.txt"), - ("download", "tenant-1", "app-1", "run-1", "agent-node", "exec-1", "out.txt"), - ] diff --git a/api/tests/unit_tests/controllers/console/app/test_agent_drive_inspector.py b/api/tests/unit_tests/controllers/console/app/test_agent_drive_inspector.py new file mode 100644 index 00000000000..9d1b6c4c0e9 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/app/test_agent_drive_inspector.py @@ -0,0 +1,183 @@ +"""Unit tests for the console agent drive inspector (ENG-624). + +Handlers are unwrapped past the login/app-model decorators and invoked inside a +bare Flask request context with the drive service mocked — covering agent +resolution, query handling, and error mapping, not auth. +""" + +from __future__ import annotations + +import inspect +from types import SimpleNamespace +from unittest.mock import patch + +from flask import Flask + +from controllers.console.app.agent_drive_inspector import ( + AgentDriveDownloadApi, + AgentDriveDownloadByAgentApi, + AgentDriveListApi, + AgentDriveListByAgentApi, + AgentDrivePreviewApi, + AgentDrivePreviewByAgentApi, +) +from services.agent_drive_service import AgentDriveError + +_MOD = "controllers.console.app.agent_drive_inspector" +app = Flask(__name__) + + +def _raw(method): + return inspect.unwrap(method) + + +_APP = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id="agent-1") + + +def test_list_filters_value_pointers_out_of_console_payload(): + raw = _raw(AgentDriveListApi.get) + with app.test_request_context("/?prefix=pdf-toolkit/"): + with patch(f"{_MOD}.AgentDriveService") as drive: + drive.return_value.manifest.return_value = [ + { + "key": "pdf-toolkit/SKILL.md", + "size": 5, + "hash": "h", + "mime_type": "text/markdown", + "file_kind": "tool_file", + "file_id": "tf-1", + "created_at": 1718000000, + } + ] + body = raw(AgentDriveListApi(), _APP) + + assert body["items"][0]["key"] == "pdf-toolkit/SKILL.md" + assert "file_id" not in body["items"][0] + assert drive.return_value.manifest.call_args.kwargs["prefix"] == "pdf-toolkit/" + + +def test_list_by_agent_filters_value_pointers_out_of_console_payload(): + raw = _raw(AgentDriveListByAgentApi.get) + with app.test_request_context("/?prefix=pdf-toolkit/"): + with ( + patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app, + patch(f"{_MOD}.AgentDriveService") as drive, + ): + drive.return_value.manifest.return_value = [ + { + "key": "pdf-toolkit/SKILL.md", + "size": 5, + "hash": "h", + "mime_type": "text/markdown", + "file_kind": "tool_file", + "file_id": "tf-1", + "created_at": 1718000000, + } + ] + body = raw(AgentDriveListByAgentApi(), "tenant-1", "agent-1") + + assert body["items"][0]["key"] == "pdf-toolkit/SKILL.md" + assert "file_id" not in body["items"][0] + resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1") + assert drive.return_value.manifest.call_args.kwargs["agent_id"] == "agent-1" + + +def test_list_resolves_workflow_node_binding_agent(): + raw = _raw(AgentDriveListApi.get) + with app.test_request_context("/?node_id=agent-node-1"): + with ( + patch(f"{_MOD}.AgentComposerService") as composer, + patch(f"{_MOD}.AgentDriveService") as drive, + ): + composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9" + drive.return_value.manifest.return_value = [] + raw(AgentDriveListApi(), _APP) + + assert drive.return_value.manifest.call_args.kwargs["agent_id"] == "wf-agent-9" + assert composer.resolve_workflow_node_agent_id.call_args.kwargs["node_id"] == "agent-node-1" + + +def test_list_400_when_no_agent_bound(): + raw = _raw(AgentDriveListApi.get) + app_without_agent = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id=None) + with app.test_request_context("/"): + body, status = raw(AgentDriveListApi(), app_without_agent) + assert status == 400 + assert body["code"] == "agent_not_bound" + + +def test_preview_passes_through_and_maps_errors(): + raw = _raw(AgentDrivePreviewApi.get) + with app.test_request_context("/?key=pdf-toolkit/SKILL.md"): + with patch(f"{_MOD}.AgentDriveService") as drive: + drive.return_value.preview.return_value = { + "key": "pdf-toolkit/SKILL.md", + "size": 5, + "truncated": False, + "binary": False, + "text": "# hi", + } + body = raw(AgentDrivePreviewApi(), _APP) + assert body["text"] == "# hi" + + with app.test_request_context("/?key=ghost/SKILL.md"): + with patch(f"{_MOD}.AgentDriveService") as drive: + drive.return_value.preview.side_effect = AgentDriveError( + "drive_key_not_found", "no drive entry", status_code=404 + ) + body, status = raw(AgentDrivePreviewApi(), _APP) + assert status == 404 + assert body["code"] == "drive_key_not_found" + + +def test_preview_by_agent_passes_through_and_maps_errors(): + raw = _raw(AgentDrivePreviewByAgentApi.get) + with app.test_request_context("/?key=pdf-toolkit/SKILL.md"): + with ( + patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app, + patch(f"{_MOD}.AgentDriveService") as drive, + ): + drive.return_value.preview.return_value = { + "key": "pdf-toolkit/SKILL.md", + "size": 5, + "truncated": False, + "binary": False, + "text": "# hi", + } + body = raw(AgentDrivePreviewByAgentApi(), "tenant-1", "agent-1") + assert body["text"] == "# hi" + resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1") + + with app.test_request_context("/?key=ghost/SKILL.md"): + with ( + patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP), + patch(f"{_MOD}.AgentDriveService") as drive, + ): + drive.return_value.preview.side_effect = AgentDriveError( + "drive_key_not_found", "no drive entry", status_code=404 + ) + body, status = raw(AgentDrivePreviewByAgentApi(), "tenant-1", "agent-1") + assert status == 404 + assert body["code"] == "drive_key_not_found" + + +def test_download_returns_signed_url_json(): + raw = _raw(AgentDriveDownloadApi.get) + with app.test_request_context("/?key=pdf-toolkit/.DIFY-SKILL-FULL.zip"): + with patch(f"{_MOD}.AgentDriveService") as drive: + drive.return_value.download_url.return_value = "https://signed.example/zip" + body = raw(AgentDriveDownloadApi(), _APP) + assert body == {"url": "https://signed.example/zip"} + + +def test_download_by_agent_returns_signed_url_json(): + raw = _raw(AgentDriveDownloadByAgentApi.get) + with app.test_request_context("/?key=pdf-toolkit/.DIFY-SKILL-FULL.zip"): + with ( + patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app, + patch(f"{_MOD}.AgentDriveService") as drive, + ): + drive.return_value.download_url.return_value = "https://signed.example/zip" + body = raw(AgentDriveDownloadByAgentApi(), "tenant-1", "agent-1") + assert body == {"url": "https://signed.example/zip"} + resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1") diff --git a/api/tests/unit_tests/controllers/console/app/test_agent_skills.py b/api/tests/unit_tests/controllers/console/app/test_agent_skills.py index 015ae040e6f..bcb4aeab462 100644 --- a/api/tests/unit_tests/controllers/console/app/test_agent_skills.py +++ b/api/tests/unit_tests/controllers/console/app/test_agent_skills.py @@ -14,7 +14,16 @@ from unittest.mock import MagicMock, patch from flask import Flask -from controllers.console.app.agent import AgentSkillStandardizeApi, AgentSkillUploadApi +from controllers.console.app.agent import ( + AgentDriveFilesByAgentApi, + AgentSkillByAgentApi, + AgentSkillInferToolsByAgentApi, + AgentSkillStandardizeApi, + AgentSkillStandardizeByAgentApi, + AgentSkillUploadApi, + AgentSkillUploadByAgentApi, +) +from models.model import AppMode from services.agent.skill_package_service import SkillPackageError from services.agent_drive_service import AgentDriveError @@ -32,7 +41,8 @@ def _file_ctx(*, files: dict[str, bytes] | None = None): _USER = SimpleNamespace(id="user-1") -_APP = SimpleNamespace(tenant_id="tenant-1", bound_agent_id="agent-1") +_APP = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.AGENT, bound_agent_id="agent-1") +_WORKFLOW_APP = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.WORKFLOW, bound_agent_id=None) def test_upload_validates_and_returns_skill_ref(): @@ -56,6 +66,28 @@ def test_upload_validates_and_returns_skill_ref(): manifest.to_skill_ref.assert_called_once_with(file_id="uf-1") +def test_upload_by_agent_resolves_app_and_returns_skill_ref(): + raw = _raw(AgentSkillUploadByAgentApi.post) + manifest = MagicMock() + manifest.to_skill_ref.return_value.model_dump.return_value = {"name": "S", "file_id": "uf-1"} + manifest.model_dump.return_value = {"name": "S"} + + with _file_ctx(files={"file": b"zip-bytes"}): + with ( + patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app, + patch(f"{_MOD}.SkillPackageService") as pkg, + patch(f"{_MOD}.FileService") as fs, + patch(f"{_MOD}.db"), + ): + pkg.return_value.validate_and_extract.return_value = manifest + fs.return_value.upload_file.return_value = SimpleNamespace(id="uf-1") + body, status = raw(AgentSkillUploadByAgentApi(), "tenant-1", _USER, "agent-1") + + assert status == 201 + assert body["skill"] == {"name": "S", "file_id": "uf-1"} + resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1") + + def test_upload_no_file_is_400(): raw = _raw(AgentSkillUploadApi.post) with _file_ctx(files={}): @@ -87,13 +119,47 @@ def test_standardize_returns_result(): assert svc.return_value.standardize.call_args.kwargs["agent_id"] == "agent-1" +def test_standardize_by_agent_resolves_app(): + raw = _raw(AgentSkillStandardizeByAgentApi.post) + with _file_ctx(files={"file": b"zip"}): + with ( + patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app, + patch(f"{_MOD}.SkillStandardizeService") as svc, + ): + svc.return_value.standardize.return_value = {"skill": {"path": "s"}, "manifest": {}} + body, status = raw(AgentSkillStandardizeByAgentApi(), "tenant-1", _USER, "agent-1") + + assert status == 201 + assert body["skill"] == {"path": "s"} + resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1") + assert svc.return_value.standardize.call_args.kwargs["agent_id"] == "agent-1" + + def test_standardize_no_bound_agent_is_400(): raw = _raw(AgentSkillStandardizeApi.post) - app_without_agent = SimpleNamespace(tenant_id="tenant-1", bound_agent_id=None) + app_without_agent = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.AGENT, bound_agent_id=None) with _file_ctx(files={"file": b"zip"}): body, status = raw(AgentSkillStandardizeApi(), _USER, app_without_agent) assert status == 400 - assert body["code"] == "no_bound_agent" + assert body["code"] == "agent_not_bound" + + +def test_standardize_resolves_workflow_node_agent(): + raw = _raw(AgentSkillStandardizeApi.post) + with app.test_request_context( + "/?node_id=agent-node-1", method="POST", data={"file": (io.BytesIO(b"zip"), "skill.zip")} + ): + with ( + patch(f"{_MOD}.AgentComposerService") as composer, + patch(f"{_MOD}.SkillStandardizeService") as svc, + ): + composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1" + svc.return_value.standardize.return_value = {"skill": {"path": "s"}, "manifest": {}} + body, status = raw(AgentSkillStandardizeApi(), _USER, _WORKFLOW_APP) + + assert status == 201 + assert body["skill"] == {"path": "s"} + assert svc.return_value.standardize.call_args.kwargs["agent_id"] == "wf-agent-1" def test_standardize_maps_drive_error(): @@ -104,3 +170,310 @@ def test_standardize_maps_drive_error(): body, status = raw(AgentSkillStandardizeApi(), _USER, _APP) assert status == 404 assert body["code"] == "source_not_found" + + +# ── ENG-625: drive files commit + delete endpoints ──────────────────────────── + + +def _json_ctx(payload: dict | None = None, *, method: str = "POST", query_string: str = ""): + return app.test_request_context(f"/?{query_string}", method=method, json=payload or {}) + + +def test_files_commit_validates_upload_and_returns_drive_ref(): + from controllers.console.app.agent import AgentDriveFilesApi + + raw = _raw(AgentDriveFilesApi.post) + upload = SimpleNamespace(id="uf-1", name="sample qna.pdf") + with _json_ctx({"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"}): + with ( + patch(f"{_MOD}.console_ns") as ns, + patch(f"{_MOD}.db") as db_mock, + patch(f"{_MOD}.AgentDriveService") as drive, + patch(f"{_MOD}.AgentComposerService") as composer, + ): + ns.payload = {"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"} + db_mock.session.scalar.return_value = upload + drive.return_value.commit.return_value = [ + {"key": "files/sample qna.pdf", "size": 5, "mime_type": "application/pdf"} + ] + composer.add_drive_file_ref.return_value = "ver-2" + body, status = raw(AgentDriveFilesApi(), _USER, _APP) + + assert status == 201 + assert body["file"]["drive_key"] == "files/sample qna.pdf" + assert body["file"]["file_id"] == "uf-1" + assert body["config_version_id"] == "ver-2" + item = drive.return_value.commit.call_args.kwargs["items"][0] + assert item.value_owned_by_drive is True + assert item.file_ref.kind == "upload_file" + file_ref = composer.add_drive_file_ref.call_args.kwargs["file_ref"] + assert file_ref.drive_key == "files/sample qna.pdf" + assert file_ref.name == "sample qna.pdf" + assert composer.add_drive_file_ref.call_args.kwargs["app_id"] == "app-1" + + +def test_files_by_agent_commit_uses_agent_route_and_ignores_node_id(): + raw = _raw(AgentDriveFilesByAgentApi.post) + upload = SimpleNamespace(id="uf-1", name="sample.pdf") + with _json_ctx({"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"}, query_string="node_id=ignored"): + with ( + patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app, + patch(f"{_MOD}.console_ns") as ns, + patch(f"{_MOD}.db") as db_mock, + patch(f"{_MOD}.AgentDriveService") as drive, + patch(f"{_MOD}.AgentComposerService") as composer, + ): + ns.payload = {"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"} + db_mock.session.scalar.return_value = upload + drive.return_value.commit.return_value = [ + {"key": "files/sample.pdf", "size": 5, "mime_type": "application/pdf"} + ] + composer.add_drive_file_ref.return_value = "ver-2" + body, status = raw(AgentDriveFilesByAgentApi(), "tenant-1", _USER, "agent-1") + + assert status == 201 + assert body["config_version_id"] == "ver-2" + resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1") + assert composer.add_drive_file_ref.call_args.kwargs["node_id"] is None + + +def test_files_commit_404_when_upload_not_in_tenant(): + from controllers.console.app.agent import AgentDriveFilesApi + + raw = _raw(AgentDriveFilesApi.post) + with _json_ctx({"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"}): + with ( + patch(f"{_MOD}.console_ns") as ns, + patch(f"{_MOD}.db") as db_mock, + ): + ns.payload = {"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"} + db_mock.session.scalar.return_value = None + body, status = raw(AgentDriveFilesApi(), _USER, _APP) + assert status == 404 + assert body["code"] == "upload_file_not_found" + + +def test_files_commit_resolves_workflow_node_agent(): + from controllers.console.app.agent import AgentDriveFilesApi + + raw = _raw(AgentDriveFilesApi.post) + upload = SimpleNamespace(id="uf-1", name="sample.pdf") + with _json_ctx({"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"}, query_string="node_id=agent-node-1"): + with ( + patch(f"{_MOD}.console_ns") as ns, + patch(f"{_MOD}.db") as db_mock, + patch(f"{_MOD}.AgentDriveService") as drive, + patch(f"{_MOD}.AgentComposerService") as composer, + ): + ns.payload = {"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"} + db_mock.session.scalar.return_value = upload + composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1" + drive.return_value.commit.return_value = [ + {"key": "files/sample.pdf", "size": 5, "mime_type": "application/pdf"} + ] + composer.add_drive_file_ref.return_value = "ver-2" + body, status = raw(AgentDriveFilesApi(), _USER, _WORKFLOW_APP) + + assert status == 201 + assert body["config_version_id"] == "ver-2" + assert drive.return_value.commit.call_args.kwargs["agent_id"] == "wf-agent-1" + assert composer.add_drive_file_ref.call_args.kwargs["node_id"] == "agent-node-1" + + +def test_files_delete_updates_soul_then_drive(): + from controllers.console.app.agent import AgentDriveFilesApi + + raw = _raw(AgentDriveFilesApi.delete) + calls: list[str] = [] + with _json_ctx(method="DELETE", query_string="key=files/sample.pdf"): + with ( + patch(f"{_MOD}.AgentComposerService") as composer, + patch(f"{_MOD}.AgentDriveService") as drive, + ): + composer.remove_drive_refs.side_effect = lambda **kw: calls.append("soul") or "ver-2" + drive.return_value.delete.side_effect = lambda **kw: calls.append("drive") or ["files/sample.pdf"] + body = raw(AgentDriveFilesApi(), _USER, _APP) + + assert calls == ["soul", "drive"] # soul-first ordering + assert body == {"result": "success", "removed_keys": ["files/sample.pdf"], "config_version_id": "ver-2"} + assert composer.remove_drive_refs.call_args.kwargs["file_key"] == "files/sample.pdf" + assert composer.remove_drive_refs.call_args.kwargs["app_id"] == "app-1" + + +def test_files_by_agent_delete_uses_agent_route_and_ignores_node_id(): + raw = _raw(AgentDriveFilesByAgentApi.delete) + with _json_ctx(method="DELETE", query_string="key=files/sample.pdf&node_id=ignored"): + with ( + patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app, + patch(f"{_MOD}.AgentComposerService") as composer, + patch(f"{_MOD}.AgentDriveService") as drive, + ): + composer.remove_drive_refs.return_value = "ver-2" + drive.return_value.delete.return_value = ["files/sample.pdf"] + body = raw(AgentDriveFilesByAgentApi(), "tenant-1", _USER, "agent-1") + + assert body["config_version_id"] == "ver-2" + resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1") + assert composer.remove_drive_refs.call_args.kwargs["node_id"] is None + + +def test_files_delete_resolves_workflow_node_agent(): + from controllers.console.app.agent import AgentDriveFilesApi + + raw = _raw(AgentDriveFilesApi.delete) + with _json_ctx(method="DELETE", query_string="key=files/sample.pdf&node_id=agent-node-1"): + with ( + patch(f"{_MOD}.AgentComposerService") as composer, + patch(f"{_MOD}.AgentDriveService") as drive, + ): + composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1" + composer.remove_drive_refs.return_value = "ver-2" + drive.return_value.delete.return_value = ["files/sample.pdf"] + body = raw(AgentDriveFilesApi(), _USER, _WORKFLOW_APP) + + assert body["config_version_id"] == "ver-2" + assert drive.return_value.delete.call_args.kwargs["agent_id"] == "wf-agent-1" + assert composer.remove_drive_refs.call_args.kwargs["node_id"] == "agent-node-1" + + +def test_files_delete_survives_drive_failure(): + from controllers.console.app.agent import AgentDriveFilesApi + + raw = _raw(AgentDriveFilesApi.delete) + with _json_ctx(method="DELETE", query_string="key=files/sample.pdf"): + with ( + patch(f"{_MOD}.AgentComposerService") as composer, + patch(f"{_MOD}.AgentDriveService") as drive, + ): + composer.remove_drive_refs.return_value = "ver-2" + drive.return_value.delete.side_effect = RuntimeError("storage down") + body = raw(AgentDriveFilesApi(), _USER, _APP) + # soul already updated; drive cleanup is best-effort and retryable + assert body == {"result": "success", "removed_keys": [], "config_version_id": "ver-2"} + + +def test_skill_delete_uses_slug_prefix_and_is_idempotent(): + from controllers.console.app.agent import AgentSkillApi + + raw = _raw(AgentSkillApi.delete) + with _json_ctx(method="DELETE"): + with ( + patch(f"{_MOD}.AgentComposerService") as composer, + patch(f"{_MOD}.AgentDriveService") as drive, + ): + composer.remove_drive_refs.return_value = None # ref already gone + drive.return_value.delete.return_value = [] + body = raw(AgentSkillApi(), _USER, _APP, "tender-analyzer") + + assert body == {"result": "success", "removed_keys": [], "config_version_id": None} + assert drive.return_value.delete.call_args.kwargs["prefix"] == "tender-analyzer/" + assert composer.remove_drive_refs.call_args.kwargs["skill_slug"] == "tender-analyzer" + assert composer.remove_drive_refs.call_args.kwargs["app_id"] == "app-1" + + +def test_skill_delete_by_agent_uses_agent_route(): + raw = _raw(AgentSkillByAgentApi.delete) + with _json_ctx(method="DELETE", query_string="node_id=ignored"): + with ( + patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app, + patch(f"{_MOD}.AgentComposerService") as composer, + patch(f"{_MOD}.AgentDriveService") as drive, + ): + composer.remove_drive_refs.return_value = "ver-2" + drive.return_value.delete.return_value = ["tender-analyzer/SKILL.md"] + body = raw(AgentSkillByAgentApi(), "tenant-1", _USER, "agent-1", "tender-analyzer") + + assert body["config_version_id"] == "ver-2" + resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1") + assert composer.remove_drive_refs.call_args.kwargs["node_id"] is None + + +def test_skill_delete_rejects_path_like_slug(): + from controllers.console.app.agent import AgentSkillApi + + raw = _raw(AgentSkillApi.delete) + with _json_ctx(method="DELETE"): + body, status = raw(AgentSkillApi(), _USER, _APP, "a/b") + assert status == 400 + assert body["code"] == "drive_key_invalid" + + +# ── ENG-371: infer-tools endpoint ───────────────────────────────────────────── + + +def test_infer_tools_returns_draft_suggestions(): + from controllers.console.app.agent import AgentSkillInferToolsApi + + raw = _raw(AgentSkillInferToolsApi.post) + with _json_ctx(): + with patch(f"{_MOD}.SkillToolInferenceService") as svc: + svc.return_value.infer.return_value = { + "inferable": True, + "cli_tools": [{"name": "ffmpeg", "inferred_from": "audio-transcribe"}], + "reason": None, + } + body = raw(AgentSkillInferToolsApi(), _APP, "audio-transcribe") + + assert body["inferable"] is True + assert svc.return_value.infer.call_args.kwargs["slug"] == "audio-transcribe" + + +def test_infer_tools_by_agent_uses_agent_route(): + raw = _raw(AgentSkillInferToolsByAgentApi.post) + with _json_ctx(query_string="node_id=ignored"): + with ( + patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app, + patch(f"{_MOD}.SkillToolInferenceService") as svc, + ): + svc.return_value.infer.return_value = {"inferable": True, "cli_tools": [], "reason": None} + body = raw(AgentSkillInferToolsByAgentApi(), "tenant-1", "agent-1", "audio-transcribe") + + assert body["inferable"] is True + resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1") + assert svc.return_value.infer.call_args.kwargs["agent_id"] == "agent-1" + + +def test_infer_tools_resolves_workflow_node_agent(): + from controllers.console.app.agent import AgentSkillInferToolsApi + + raw = _raw(AgentSkillInferToolsApi.post) + with _json_ctx(query_string="node_id=agent-node-1"): + with ( + patch(f"{_MOD}.AgentComposerService") as composer, + patch(f"{_MOD}.SkillToolInferenceService") as svc, + ): + composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1" + svc.return_value.infer.return_value = {"inferable": False, "cli_tools": [], "reason": "none"} + body = raw(AgentSkillInferToolsApi(), _WORKFLOW_APP, "audio-transcribe") + + assert body["inferable"] is False + assert svc.return_value.infer.call_args.kwargs["agent_id"] == "wf-agent-1" + + +def test_infer_tools_maps_inference_errors(): + from controllers.console.app.agent import AgentSkillInferToolsApi + from services.agent.skill_tool_inference_service import SkillToolInferenceError + + raw = _raw(AgentSkillInferToolsApi.post) + with _json_ctx(): + with patch(f"{_MOD}.SkillToolInferenceService") as svc: + svc.return_value.infer.side_effect = SkillToolInferenceError( + "default_model_not_configured", "no model", status_code=400 + ) + body, status = raw(AgentSkillInferToolsApi(), _APP, "audio-transcribe") + assert status == 400 + assert body["code"] == "default_model_not_configured" + + +def test_infer_tools_rejects_path_like_slug_and_unbound_app(): + from controllers.console.app.agent import AgentSkillInferToolsApi + + raw = _raw(AgentSkillInferToolsApi.post) + with _json_ctx(): + body, status = raw(AgentSkillInferToolsApi(), _APP, "a/b") + assert (status, body["code"]) == (400, "drive_key_invalid") + + app_without_agent = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.AGENT, bound_agent_id=None) + with _json_ctx(): + body, status = raw(AgentSkillInferToolsApi(), app_without_agent, "x") + assert (status, body["code"]) == (400, "agent_not_bound") diff --git a/api/tests/unit_tests/controllers/console/app/test_app_response_models.py b/api/tests/unit_tests/controllers/console/app/test_app_response_models.py index 35eac429c02..ef8f90e5c9c 100644 --- a/api/tests/unit_tests/controllers/console/app/test_app_response_models.py +++ b/api/tests/unit_tests/controllers/console/app/test_app_response_models.py @@ -314,6 +314,21 @@ def test_app_list_query_rejects_flat_tag_ids(app_module): app_module.AppListQuery.model_validate(normalized) +def test_create_app_endpoint_rejects_agent_mode(app_module, monkeypatch: pytest.MonkeyPatch): + payload = {"name": "Iris", "mode": "agent", "description": "Agent app"} + app_service = MagicMock() + monkeypatch.setattr(app_module, "AppService", lambda: app_service) + + app_module.console_ns.payload = payload + try: + with pytest.raises(ValidationError): + _unwrap(app_module.AppListApi().post)("tenant-1", SimpleNamespace(id="account-1")) + finally: + app_module.console_ns.payload = None + + app_service.create_app.assert_not_called() + + def test_app_partial_serialization_uses_aliases(app_models): AppPartial = app_models.AppPartial created_at = _ts() @@ -389,6 +404,7 @@ def test_app_detail_with_site_includes_nested_serialization(app_models): max_active_requests=5, deleted_tools=[{"type": "api", "tool_name": "search", "provider_id": "prov"}], site=site, + bound_agent_id="agent-1", ) serialized = AppDetailWithSite.model_validate(app_obj, from_attributes=True).model_dump(mode="json") @@ -398,6 +414,7 @@ def test_app_detail_with_site_includes_nested_serialization(app_models): assert serialized["deleted_tools"][0]["tool_name"] == "search" assert serialized["site"]["icon_url"] == "signed:site-icon" assert serialized["site"]["created_at"] == int(timestamp.timestamp()) + assert serialized["bound_agent_id"] == "agent-1" def test_app_pagination_aliases_per_page_and_has_next(app_models): diff --git a/api/tests/unit_tests/controllers/console/app/test_create_app_payload.py b/api/tests/unit_tests/controllers/console/app/test_create_app_payload.py index ebabe50c938..dfb2a4c1398 100644 --- a/api/tests/unit_tests/controllers/console/app/test_create_app_payload.py +++ b/api/tests/unit_tests/controllers/console/app/test_create_app_payload.py @@ -1,9 +1,4 @@ -"""Regression tests for CreateAppPayload mode validation. - -The HTTP create-app payload must accept the new "agent" app mode; without it a -user cannot create an Agent App through POST /console/api/apps even though the -service layer (CreateAppParams) supports it. -""" +"""Regression tests for CreateAppPayload mode validation.""" import pytest from pydantic import ValidationError @@ -14,12 +9,16 @@ from controllers.console.app.app import CreateAppPayload class TestCreateAppPayloadMode: @pytest.mark.parametrize( "mode", - ["chat", "agent-chat", "agent", "advanced-chat", "workflow", "completion"], + ["chat", "agent-chat", "advanced-chat", "workflow", "completion"], ) def test_accepts_supported_modes(self, mode: str): payload = CreateAppPayload.model_validate({"name": "X", "mode": mode}) assert payload.mode == mode + def test_rejects_agent_mode(self): + with pytest.raises(ValidationError): + CreateAppPayload.model_validate({"name": "X", "mode": "agent"}) + def test_rejects_unknown_mode(self): with pytest.raises(ValidationError): CreateAppPayload.model_validate({"name": "X", "mode": "not-a-mode"}) diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow.py b/api/tests/unit_tests/controllers/console/app/test_workflow.py index d2f7770b2fb..e05796d8853 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow.py @@ -540,6 +540,58 @@ def test_draft_workflow_get_not_found(monkeypatch: pytest.MonkeyPatch) -> None: handler(api, app_model=SimpleNamespace(id="app")) +def test_draft_workflow_get_projects_agent_node_job_to_graph(monkeypatch: pytest.MonkeyPatch) -> None: + workflow = _make_workflow( + graph_dict={ + "nodes": [ + { + "id": "agent-node", + "data": { + "type": "agent", + "version": "2", + }, + } + ], + "edges": [], + } + ) + projected_graph = { + "nodes": [ + { + "id": "agent-node", + "data": { + "type": "agent", + "version": "2", + "agent_task": "Summarize it.", + "agent_declared_outputs": [{"name": "summary", "type": "string"}], + }, + } + ], + "edges": [], + } + + monkeypatch.setattr( + workflow_module, + "WorkflowService", + lambda: SimpleNamespace(get_draft_workflow=lambda **_k: workflow), + ) + + from services.agent.workflow_publish_service import WorkflowAgentPublishService + + monkeypatch.setattr( + WorkflowAgentPublishService, + "project_draft_bindings_to_graph", + lambda **_k: projected_graph, + ) + + api = workflow_module.DraftWorkflowApi() + handler = inspect.unwrap(api.get) + + response = handler(api, app_model=SimpleNamespace(id="app")) + + assert response["graph"] == projected_graph + + def test_advanced_chat_run_conversation_not_exists(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( workflow_module.AppGenerateService, diff --git a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline.py b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline.py index 5a66bc4e92f..bca2d73ad9f 100644 --- a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline.py +++ b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline.py @@ -15,6 +15,7 @@ from controllers.console.datasets.rag_pipeline.rag_pipeline import ( PipelineTemplateListApi, PublishCustomizedPipelineTemplateApi, ) +from models.account import Account from models.dataset import PipelineCustomizedTemplate from services.entities.knowledge_entities.rag_pipeline_entities import PipelineTemplateInfoEntity @@ -50,24 +51,31 @@ def _payload() -> dict[str, object]: } +def _account() -> Account: + account = Account(name="Test User", email="test@example.com") + account.id = "account-1" + return account + + class TestPipelineTemplateListApi: def test_get_uses_query_defaults_and_serializes_nullable_fields(self, app: Flask) -> None: api = PipelineTemplateListApi() method = unwrap(api.get) - service_calls: list[tuple[str, str]] = [] + tenant_id = "tenant-1" + service_calls: list[tuple[str, str, str]] = [] - def get_pipeline_templates(template_type: str, language: str) -> dict[str, object]: - service_calls.append((template_type, language)) + def get_pipeline_templates(template_type: str, language: str, current_tenant_id: str) -> dict[str, object]: + service_calls.append((template_type, language, current_tenant_id)) return {"pipeline_templates": [_template_item()]} with ( app.test_request_context("/rag/pipeline/templates"), patch.object(module.RagPipelineService, "get_pipeline_templates", side_effect=get_pipeline_templates), ): - response, status = method(api) + response, status = method(api, tenant_id) assert status == 200 - assert service_calls == [("built-in", "en-US")] + assert service_calls == [("built-in", "en-US", tenant_id)] assert response == { "pipeline_templates": [ { @@ -81,21 +89,22 @@ class TestPipelineTemplateListApi: def test_get_passes_explicit_query_to_service(self, app: Flask) -> None: api = PipelineTemplateListApi() method = unwrap(api.get) - service_calls: list[tuple[str, str]] = [] + tenant_id = "tenant-1" + service_calls: list[tuple[str, str, str]] = [] - def get_pipeline_templates(template_type: str, language: str) -> dict[str, object]: - service_calls.append((template_type, language)) + def get_pipeline_templates(template_type: str, language: str, current_tenant_id: str) -> dict[str, object]: + service_calls.append((template_type, language, current_tenant_id)) return {"pipeline_templates": []} with ( app.test_request_context("/rag/pipeline/templates?type=customized&language=ja-JP"), patch.object(module.RagPipelineService, "get_pipeline_templates", side_effect=get_pipeline_templates), ): - response, status = method(api) + response, status = method(api, tenant_id) assert status == 200 assert response == {"pipeline_templates": []} - assert service_calls == [("customized", "ja-JP")] + assert service_calls == [("customized", "ja-JP", tenant_id)] class TestPipelineTemplateDetailApi: @@ -140,22 +149,28 @@ class TestCustomizedPipelineTemplateApi: api = CustomizedPipelineTemplateApi() method = unwrap(api.patch) payload = _payload() - service_calls: list[tuple[str, PipelineTemplateInfoEntity]] = [] + account = _account() + tenant_id = "tenant-1" + service_calls: list[tuple[str, PipelineTemplateInfoEntity, Account, str]] = [] - def update_template(template_id: str, template_info: PipelineTemplateInfoEntity) -> None: - service_calls.append((template_id, template_info)) + def update_template( + template_id: str, template_info: PipelineTemplateInfoEntity, current_user: Account, current_tenant_id: str + ) -> None: + service_calls.append((template_id, template_info, current_user, current_tenant_id)) with ( app.test_request_context("/rag/pipeline/customized/templates/template-1", method="PATCH", json=payload), patch.object(type(console_ns), "payload", new_callable=PropertyMock, return_value=payload), patch.object(module.RagPipelineService, "update_customized_pipeline_template", side_effect=update_template), ): - response, status = method(api, "template-1") + response, status = method(api, tenant_id, account, "template-1") assert (response, status) == ("", 204) assert len(service_calls) == 1 - template_id, template_info = service_calls[0] + template_id, template_info, current_user, current_tenant_id = service_calls[0] assert template_id == "template-1" + assert current_user is account + assert current_tenant_id == tenant_id assert template_info.name == "Updated template" assert template_info.description == "Updated description" assert template_info.icon_info.model_dump() == { @@ -172,22 +187,28 @@ class TestCustomizedPipelineTemplateApi: "name": "Updated template", "description": "Updated description", } - service_calls: list[tuple[str, PipelineTemplateInfoEntity]] = [] + account = _account() + tenant_id = "tenant-1" + service_calls: list[tuple[str, PipelineTemplateInfoEntity, Account, str]] = [] - def update_template(template_id: str, template_info: PipelineTemplateInfoEntity) -> None: - service_calls.append((template_id, template_info)) + def update_template( + template_id: str, template_info: PipelineTemplateInfoEntity, current_user: Account, current_tenant_id: str + ) -> None: + service_calls.append((template_id, template_info, current_user, current_tenant_id)) with ( app.test_request_context("/rag/pipeline/customized/templates/template-1", method="PATCH", json=payload), patch.object(type(console_ns), "payload", new_callable=PropertyMock, return_value=payload), patch.object(module.RagPipelineService, "update_customized_pipeline_template", side_effect=update_template), ): - response, status = method(api, "template-1") + response, status = method(api, tenant_id, account, "template-1") assert (response, status) == ("", 204) assert len(service_calls) == 1 - template_id, template_info = service_calls[0] + template_id, template_info, current_user, current_tenant_id = service_calls[0] assert template_id == "template-1" + assert current_user is account + assert current_tenant_id == tenant_id assert template_info.icon_info.model_dump() == { "icon": "", "icon_background": None, @@ -198,19 +219,20 @@ class TestCustomizedPipelineTemplateApi: def test_delete_returns_empty_204(self, app: Flask) -> None: api = CustomizedPipelineTemplateApi() method = unwrap(api.delete) - deleted_template_ids: list[str] = [] + tenant_id = "tenant-1" + deleted_templates: list[tuple[str, str]] = [] - def delete_template(template_id: str) -> None: - deleted_template_ids.append(template_id) + def delete_template(template_id: str, current_tenant_id: str) -> None: + deleted_templates.append((template_id, current_tenant_id)) with ( app.test_request_context("/rag/pipeline/customized/templates/template-1", method="DELETE"), patch.object(module.RagPipelineService, "delete_customized_pipeline_template", side_effect=delete_template), ): - response, status = method(api, "template-1") + response, status = method(api, tenant_id, "template-1") assert (response, status) == ("", 204) - assert deleted_template_ids == ["template-1"] + assert deleted_templates == [("template-1", tenant_id)] def test_post_exports_yaml_from_orm_template(self, app: Flask) -> None: api = CustomizedPipelineTemplateApi() @@ -292,21 +314,25 @@ class TestPublishCustomizedPipelineTemplateApi: api = PublishCustomizedPipelineTemplateApi() method = unwrap(api.post) payload = _payload() - service_calls: list[tuple[str, dict[str, object]]] = [] + account = _account() + tenant_id = "tenant-1" + service_calls: list[tuple[str, dict[str, object], Account, str]] = [] class Service: - def publish_customized_pipeline_template(self, pipeline_id: str, data: dict[str, object]) -> None: - service_calls.append((pipeline_id, data)) + def publish_customized_pipeline_template( + self, pipeline_id: str, data: dict[str, object], current_user: Account, current_tenant_id: str + ) -> None: + service_calls.append((pipeline_id, data, current_user, current_tenant_id)) with ( app.test_request_context("/rag/pipelines/pipeline-1/customized/publish", method="POST", json=payload), patch.object(type(console_ns), "payload", new_callable=PropertyMock, return_value=payload), patch.object(module, "RagPipelineService", Service), ): - response, status = method(api, "pipeline-1") + response, status = method(api, tenant_id, account, "pipeline-1") assert (response, status) == ("", 204) - assert service_calls == [("pipeline-1", payload)] + assert service_calls == [("pipeline-1", payload, account, tenant_id)] def test_post_allows_missing_icon_info_for_publish_service_fallback(self, app: Flask) -> None: api = PublishCustomizedPipelineTemplateApi() @@ -315,18 +341,22 @@ class TestPublishCustomizedPipelineTemplateApi: "name": "Published template", "description": "Description", } - service_calls: list[tuple[str, dict[str, object]]] = [] + account = _account() + tenant_id = "tenant-1" + service_calls: list[tuple[str, dict[str, object], Account, str]] = [] class Service: - def publish_customized_pipeline_template(self, pipeline_id: str, data: dict[str, object]) -> None: - service_calls.append((pipeline_id, data)) + def publish_customized_pipeline_template( + self, pipeline_id: str, data: dict[str, object], current_user: Account, current_tenant_id: str + ) -> None: + service_calls.append((pipeline_id, data, current_user, current_tenant_id)) with ( app.test_request_context("/rag/pipelines/pipeline-1/customized/publish", method="POST", json=payload), patch.object(type(console_ns), "payload", new_callable=PropertyMock, return_value=payload), patch.object(module, "RagPipelineService", Service), ): - response, status = method(api, "pipeline-1") + response, status = method(api, tenant_id, account, "pipeline-1") assert (response, status) == ("", 204) assert service_calls == [ @@ -341,5 +371,7 @@ class TestPublishCustomizedPipelineTemplateApi: "icon_url": None, }, }, + account, + tenant_id, ) ] diff --git a/api/tests/unit_tests/controllers/console/datasets/test_hit_testing.py b/api/tests/unit_tests/controllers/console/datasets/test_hit_testing.py index faedd4d7e1d..3de780f3bbb 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_hit_testing.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_hit_testing.py @@ -1,4 +1,5 @@ import uuid +from inspect import unwrap from unittest.mock import PropertyMock, patch import pytest @@ -8,16 +9,10 @@ from werkzeug.exceptions import NotFound from controllers.console import console_ns from controllers.console.datasets.hit_testing import HitTestingApi +from models.account import Account, Tenant, TenantAccountRole from models.dataset import Dataset -def unwrap(func): - """Recursively unwrap decorated functions.""" - while hasattr(func, "__wrapped__"): - func = func.__wrapped__ - return func - - @pytest.fixture def app(): app = Flask("test_hit_testing") @@ -35,6 +30,17 @@ def dataset(): return Dataset(id="dataset-1", tenant_id="tenant-1", name="Dataset", created_by="account-1") +@pytest.fixture +def account() -> Account: + account = Account(name="User", email="user@example.com") + account.id = "account-1" + tenant = Tenant(name="Tenant") + tenant.id = "tenant-1" + account._current_tenant = tenant + account.role = TenantAccountRole.OWNER + return account + + def hit_testing_record() -> dict[str, object]: return { "segment": { @@ -98,7 +104,7 @@ def bypass_decorators(mocker: MockerFixture): class TestHitTestingApi: - def test_hit_testing_success(self, app: Flask, dataset, dataset_id): + def test_hit_testing_success(self, app: Flask, dataset, dataset_id, account: Account): api = HitTestingApi() method = unwrap(api.post) @@ -129,13 +135,13 @@ class TestHitTestingApi: return_value={"query": {"content": "what is vector search"}, "records": []}, ), ): - result = method(api, dataset_id) + result = method(api, account, "tenant-1", dataset_id) assert "query" in result assert "records" in result assert result["records"] == [] - def test_hit_testing_success_with_optional_record_fields(self, app: Flask, dataset, dataset_id): + def test_hit_testing_success_with_optional_record_fields(self, app: Flask, dataset, dataset_id, account: Account): api = HitTestingApi() method = unwrap(api.post) @@ -167,7 +173,7 @@ class TestHitTestingApi: return_value={"query": {"content": payload["query"]}, "records": records}, ), ): - result = method(api, dataset_id) + result = method(api, account, "tenant-1", dataset_id) assert result["query"] == {"content": payload["query"]} assert result["records"][0]["segment"]["keywords"] == [] @@ -175,7 +181,7 @@ class TestHitTestingApi: assert result["records"][0]["files"] == [] assert result["records"][0]["score"] is None - def test_hit_testing_dataset_not_found(self, app: Flask, dataset_id): + def test_hit_testing_dataset_not_found(self, app: Flask, dataset_id, account: Account): api = HitTestingApi() method = unwrap(api.post) @@ -198,9 +204,9 @@ class TestHitTestingApi: ), ): with pytest.raises(NotFound, match="Dataset not found"): - method(api, dataset_id) + method(api, account, "tenant-1", dataset_id) - def test_hit_testing_invalid_args(self, app: Flask, dataset, dataset_id): + def test_hit_testing_invalid_args(self, app: Flask, dataset, dataset_id, account: Account): api = HitTestingApi() method = unwrap(api.post) @@ -228,4 +234,4 @@ class TestHitTestingApi: ), ): with pytest.raises(ValueError, match="Invalid parameters"): - method(api, dataset_id) + method(api, account, "tenant-1", dataset_id) diff --git a/api/tests/unit_tests/controllers/console/datasets/test_hit_testing_base.py b/api/tests/unit_tests/controllers/console/datasets/test_hit_testing_base.py index 072aa559dff..0fcf0df5262 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_hit_testing_base.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_hit_testing_base.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest from werkzeug.exceptions import Forbidden, InternalServerError, NotFound @@ -21,7 +21,7 @@ from core.errors.error import ( QuotaExceededError, ) from graphon.model_runtime.errors.invoke import InvokeError -from models.account import Account +from models.account import Account, Tenant, TenantAccountRole from models.dataset import Dataset from services.dataset_service import DatasetService from services.hit_testing_service import HitTestingService @@ -29,19 +29,15 @@ from services.hit_testing_service import HitTestingService @pytest.fixture def account(): - acc = MagicMock(spec=Account) + acc = Account(name="User", email="user@example.com") + acc.id = "account-1" + tenant = Tenant(name="Tenant") + tenant.id = "tenant-1" + acc._current_tenant = tenant + acc.role = TenantAccountRole.OWNER return acc -@pytest.fixture(autouse=True) -def patch_current_user(mocker, account): - """Patch current_user to a valid Account.""" - mocker.patch( - "controllers.console.datasets.hit_testing_base.current_user", - account, - ) - - @pytest.fixture def dataset(): return Dataset(id="dataset-1", tenant_id="tenant-1", name="Dataset", created_by="account-1") @@ -86,7 +82,7 @@ def hit_testing_record() -> dict[str, object]: class TestGetAndValidateDataset: - def test_success(self, dataset): + def test_success(self, dataset, account): with ( patch.object( DatasetService, @@ -98,20 +94,20 @@ class TestGetAndValidateDataset: "check_dataset_permission", ), ): - result = DatasetsHitTestingBase.get_and_validate_dataset("dataset-1") + result = DatasetsHitTestingBase.get_and_validate_dataset("dataset-1", account, "tenant-1") assert result == dataset - def test_dataset_not_found(self): + def test_dataset_not_found(self, account): with patch.object( DatasetService, "get_dataset", return_value=None, ): with pytest.raises(NotFound, match="Dataset not found"): - DatasetsHitTestingBase.get_and_validate_dataset("dataset-1") + DatasetsHitTestingBase.get_and_validate_dataset("dataset-1", account, "tenant-1") - def test_permission_denied(self, dataset): + def test_permission_denied(self, dataset, account): with ( patch.object( DatasetService, @@ -125,7 +121,7 @@ class TestGetAndValidateDataset: ), ): with pytest.raises(Forbidden, match="no access"): - DatasetsHitTestingBase.get_and_validate_dataset("dataset-1") + DatasetsHitTestingBase.get_and_validate_dataset("dataset-1", account, "tenant-1") class TestHitTestingArgsCheck: @@ -164,7 +160,7 @@ class TestParseArgs: class TestPerformHitTesting: - def test_success(self, dataset): + def test_success(self, dataset, account): response = { "query": {"content": "hello"}, "records": [], @@ -175,12 +171,12 @@ class TestPerformHitTesting: "retrieve", return_value=response, ): - result = DatasetsHitTestingBase.perform_hit_testing(dataset, {"query": "hello"}) + result = DatasetsHitTestingBase.perform_hit_testing(dataset, {"query": "hello"}, account, "tenant-1") assert result["query"] == {"content": "hello"} assert result["records"] == [] - def test_success_prepares_nullable_list_fields(self, dataset): + def test_success_prepares_nullable_list_fields(self, dataset, account): response = { "query": {"content": "hello"}, "records": [hit_testing_record()], @@ -191,7 +187,7 @@ class TestPerformHitTesting: "retrieve", return_value=response, ): - result = DatasetsHitTestingBase.perform_hit_testing(dataset, {"query": "hello"}) + result = DatasetsHitTestingBase.perform_hit_testing(dataset, {"query": "hello"}, account, "tenant-1") assert result["query"] == {"content": "hello"} record = result["records"][0] @@ -203,7 +199,7 @@ class TestPerformHitTesting: assert record["tsne_position"] is None assert record["summary"] is None - def test_invalid_query_response_raises_value_error(self, dataset): + def test_invalid_query_response_raises_value_error(self, dataset, account): with ( patch.object( HitTestingService, @@ -212,7 +208,7 @@ class TestPerformHitTesting: ), pytest.raises(ValueError, match="Invalid hit testing query response"), ): - DatasetsHitTestingBase.perform_hit_testing(dataset, {"query": "hello"}) + DatasetsHitTestingBase.perform_hit_testing(dataset, {"query": "hello"}, account, "tenant-1") def test_invalid_records_response_raises_value_error(self): with pytest.raises(ValueError, match="Invalid hit testing records response"): @@ -222,74 +218,74 @@ class TestPerformHitTesting: with pytest.raises(ValueError, match="Invalid hit testing record response"): DatasetsHitTestingBase._prepare_hit_testing_records(["record"]) - def test_index_not_initialized(self, dataset): + def test_index_not_initialized(self, dataset, account): with patch.object( HitTestingService, "retrieve", side_effect=services.errors.index.IndexNotInitializedError(), ): with pytest.raises(DatasetNotInitializedError): - DatasetsHitTestingBase.perform_hit_testing(dataset, {"query": "hello"}) + DatasetsHitTestingBase.perform_hit_testing(dataset, {"query": "hello"}, account, "tenant-1") - def test_provider_token_not_init(self, dataset): + def test_provider_token_not_init(self, dataset, account): with patch.object( HitTestingService, "retrieve", side_effect=ProviderTokenNotInitError("token missing"), ): with pytest.raises(ProviderNotInitializeError): - DatasetsHitTestingBase.perform_hit_testing(dataset, {"query": "hello"}) + DatasetsHitTestingBase.perform_hit_testing(dataset, {"query": "hello"}, account, "tenant-1") - def test_quota_exceeded(self, dataset): + def test_quota_exceeded(self, dataset, account): with patch.object( HitTestingService, "retrieve", side_effect=QuotaExceededError(), ): with pytest.raises(ProviderQuotaExceededError): - DatasetsHitTestingBase.perform_hit_testing(dataset, {"query": "hello"}) + DatasetsHitTestingBase.perform_hit_testing(dataset, {"query": "hello"}, account, "tenant-1") - def test_model_not_supported(self, dataset): + def test_model_not_supported(self, dataset, account): with patch.object( HitTestingService, "retrieve", side_effect=ModelCurrentlyNotSupportError(), ): with pytest.raises(ProviderModelCurrentlyNotSupportError): - DatasetsHitTestingBase.perform_hit_testing(dataset, {"query": "hello"}) + DatasetsHitTestingBase.perform_hit_testing(dataset, {"query": "hello"}, account, "tenant-1") - def test_llm_bad_request(self, dataset): + def test_llm_bad_request(self, dataset, account): with patch.object( HitTestingService, "retrieve", side_effect=LLMBadRequestError("bad request"), ): with pytest.raises(ProviderNotInitializeError): - DatasetsHitTestingBase.perform_hit_testing(dataset, {"query": "hello"}) + DatasetsHitTestingBase.perform_hit_testing(dataset, {"query": "hello"}, account, "tenant-1") - def test_invoke_error(self, dataset): + def test_invoke_error(self, dataset, account): with patch.object( HitTestingService, "retrieve", side_effect=InvokeError("invoke failed"), ): with pytest.raises(CompletionRequestError): - DatasetsHitTestingBase.perform_hit_testing(dataset, {"query": "hello"}) + DatasetsHitTestingBase.perform_hit_testing(dataset, {"query": "hello"}, account, "tenant-1") - def test_value_error(self, dataset): + def test_value_error(self, dataset, account): with patch.object( HitTestingService, "retrieve", side_effect=ValueError("bad args"), ): with pytest.raises(ValueError, match="bad args"): - DatasetsHitTestingBase.perform_hit_testing(dataset, {"query": "hello"}) + DatasetsHitTestingBase.perform_hit_testing(dataset, {"query": "hello"}, account, "tenant-1") - def test_unexpected_error(self, dataset): + def test_unexpected_error(self, dataset, account): with patch.object( HitTestingService, "retrieve", side_effect=Exception("boom"), ): with pytest.raises(InternalServerError, match="boom"): - DatasetsHitTestingBase.perform_hit_testing(dataset, {"query": "hello"}) + DatasetsHitTestingBase.perform_hit_testing(dataset, {"query": "hello"}, account, "tenant-1") diff --git a/api/tests/unit_tests/controllers/console/datasets/test_metadata.py b/api/tests/unit_tests/controllers/console/datasets/test_metadata.py index 3015ed6604b..785c0ac09f2 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_metadata.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_metadata.py @@ -1,4 +1,5 @@ import uuid +from inspect import unwrap from unittest.mock import MagicMock, PropertyMock, patch import pytest @@ -14,6 +15,7 @@ from controllers.console.datasets.metadata import ( DatasetMetadataCreateApi, DocumentMetadataEditApi, ) +from models.account import Account from services.dataset_service import DatasetService from services.entities.knowledge_entities.knowledge_entities import ( MetadataArgs, @@ -22,13 +24,6 @@ from services.entities.knowledge_entities.knowledge_entities import ( from services.metadata_service import MetadataService -def unwrap(func): - """Recursively unwrap decorated functions.""" - while hasattr(func, "__wrapped__"): - func = func.__wrapped__ - return func - - @pytest.fixture def app(): app = Flask("test_dataset_metadata") @@ -37,8 +32,8 @@ def app(): @pytest.fixture -def current_user(): - user = MagicMock() +def current_user() -> Account: + user = Account(name="Test User", email="test@example.com") user.id = "user-1" return user @@ -116,7 +111,7 @@ class TestDatasetMetadataCreateApi: return_value={"id": "m1", "type": "string", "name": "author"}, ), ): - result, status = method(api, current_user, dataset_id) + result, status = method(api, "tenant-1", current_user, dataset_id) assert status == 201 assert result["type"] == "string" @@ -151,7 +146,7 @@ class TestDatasetMetadataCreateApi: ), ): with pytest.raises(NotFound, match="Dataset not found"): - method(api, current_user, dataset_id) + method(api, "tenant-1", current_user, dataset_id) class TestDatasetMetadataGetApi: @@ -227,7 +222,7 @@ class TestDatasetMetadataApi: return_value={"id": "m1", "type": "string", "name": "updated-name"}, ), ): - result, status = method(api, current_user, dataset_id, metadata_id) + result, status = method(api, "tenant-1", current_user, dataset_id, metadata_id) assert status == 200 assert result["type"] == "string" diff --git a/api/tests/unit_tests/controllers/console/explore/test_completion.py b/api/tests/unit_tests/controllers/console/explore/test_completion.py index 420392f1dfa..8b9121c4d7f 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_completion.py +++ b/api/tests/unit_tests/controllers/console/explore/test_completion.py @@ -1,3 +1,4 @@ +from inspect import unwrap from unittest.mock import MagicMock, PropertyMock, patch import pytest @@ -15,15 +16,11 @@ from models.model import AppMode from services.errors.llm import InvokeRateLimitError -def unwrap(func): - while hasattr(func, "__wrapped__"): - func = func.__wrapped__ - return func - - @pytest.fixture def user(): - return MagicMock(spec=Account) + account = Account(name="User", email="user.com") + account.id = "uid" + return account @pytest.fixture @@ -59,7 +56,6 @@ class TestCompletionApi: with ( app.test_request_context("/", json={}), payload_patch, - patch.object(completion_module, "current_user", user), patch.object( completion_module.AppGenerateService, "generate", @@ -71,18 +67,18 @@ class TestCompletionApi: return_value=("ok", 200), ), ): - result = method(completion_app) + result = method(api, user, completion_app) assert result == ("ok", 200) - def test_post_wrong_app_mode(self): + def test_post_wrong_app_mode(self, user): api = completion_module.CompletionApi() method = unwrap(api.post) installed_app = MagicMock(app=MagicMock(mode=AppMode.CHAT)) with pytest.raises(NotCompletionAppError): - method(installed_app) + method(api, user, installed_app) def test_conversation_completed(self, app: Flask, completion_app, user, payload_patch): api = completion_module.CompletionApi() @@ -91,7 +87,6 @@ class TestCompletionApi: with ( app.test_request_context("/", json={}), payload_patch, - patch.object(completion_module, "current_user", user), patch.object( completion_module.AppGenerateService, "generate", @@ -99,7 +94,7 @@ class TestCompletionApi: ), ): with pytest.raises(ConversationCompletedError): - method(completion_app) + method(api, user, completion_app) def test_internal_error(self, app: Flask, completion_app, user, payload_patch): api = completion_module.CompletionApi() @@ -108,7 +103,6 @@ class TestCompletionApi: with ( app.test_request_context("/", json={}), payload_patch, - patch.object(completion_module, "current_user", user), patch.object( completion_module.AppGenerateService, "generate", @@ -116,7 +110,7 @@ class TestCompletionApi: ), ): with pytest.raises(InternalServerError): - method(completion_app) + method(api, user, completion_app) def test_conversation_not_exists(self, app: Flask, completion_app, user, payload_patch): api = completion_module.CompletionApi() @@ -125,7 +119,6 @@ class TestCompletionApi: with ( app.test_request_context("/", json={}), payload_patch, - patch.object(completion_module, "current_user", user), patch.object( completion_module.AppGenerateService, "generate", @@ -133,7 +126,7 @@ class TestCompletionApi: ), ): with pytest.raises(completion_module.NotFound): - method(completion_app) + method(api, user, completion_app) def test_app_unavailable(self, app: Flask, completion_app, user, payload_patch): api = completion_module.CompletionApi() @@ -142,7 +135,6 @@ class TestCompletionApi: with ( app.test_request_context("/", json={}), payload_patch, - patch.object(completion_module, "current_user", user), patch.object( completion_module.AppGenerateService, "generate", @@ -150,7 +142,7 @@ class TestCompletionApi: ), ): with pytest.raises(completion_module.AppUnavailableError): - method(completion_app) + method(api, user, completion_app) def test_provider_not_initialized(self, app: Flask, completion_app, user, payload_patch): api = completion_module.CompletionApi() @@ -159,7 +151,6 @@ class TestCompletionApi: with ( app.test_request_context("/", json={}), payload_patch, - patch.object(completion_module, "current_user", user), patch.object( completion_module.AppGenerateService, "generate", @@ -167,7 +158,7 @@ class TestCompletionApi: ), ): with pytest.raises(completion_module.ProviderNotInitializeError): - method(completion_app) + method(api, user, completion_app) def test_quota_exceeded(self, app: Flask, completion_app, user, payload_patch): api = completion_module.CompletionApi() @@ -176,7 +167,6 @@ class TestCompletionApi: with ( app.test_request_context("/", json={}), payload_patch, - patch.object(completion_module, "current_user", user), patch.object( completion_module.AppGenerateService, "generate", @@ -184,7 +174,7 @@ class TestCompletionApi: ), ): with pytest.raises(completion_module.ProviderQuotaExceededError): - method(completion_app) + method(api, user, completion_app) def test_model_not_supported(self, app: Flask, completion_app, user, payload_patch): api = completion_module.CompletionApi() @@ -193,7 +183,6 @@ class TestCompletionApi: with ( app.test_request_context("/", json={}), payload_patch, - patch.object(completion_module, "current_user", user), patch.object( completion_module.AppGenerateService, "generate", @@ -201,7 +190,7 @@ class TestCompletionApi: ), ): with pytest.raises(completion_module.ProviderModelCurrentlyNotSupportError): - method(completion_app) + method(api, user, completion_app) def test_invoke_error(self, app: Flask, completion_app, user, payload_patch): api = completion_module.CompletionApi() @@ -210,7 +199,6 @@ class TestCompletionApi: with ( app.test_request_context("/", json={}), payload_patch, - patch.object(completion_module, "current_user", user), patch.object( completion_module.AppGenerateService, "generate", @@ -218,7 +206,7 @@ class TestCompletionApi: ), ): with pytest.raises(completion_module.CompletionRequestError): - method(completion_app) + method(api, user, completion_app) class TestCompletionStopApi: @@ -250,7 +238,6 @@ class TestChatApi: with ( app.test_request_context("/", json={}), payload_patch, - patch.object(completion_module, "current_user", user), patch.object( completion_module.AppGenerateService, "generate", @@ -262,18 +249,18 @@ class TestChatApi: return_value=("ok", 200), ), ): - result = method(chat_app) + result = method(api, user, chat_app) assert result == ("ok", 200) - def test_post_not_chat_app(self): + def test_post_not_chat_app(self, user): api = completion_module.ChatApi() method = unwrap(api.post) installed_app = MagicMock(app=MagicMock(mode=AppMode.COMPLETION)) with pytest.raises(NotChatAppError): - method(installed_app) + method(api, user, installed_app) def test_rate_limit_error(self, app: Flask, chat_app, user, payload_patch): api = completion_module.ChatApi() @@ -282,7 +269,6 @@ class TestChatApi: with ( app.test_request_context("/", json={}), payload_patch, - patch.object(completion_module, "current_user", user), patch.object( completion_module.AppGenerateService, "generate", @@ -290,7 +276,7 @@ class TestChatApi: ), ): with pytest.raises(InvokeRateLimitHttpError): - method(chat_app) + method(api, user, chat_app) def test_conversation_completed_chat(self, app: Flask, chat_app, user, payload_patch): api = completion_module.ChatApi() @@ -299,7 +285,6 @@ class TestChatApi: with ( app.test_request_context("/", json={}), payload_patch, - patch.object(completion_module, "current_user", user), patch.object( completion_module.AppGenerateService, "generate", @@ -307,7 +292,7 @@ class TestChatApi: ), ): with pytest.raises(ConversationCompletedError): - method(chat_app) + method(api, user, chat_app) def test_conversation_not_exists_chat(self, app: Flask, chat_app, user, payload_patch): api = completion_module.ChatApi() @@ -316,7 +301,6 @@ class TestChatApi: with ( app.test_request_context("/", json={}), payload_patch, - patch.object(completion_module, "current_user", user), patch.object( completion_module.AppGenerateService, "generate", @@ -324,7 +308,7 @@ class TestChatApi: ), ): with pytest.raises(completion_module.NotFound): - method(chat_app) + method(api, user, chat_app) def test_app_unavailable_chat(self, app: Flask, chat_app, user, payload_patch): api = completion_module.ChatApi() @@ -333,7 +317,6 @@ class TestChatApi: with ( app.test_request_context("/", json={}), payload_patch, - patch.object(completion_module, "current_user", user), patch.object( completion_module.AppGenerateService, "generate", @@ -341,7 +324,7 @@ class TestChatApi: ), ): with pytest.raises(completion_module.AppUnavailableError): - method(chat_app) + method(api, user, chat_app) def test_provider_not_initialized_chat(self, app: Flask, chat_app, user, payload_patch): api = completion_module.ChatApi() @@ -350,7 +333,6 @@ class TestChatApi: with ( app.test_request_context("/", json={}), payload_patch, - patch.object(completion_module, "current_user", user), patch.object( completion_module.AppGenerateService, "generate", @@ -358,7 +340,7 @@ class TestChatApi: ), ): with pytest.raises(completion_module.ProviderNotInitializeError): - method(chat_app) + method(api, user, chat_app) def test_quota_exceeded_chat(self, app: Flask, chat_app, user, payload_patch): api = completion_module.ChatApi() @@ -367,7 +349,6 @@ class TestChatApi: with ( app.test_request_context("/", json={}), payload_patch, - patch.object(completion_module, "current_user", user), patch.object( completion_module.AppGenerateService, "generate", @@ -375,7 +356,7 @@ class TestChatApi: ), ): with pytest.raises(completion_module.ProviderQuotaExceededError): - method(chat_app) + method(api, user, chat_app) def test_model_not_supported_chat(self, app: Flask, chat_app, user, payload_patch): api = completion_module.ChatApi() @@ -384,7 +365,6 @@ class TestChatApi: with ( app.test_request_context("/", json={}), payload_patch, - patch.object(completion_module, "current_user", user), patch.object( completion_module.AppGenerateService, "generate", @@ -392,7 +372,7 @@ class TestChatApi: ), ): with pytest.raises(completion_module.ProviderModelCurrentlyNotSupportError): - method(chat_app) + method(api, user, chat_app) def test_invoke_error_chat(self, app: Flask, chat_app, user, payload_patch): api = completion_module.ChatApi() @@ -401,7 +381,6 @@ class TestChatApi: with ( app.test_request_context("/", json={}), payload_patch, - patch.object(completion_module, "current_user", user), patch.object( completion_module.AppGenerateService, "generate", @@ -409,7 +388,7 @@ class TestChatApi: ), ): with pytest.raises(completion_module.CompletionRequestError): - method(chat_app) + method(api, user, chat_app) def test_internal_error_chat(self, app: Flask, chat_app, user, payload_patch): api = completion_module.ChatApi() @@ -418,7 +397,6 @@ class TestChatApi: with ( app.test_request_context("/", json={}), payload_patch, - patch.object(completion_module, "current_user", user), patch.object( completion_module.AppGenerateService, "generate", @@ -426,7 +404,7 @@ class TestChatApi: ), ): with pytest.raises(InternalServerError): - method(chat_app) + method(api, user, chat_app) class TestChatStopApi: diff --git a/api/tests/unit_tests/controllers/console/explore/test_recommended_app.py b/api/tests/unit_tests/controllers/console/explore/test_recommended_app.py index 0121d5c424b..ef08aa1b36a 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_recommended_app.py +++ b/api/tests/unit_tests/controllers/console/explore/test_recommended_app.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import ANY, patch from flask import Flask @@ -37,7 +37,7 @@ class TestRecommendedAppListApi: ): result = method(api, make_account("fr-FR")) - service_mock.assert_called_once_with("en-US") + service_mock.assert_called_once_with(ANY, "en-US") assert result == result_data def test_get_fallback_to_user_language(self, app: Flask): @@ -56,7 +56,7 @@ class TestRecommendedAppListApi: ): result = method(api, make_account("fr-FR")) - service_mock.assert_called_once_with("fr-FR") + service_mock.assert_called_once_with(ANY, "fr-FR") assert result == result_data def test_get_fallback_to_default_language(self, app: Flask): @@ -75,7 +75,47 @@ class TestRecommendedAppListApi: ): result = method(api, make_account(None)) - service_mock.assert_called_once_with(module.languages[0]) + service_mock.assert_called_once_with(ANY, module.languages[0]) + assert result == result_data + + +class TestLearnDifyAppListApi: + def test_get_with_language_param(self, app: Flask): + api = module.LearnDifyAppListApi() + method = unwrap(api.get) + + result_data = {"recommended_apps": []} + + with ( + app.test_request_context("/", query_string={"language": "en-US"}), + patch.object( + module.RecommendedAppService, + "get_learn_dify_apps", + return_value=result_data, + ) as service_mock, + ): + result = method(api, make_account("fr-FR")) + + service_mock.assert_called_once_with(ANY, "en-US") + assert result == result_data + + def test_get_fallback_to_user_language(self, app: Flask): + api = module.LearnDifyAppListApi() + method = unwrap(api.get) + + result_data = {"recommended_apps": []} + + with ( + app.test_request_context("/", query_string={"language": "invalid"}), + patch.object( + module.RecommendedAppService, + "get_learn_dify_apps", + return_value=result_data, + ) as service_mock, + ): + result = method(api, make_account("fr-FR")) + + service_mock.assert_called_once_with(ANY, "fr-FR") assert result == result_data @@ -96,7 +136,7 @@ class TestRecommendedAppApi: ): result = method(api, "11111111-1111-1111-1111-111111111111") - service_mock.assert_called_once_with("11111111-1111-1111-1111-111111111111") + service_mock.assert_called_once_with(ANY, "11111111-1111-1111-1111-111111111111") assert result == result_data @@ -144,3 +184,29 @@ class TestRecommendedAppResponseModels: assert response["recommended_apps"][0]["app_id"] == "app-1" assert response["recommended_apps"][0]["categories"] == ["cat", "other"] assert response["categories"] == ["cat"] + + def test_learn_dify_app_list_response_serialization(self): + response = module.LearnDifyAppListResponse.model_validate( + { + "recommended_apps": [ + { + "app": { + "id": "app-1", + "name": "App", + "mode": "chat", + "icon": "icon.png", + "icon_type": "emoji", + "icon_background": "#fff", + }, + "app_id": "app-1", + "description": "desc", + "categories": ["Workflow"], + "position": 1, + "is_listed": True, + } + ], + } + ).model_dump(mode="json") + + assert response["recommended_apps"][0]["app_id"] == "app-1" + assert response["recommended_apps"][0]["categories"] == ["Workflow"] 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 641209d1deb..be68a3beed6 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,6 @@ +from inspect import unwrap as inspect_unwrap from io import BytesIO +from typing import Any from unittest.mock import MagicMock, patch from uuid import uuid4 @@ -33,22 +35,24 @@ from models.model import AppMode from services.errors.conversation import ConversationNotExistsError from services.errors.llm import InvokeRateLimitError - -def unwrap(func): - while hasattr(func, "__wrapped__"): - func = func.__wrapped__ - return func +unwrap: Any = inspect_unwrap @pytest.fixture -def account(): - acc = MagicMock(spec=Account) +def account() -> Account: + acc = Account(name="User", email="user@example.com") 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(): +def trial_app_chat() -> MagicMock: app = MagicMock() app.id = "a-chat" app.mode = AppMode.CHAT @@ -56,7 +60,7 @@ def trial_app_chat(): @pytest.fixture -def trial_app_completion(): +def trial_app_completion() -> MagicMock: app = MagicMock() app.id = "a-comp" app.mode = AppMode.COMPLETION @@ -64,7 +68,7 @@ def trial_app_completion(): @pytest.fixture -def trial_app_workflow(): +def trial_app_workflow() -> MagicMock: app = MagicMock() app.id = "a-workflow" app.mode = AppMode.WORKFLOW @@ -72,7 +76,7 @@ def trial_app_workflow(): @pytest.fixture -def valid_parameters(): +def valid_parameters() -> dict[str, object]: return { "user_input_form": [], "system_parameters": {}, @@ -88,41 +92,39 @@ def valid_parameters(): } -def test_trial_workflow_uses_trial_scoped_simple_account_model(): +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") class TestTrialAppWorkflowRunApi: - def test_not_workflow_app(self, app: Flask): + def test_not_workflow_app(self, app: Flask, account: Account) -> None: api = module.TrialAppWorkflowRunApi() method = unwrap(api.post) with app.test_request_context("/"): with pytest.raises(NotWorkflowAppError): - method(api, MagicMock(mode=AppMode.CHAT)) + method(api, account, MagicMock(mode=AppMode.CHAT)) - def test_success(self, app: Flask, trial_app_workflow, account): + def test_success(self, app: Flask, trial_app_workflow: MagicMock, account: Account) -> None: api = module.TrialAppWorkflowRunApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"inputs": {}}), - patch.object(module, "current_user", account), patch.object(module.AppGenerateService, "generate", return_value=MagicMock()), patch.object(module.RecommendedAppService, "add_trial_app_record"), ): - result = method(api, trial_app_workflow) + result = method(api, account, trial_app_workflow) assert result is not None - def test_workflow_provider_not_init(self, app: Flask, trial_app_workflow, account): + def test_workflow_provider_not_init(self, app: Flask, trial_app_workflow: MagicMock, account: Account) -> None: api = module.TrialAppWorkflowRunApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"inputs": {}}), - patch.object(module, "current_user", account), patch.object( module.AppGenerateService, "generate", @@ -130,15 +132,14 @@ class TestTrialAppWorkflowRunApi: ), ): with pytest.raises(ProviderNotInitializeError): - method(api, trial_app_workflow) + method(api, account, trial_app_workflow) - def test_workflow_quota_exceeded(self, app: Flask, trial_app_workflow, account): + def test_workflow_quota_exceeded(self, app: Flask, trial_app_workflow: MagicMock, account: Account) -> None: api = module.TrialAppWorkflowRunApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"inputs": {}}), - patch.object(module, "current_user", account), patch.object( module.AppGenerateService, "generate", @@ -146,15 +147,14 @@ class TestTrialAppWorkflowRunApi: ), ): with pytest.raises(ProviderQuotaExceededError): - method(api, trial_app_workflow) + method(api, account, trial_app_workflow) - def test_workflow_model_not_support(self, app: Flask, trial_app_workflow, account): + def test_workflow_model_not_support(self, app: Flask, trial_app_workflow: MagicMock, account: Account) -> None: api = module.TrialAppWorkflowRunApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"inputs": {}}), - patch.object(module, "current_user", account), patch.object( module.AppGenerateService, "generate", @@ -162,15 +162,14 @@ class TestTrialAppWorkflowRunApi: ), ): with pytest.raises(ProviderModelCurrentlyNotSupportError): - method(api, trial_app_workflow) + method(api, account, trial_app_workflow) - def test_workflow_invoke_error(self, app: Flask, trial_app_workflow, account): + def test_workflow_invoke_error(self, app: Flask, trial_app_workflow: MagicMock, account: Account) -> None: api = module.TrialAppWorkflowRunApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"inputs": {}}), - patch.object(module, "current_user", account), patch.object( module.AppGenerateService, "generate", @@ -178,15 +177,14 @@ class TestTrialAppWorkflowRunApi: ), ): with pytest.raises(CompletionRequestError): - method(api, trial_app_workflow) + method(api, account, trial_app_workflow) - def test_workflow_rate_limit_error(self, app: Flask, trial_app_workflow, account): + def test_workflow_rate_limit_error(self, app: Flask, trial_app_workflow: MagicMock, account: Account) -> None: api = module.TrialAppWorkflowRunApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"inputs": {}}), - patch.object(module, "current_user", account), patch.object( module.AppGenerateService, "generate", @@ -194,15 +192,14 @@ class TestTrialAppWorkflowRunApi: ), ): with pytest.raises(InvokeRateLimitHttpError): - method(api, trial_app_workflow) + method(api, account, trial_app_workflow) - def test_workflow_value_error(self, app: Flask, trial_app_workflow, account): + def test_workflow_value_error(self, app: Flask, trial_app_workflow: MagicMock, account: Account) -> None: api = module.TrialAppWorkflowRunApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"inputs": {}, "files": []}), - patch.object(module, "current_user", account), patch.object( module.AppGenerateService, "generate", @@ -210,15 +207,14 @@ class TestTrialAppWorkflowRunApi: ), ): with pytest.raises(ValueError): - method(api, trial_app_workflow) + method(api, account, trial_app_workflow) - def test_workflow_generic_exception(self, app: Flask, trial_app_workflow, account): + def test_workflow_generic_exception(self, app: Flask, trial_app_workflow: MagicMock, account: Account) -> None: api = module.TrialAppWorkflowRunApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"inputs": {}, "files": []}), - patch.object(module, "current_user", account), patch.object( module.AppGenerateService, "generate", @@ -226,39 +222,37 @@ class TestTrialAppWorkflowRunApi: ), ): with pytest.raises(InternalServerError): - method(api, trial_app_workflow) + method(api, account, trial_app_workflow) class TestTrialChatApi: - def test_not_chat_app(self, app: Flask): + def test_not_chat_app(self, app: Flask, account: Account) -> None: api = module.TrialChatApi() method = unwrap(api.post) with app.test_request_context("/", json={"inputs": {}, "query": "hi"}): with pytest.raises(NotChatAppError): - method(api, MagicMock(mode="completion")) + method(api, account, MagicMock(mode="completion")) - def test_success(self, app: Flask, trial_app_chat, account): + def test_success(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: api = module.TrialChatApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"inputs": {}, "query": "hi"}), - patch.object(module, "current_user", account), patch.object(module.AppGenerateService, "generate", return_value=MagicMock()), patch.object(module.RecommendedAppService, "add_trial_app_record"), ): - result = method(api, trial_app_chat) + result = method(api, account, trial_app_chat) assert result is not None - def test_chat_conversation_not_exists(self, app: Flask, trial_app_chat, account): + def test_chat_conversation_not_exists(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: api = module.TrialChatApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"inputs": {}, "query": "hi"}), - patch.object(module, "current_user", account), patch.object( module.AppGenerateService, "generate", @@ -266,15 +260,14 @@ class TestTrialChatApi: ), ): with pytest.raises(NotFound): - method(api, trial_app_chat) + method(api, account, trial_app_chat) - def test_chat_conversation_completed(self, app: Flask, trial_app_chat, account): + def test_chat_conversation_completed(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: api = module.TrialChatApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"inputs": {}, "query": "hi"}), - patch.object(module, "current_user", account), patch.object( module.AppGenerateService, "generate", @@ -282,15 +275,14 @@ class TestTrialChatApi: ), ): with pytest.raises(ConversationCompletedError): - method(api, trial_app_chat) + method(api, account, trial_app_chat) - def test_chat_app_config_broken(self, app: Flask, trial_app_chat, account): + def test_chat_app_config_broken(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: api = module.TrialChatApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"inputs": {}, "query": "hi"}), - patch.object(module, "current_user", account), patch.object( module.AppGenerateService, "generate", @@ -298,15 +290,14 @@ class TestTrialChatApi: ), ): with pytest.raises(AppUnavailableError): - method(api, trial_app_chat) + method(api, account, trial_app_chat) - def test_chat_provider_not_init(self, app: Flask, trial_app_chat, account): + def test_chat_provider_not_init(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: api = module.TrialChatApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"inputs": {}, "query": "hi"}), - patch.object(module, "current_user", account), patch.object( module.AppGenerateService, "generate", @@ -314,15 +305,14 @@ class TestTrialChatApi: ), ): with pytest.raises(ProviderNotInitializeError): - method(api, trial_app_chat) + method(api, account, trial_app_chat) - def test_chat_quota_exceeded(self, app: Flask, trial_app_chat, account): + def test_chat_quota_exceeded(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: api = module.TrialChatApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"inputs": {}, "query": "hi"}), - patch.object(module, "current_user", account), patch.object( module.AppGenerateService, "generate", @@ -330,15 +320,14 @@ class TestTrialChatApi: ), ): with pytest.raises(ProviderQuotaExceededError): - method(api, trial_app_chat) + method(api, account, trial_app_chat) - def test_chat_model_not_support(self, app: Flask, trial_app_chat, account): + def test_chat_model_not_support(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: api = module.TrialChatApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"inputs": {}, "query": "hi"}), - patch.object(module, "current_user", account), patch.object( module.AppGenerateService, "generate", @@ -346,15 +335,14 @@ class TestTrialChatApi: ), ): with pytest.raises(ProviderModelCurrentlyNotSupportError): - method(api, trial_app_chat) + method(api, account, trial_app_chat) - def test_chat_invoke_error(self, app: Flask, trial_app_chat, account): + def test_chat_invoke_error(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: api = module.TrialChatApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"inputs": {}, "query": "hi"}), - patch.object(module, "current_user", account), patch.object( module.AppGenerateService, "generate", @@ -362,15 +350,14 @@ class TestTrialChatApi: ), ): with pytest.raises(CompletionRequestError): - method(api, trial_app_chat) + method(api, account, trial_app_chat) - def test_chat_rate_limit_error(self, app: Flask, trial_app_chat, account): + def test_chat_rate_limit_error(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: api = module.TrialChatApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"inputs": {}, "query": "hi"}), - patch.object(module, "current_user", account), patch.object( module.AppGenerateService, "generate", @@ -378,15 +365,14 @@ class TestTrialChatApi: ), ): with pytest.raises(InvokeRateLimitHttpError): - method(api, trial_app_chat) + method(api, account, trial_app_chat) - def test_chat_value_error(self, app: Flask, trial_app_chat, account): + def test_chat_value_error(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: api = module.TrialChatApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"inputs": {}, "query": "hi"}), - patch.object(module, "current_user", account), patch.object( module.AppGenerateService, "generate", @@ -394,15 +380,14 @@ class TestTrialChatApi: ), ): with pytest.raises(ValueError): - method(api, trial_app_chat) + method(api, account, trial_app_chat) - def test_chat_generic_exception(self, app: Flask, trial_app_chat, account): + def test_chat_generic_exception(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: api = module.TrialChatApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"inputs": {}, "query": "hi"}), - patch.object(module, "current_user", account), patch.object( module.AppGenerateService, "generate", @@ -410,39 +395,37 @@ class TestTrialChatApi: ), ): with pytest.raises(InternalServerError): - method(api, trial_app_chat) + method(api, account, trial_app_chat) class TestTrialCompletionApi: - def test_not_completion_app(self, app: Flask): + def test_not_completion_app(self, app: Flask, account: Account) -> None: api = module.TrialCompletionApi() method = unwrap(api.post) with app.test_request_context("/", json={"inputs": {}, "query": ""}): with pytest.raises(NotCompletionAppError): - method(api, MagicMock(mode=AppMode.CHAT)) + method(api, account, MagicMock(mode=AppMode.CHAT)) - def test_success(self, app: Flask, trial_app_completion, account): + def test_success(self, app: Flask, trial_app_completion: MagicMock, account: Account) -> None: api = module.TrialCompletionApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"inputs": {}, "query": ""}), - patch.object(module, "current_user", account), patch.object(module.AppGenerateService, "generate", return_value=MagicMock()), patch.object(module.RecommendedAppService, "add_trial_app_record"), ): - result = method(api, trial_app_completion) + result = method(api, account, trial_app_completion) assert result is not None - def test_completion_app_config_broken(self, app: Flask, trial_app_completion, account): + def test_completion_app_config_broken(self, app: Flask, trial_app_completion: MagicMock, account: Account) -> None: api = module.TrialCompletionApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"inputs": {}, "query": ""}), - patch.object(module, "current_user", account), patch.object( module.AppGenerateService, "generate", @@ -450,15 +433,14 @@ class TestTrialCompletionApi: ), ): with pytest.raises(AppUnavailableError): - method(api, trial_app_completion) + method(api, account, trial_app_completion) - def test_completion_provider_not_init(self, app: Flask, trial_app_completion, account): + def test_completion_provider_not_init(self, app: Flask, trial_app_completion: MagicMock, account: Account) -> None: api = module.TrialCompletionApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"inputs": {}, "query": ""}), - patch.object(module, "current_user", account), patch.object( module.AppGenerateService, "generate", @@ -466,15 +448,14 @@ class TestTrialCompletionApi: ), ): with pytest.raises(ProviderNotInitializeError): - method(api, trial_app_completion) + method(api, account, trial_app_completion) - def test_completion_quota_exceeded(self, app: Flask, trial_app_completion, account): + def test_completion_quota_exceeded(self, app: Flask, trial_app_completion: MagicMock, account: Account) -> None: api = module.TrialCompletionApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"inputs": {}, "query": ""}), - patch.object(module, "current_user", account), patch.object( module.AppGenerateService, "generate", @@ -482,15 +463,14 @@ class TestTrialCompletionApi: ), ): with pytest.raises(ProviderQuotaExceededError): - method(api, trial_app_completion) + method(api, account, trial_app_completion) - def test_completion_model_not_support(self, app: Flask, trial_app_completion, account): + def test_completion_model_not_support(self, app: Flask, trial_app_completion: MagicMock, account: Account) -> None: api = module.TrialCompletionApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"inputs": {}, "query": ""}), - patch.object(module, "current_user", account), patch.object( module.AppGenerateService, "generate", @@ -498,15 +478,14 @@ class TestTrialCompletionApi: ), ): with pytest.raises(ProviderModelCurrentlyNotSupportError): - method(api, trial_app_completion) + method(api, account, trial_app_completion) - def test_completion_invoke_error(self, app: Flask, trial_app_completion, account): + def test_completion_invoke_error(self, app: Flask, trial_app_completion: MagicMock, account: Account) -> None: api = module.TrialCompletionApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"inputs": {}, "query": ""}), - patch.object(module, "current_user", account), patch.object( module.AppGenerateService, "generate", @@ -514,15 +493,14 @@ class TestTrialCompletionApi: ), ): with pytest.raises(CompletionRequestError): - method(api, trial_app_completion) + method(api, account, trial_app_completion) - def test_completion_rate_limit_error(self, app: Flask, trial_app_completion, account): + def test_completion_rate_limit_error(self, app: Flask, trial_app_completion: MagicMock, account: Account) -> None: api = module.TrialCompletionApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"inputs": {}, "query": ""}), - patch.object(module, "current_user", account), patch.object( module.AppGenerateService, "generate", @@ -530,15 +508,14 @@ class TestTrialCompletionApi: ), ): with pytest.raises(InternalServerError): - method(api, trial_app_completion) + method(api, account, trial_app_completion) - def test_completion_value_error(self, app: Flask, trial_app_completion, account): + def test_completion_value_error(self, app: Flask, trial_app_completion: MagicMock, account: Account) -> None: api = module.TrialCompletionApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"inputs": {}, "query": ""}), - patch.object(module, "current_user", account), patch.object( module.AppGenerateService, "generate", @@ -546,15 +523,14 @@ class TestTrialCompletionApi: ), ): with pytest.raises(ValueError): - method(api, trial_app_completion) + method(api, account, trial_app_completion) - def test_completion_generic_exception(self, app: Flask, trial_app_completion, account): + def test_completion_generic_exception(self, app: Flask, trial_app_completion: MagicMock, account: Account) -> None: api = module.TrialCompletionApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"inputs": {}, "query": ""}), - patch.object(module, "current_user", account), patch.object( module.AppGenerateService, "generate", @@ -562,42 +538,40 @@ class TestTrialCompletionApi: ), ): with pytest.raises(InternalServerError): - method(api, trial_app_completion) + method(api, account, trial_app_completion) class TestTrialMessageSuggestedQuestionApi: - def test_not_chat_app(self, app: Flask): + def test_not_chat_app(self, app: Flask, account: Account) -> None: api = module.TrialMessageSuggestedQuestionApi() method = unwrap(api.get) with app.test_request_context("/"): with pytest.raises(NotChatAppError): - method(MagicMock(mode="completion"), str(uuid4())) + method(api, account, MagicMock(mode="completion"), str(uuid4())) - def test_success(self, app: Flask, trial_app_chat, account): + def test_success(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: api = module.TrialMessageSuggestedQuestionApi() method = unwrap(api.get) with ( app.test_request_context("/"), - patch.object(module, "current_user", account), patch.object( module.MessageService, "get_suggested_questions_after_answer", return_value=["q1", "q2"], ), ): - result = method(trial_app_chat, str(uuid4())) + result = method(api, account, trial_app_chat, str(uuid4())) assert result == {"data": ["q1", "q2"]} - def test_conversation_not_exists(self, app: Flask, trial_app_chat, account): + def test_conversation_not_exists(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: api = module.TrialMessageSuggestedQuestionApi() method = unwrap(api.get) with ( app.test_request_context("/"), - patch.object(module, "current_user", account), patch.object( module.MessageService, "get_suggested_questions_after_answer", @@ -605,18 +579,18 @@ class TestTrialMessageSuggestedQuestionApi: ), ): with pytest.raises(NotFound): - method(trial_app_chat, str(uuid4())) + method(api, account, trial_app_chat, str(uuid4())) class TestTrialAppParameterApi: - def test_app_unavailable(self): + def test_app_unavailable(self) -> None: api = module.TrialAppParameterApi() method = unwrap(api.get) with pytest.raises(AppUnavailableError): method(api, None) - def test_success_non_workflow(self, valid_parameters): + def test_success_non_workflow(self, valid_parameters: dict[str, object]) -> None: api = module.TrialAppParameterApi() method = unwrap(api.get) @@ -643,37 +617,33 @@ class TestTrialAppParameterApi: class TestTrialChatAudioApi: - def test_success(self, app: Flask, trial_app_chat, account): + def test_success(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: api = module.TrialChatAudioApi() method = unwrap(api.post) - file_data = BytesIO(b"fake audio data") - file_data.filename = "test.wav" + file_data = _file_data() with ( app.test_request_context( "/", method="POST", data={"file": (file_data, "test.wav")}, content_type="multipart/form-data" ), - patch.object(module, "current_user", account), patch.object(module.AudioService, "transcript_asr", return_value={"text": "hello"}), patch.object(module.RecommendedAppService, "add_trial_app_record"), ): - result = method(api, trial_app_chat) + result = method(api, account, trial_app_chat) assert result == {"text": "hello"} - def test_app_config_broken(self, app: Flask, trial_app_chat, account): + def test_app_config_broken(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: api = module.TrialChatAudioApi() method = unwrap(api.post) - file_data = BytesIO(b"fake audio data") - file_data.filename = "test.wav" + file_data = _file_data() with ( app.test_request_context( "/", method="POST", data={"file": (file_data, "test.wav")}, content_type="multipart/form-data" ), - patch.object(module, "current_user", account), patch.object( module.AudioService, "transcript_asr", @@ -681,20 +651,18 @@ class TestTrialChatAudioApi: ), ): with pytest.raises(module.AppUnavailableError): - method(api, trial_app_chat) + method(api, account, trial_app_chat) - def test_no_audio_uploaded(self, app: Flask, trial_app_chat, account): + def test_no_audio_uploaded(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: api = module.TrialChatAudioApi() method = unwrap(api.post) - file_data = BytesIO(b"fake audio data") - file_data.filename = "test.wav" + file_data = _file_data() with ( app.test_request_context( "/", method="POST", data={"file": (file_data, "test.wav")}, content_type="multipart/form-data" ), - patch.object(module, "current_user", account), patch.object( module.AudioService, "transcript_asr", @@ -702,20 +670,18 @@ class TestTrialChatAudioApi: ), ): with pytest.raises(module.NoAudioUploadedError): - method(api, trial_app_chat) + method(api, account, trial_app_chat) - def test_audio_too_large(self, app: Flask, trial_app_chat, account): + def test_audio_too_large(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: api = module.TrialChatAudioApi() method = unwrap(api.post) - file_data = BytesIO(b"fake audio data") - file_data.filename = "test.wav" + file_data = _file_data() with ( app.test_request_context( "/", method="POST", data={"file": (file_data, "test.wav")}, content_type="multipart/form-data" ), - patch.object(module, "current_user", account), patch.object( module.AudioService, "transcript_asr", @@ -723,20 +689,18 @@ class TestTrialChatAudioApi: ), ): with pytest.raises(module.AudioTooLargeError): - method(api, trial_app_chat) + method(api, account, trial_app_chat) - def test_unsupported_audio_type(self, app: Flask, trial_app_chat, account): + def test_unsupported_audio_type(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: api = module.TrialChatAudioApi() method = unwrap(api.post) - file_data = BytesIO(b"fake audio data") - file_data.filename = "test.wav" + file_data = _file_data() with ( app.test_request_context( "/", method="POST", data={"file": (file_data, "test.wav")}, content_type="multipart/form-data" ), - patch.object(module, "current_user", account), patch.object( module.AudioService, "transcript_asr", @@ -744,20 +708,18 @@ class TestTrialChatAudioApi: ), ): with pytest.raises(module.UnsupportedAudioTypeError): - method(api, trial_app_chat) + method(api, account, trial_app_chat) - def test_provider_not_support_tts(self, app: Flask, trial_app_chat, account): + def test_provider_not_support_tts(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: api = module.TrialChatAudioApi() method = unwrap(api.post) - file_data = BytesIO(b"fake audio data") - file_data.filename = "test.wav" + file_data = _file_data() with ( app.test_request_context( "/", method="POST", data={"file": (file_data, "test.wav")}, content_type="multipart/form-data" ), - patch.object(module, "current_user", account), patch.object( module.AudioService, "transcript_asr", @@ -765,65 +727,59 @@ class TestTrialChatAudioApi: ), ): with pytest.raises(module.ProviderNotSupportSpeechToTextError): - method(api, trial_app_chat) + method(api, account, trial_app_chat) - def test_provider_not_init(self, app: Flask, trial_app_chat, account): + def test_provider_not_init(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: api = module.TrialChatAudioApi() method = unwrap(api.post) - file_data = BytesIO(b"fake audio data") - file_data.filename = "test.wav" + file_data = _file_data() with ( app.test_request_context( "/", method="POST", data={"file": (file_data, "test.wav")}, content_type="multipart/form-data" ), - patch.object(module, "current_user", account), patch.object(module.AudioService, "transcript_asr", side_effect=ProviderTokenNotInitError("test")), ): with pytest.raises(ProviderNotInitializeError): - method(api, trial_app_chat) + method(api, account, trial_app_chat) - def test_quota_exceeded(self, app: Flask, trial_app_chat, account): + def test_quota_exceeded(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: api = module.TrialChatAudioApi() method = unwrap(api.post) - file_data = BytesIO(b"fake audio data") - file_data.filename = "test.wav" + file_data = _file_data() with ( app.test_request_context( "/", method="POST", data={"file": (file_data, "test.wav")}, content_type="multipart/form-data" ), - patch.object(module, "current_user", account), patch.object(module.AudioService, "transcript_asr", side_effect=QuotaExceededError()), ): with pytest.raises(ProviderQuotaExceededError): - method(api, trial_app_chat) + method(api, account, trial_app_chat) class TestTrialChatTextApi: - def test_success(self, app: Flask, trial_app_chat, account): + def test_success(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: api = module.TrialChatTextApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"text": "hello", "voice": "en-US"}), - patch.object(module, "current_user", account), patch.object(module.AudioService, "transcript_tts", return_value={"audio": "base64_data"}), patch.object(module.RecommendedAppService, "add_trial_app_record"), ): - result = method(api, trial_app_chat) + result = method(api, account, trial_app_chat) assert result == {"audio": "base64_data"} - def test_app_config_broken(self, app: Flask, trial_app_chat, account): + def test_app_config_broken(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: api = module.TrialChatTextApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"text": "hello", "voice": "en-US"}), - patch.object(module, "current_user", account), patch.object( module.AudioService, "transcript_tts", @@ -831,15 +787,14 @@ class TestTrialChatTextApi: ), ): with pytest.raises(module.AppUnavailableError): - method(api, trial_app_chat) + method(api, account, trial_app_chat) - def test_provider_not_support(self, app: Flask, trial_app_chat, account): + def test_provider_not_support(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: api = module.TrialChatTextApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"text": "hello", "voice": "en-US"}), - patch.object(module, "current_user", account), patch.object( module.AudioService, "transcript_tts", @@ -847,15 +802,14 @@ class TestTrialChatTextApi: ), ): with pytest.raises(module.ProviderNotSupportSpeechToTextError): - method(api, trial_app_chat) + method(api, account, trial_app_chat) - def test_audio_too_large(self, app: Flask, trial_app_chat, account): + def test_audio_too_large(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: api = module.TrialChatTextApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"text": "hello", "voice": "en-US"}), - patch.object(module, "current_user", account), patch.object( module.AudioService, "transcript_tts", @@ -863,15 +817,14 @@ class TestTrialChatTextApi: ), ): with pytest.raises(module.AudioTooLargeError): - method(api, trial_app_chat) + method(api, account, trial_app_chat) - def test_no_audio_uploaded(self, app: Flask, trial_app_chat, account): + def test_no_audio_uploaded(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: api = module.TrialChatTextApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"text": "hello", "voice": "en-US"}), - patch.object(module, "current_user", account), patch.object( module.AudioService, "transcript_tts", @@ -879,59 +832,55 @@ class TestTrialChatTextApi: ), ): with pytest.raises(module.NoAudioUploadedError): - method(api, trial_app_chat) + method(api, account, trial_app_chat) - def test_provider_not_init(self, app: Flask, trial_app_chat, account): + def test_provider_not_init(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: api = module.TrialChatTextApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"text": "hello", "voice": "en-US"}), - patch.object(module, "current_user", account), patch.object(module.AudioService, "transcript_tts", side_effect=ProviderTokenNotInitError("test")), ): with pytest.raises(ProviderNotInitializeError): - method(api, trial_app_chat) + method(api, account, trial_app_chat) - def test_quota_exceeded(self, app: Flask, trial_app_chat, account): + def test_quota_exceeded(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: api = module.TrialChatTextApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"text": "hello", "voice": "en-US"}), - patch.object(module, "current_user", account), patch.object(module.AudioService, "transcript_tts", side_effect=QuotaExceededError()), ): with pytest.raises(ProviderQuotaExceededError): - method(api, trial_app_chat) + method(api, account, trial_app_chat) - def test_model_not_support(self, app: Flask, trial_app_chat, account): + def test_model_not_support(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: api = module.TrialChatTextApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"text": "hello", "voice": "en-US"}), - patch.object(module, "current_user", account), patch.object(module.AudioService, "transcript_tts", side_effect=ModelCurrentlyNotSupportError()), ): with pytest.raises(ProviderModelCurrentlyNotSupportError): - method(api, trial_app_chat) + method(api, account, trial_app_chat) - def test_invoke_error(self, app: Flask, trial_app_chat, account): + def test_invoke_error(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: api = module.TrialChatTextApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"text": "hello", "voice": "en-US"}), - patch.object(module, "current_user", account), patch.object(module.AudioService, "transcript_tts", side_effect=InvokeError("test error")), ): with pytest.raises(CompletionRequestError): - method(api, trial_app_chat) + method(api, account, trial_app_chat) class TestTrialAppWorkflowTaskStopApi: - def test_not_workflow_app(self, app: Flask, trial_app_chat): + def test_not_workflow_app(self, app: Flask, trial_app_chat: MagicMock) -> None: api = module.TrialAppWorkflowTaskStopApi() method = unwrap(api.post) @@ -939,14 +888,13 @@ class TestTrialAppWorkflowTaskStopApi: with pytest.raises(NotWorkflowAppError): method(api, trial_app_chat, str(uuid4())) - def test_success(self, app: Flask, trial_app_workflow, account): + def test_success(self, app: Flask, trial_app_workflow: MagicMock) -> None: api = module.TrialAppWorkflowTaskStopApi() method = unwrap(api.post) task_id = str(uuid4()) with ( app.test_request_context("/"), - patch.object(module, "current_user", account), patch.object(module.AppQueueManager, "set_stop_flag_no_user_check") as mock_set_flag, patch.object(module.GraphEngineManager, "send_stop_command") as mock_send_cmd, ): @@ -958,7 +906,7 @@ class TestTrialAppWorkflowTaskStopApi: class TestTrialSitApi: - def test_no_site(self, app: Flask): + def test_no_site(self, app: Flask) -> None: api = module.TrialSitApi() method = unwrap(api.get) app_model = MagicMock() @@ -969,7 +917,7 @@ class TestTrialSitApi: with pytest.raises(Forbidden): method(api, app_model) - def test_archived_tenant(self, app: Flask): + def test_archived_tenant(self, app: Flask) -> None: api = module.TrialSitApi() method = unwrap(api.get) @@ -984,7 +932,7 @@ class TestTrialSitApi: with pytest.raises(Forbidden): method(api, app_model) - def test_success(self, app: Flask): + def test_success(self, app: Flask) -> None: api = module.TrialSitApi() method = unwrap(api.get) @@ -1009,18 +957,16 @@ class TestTrialSitApi: class TestTrialChatAudioApiExceptionHandlers: - def test_provider_not_init(self, app: Flask, trial_app_chat, account): + def test_provider_not_init(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: api = module.TrialChatAudioApi() method = unwrap(api.post) - file_data = BytesIO(b"fake audio data") - file_data.filename = "test.wav" + file_data = _file_data() with ( app.test_request_context( "/", method="POST", data={"file": (file_data, "test.wav")}, content_type="multipart/form-data" ), - patch.object(module, "current_user", account), patch.object( module.AudioService, "transcript_asr", @@ -1028,20 +974,18 @@ class TestTrialChatAudioApiExceptionHandlers: ), ): with pytest.raises(ProviderNotInitializeError): - method(api, trial_app_chat) + method(api, account, trial_app_chat) - def test_quota_exceeded(self, app: Flask, trial_app_chat, account): + def test_quota_exceeded(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: api = module.TrialChatAudioApi() method = unwrap(api.post) - file_data = BytesIO(b"fake audio data") - file_data.filename = "test.wav" + file_data = _file_data() with ( app.test_request_context( "/", method="POST", data={"file": (file_data, "test.wav")}, content_type="multipart/form-data" ), - patch.object(module, "current_user", account), patch.object( module.AudioService, "transcript_asr", @@ -1049,20 +993,18 @@ class TestTrialChatAudioApiExceptionHandlers: ), ): with pytest.raises(ProviderQuotaExceededError): - method(api, trial_app_chat) + method(api, account, trial_app_chat) - def test_invoke_error(self, app: Flask, trial_app_chat, account): + def test_invoke_error(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: api = module.TrialChatAudioApi() method = unwrap(api.post) - file_data = BytesIO(b"fake audio data") - file_data.filename = "test.wav" + file_data = _file_data() with ( app.test_request_context( "/", method="POST", data={"file": (file_data, "test.wav")}, content_type="multipart/form-data" ), - patch.object(module, "current_user", account), patch.object( module.AudioService, "transcript_asr", @@ -1070,17 +1012,16 @@ class TestTrialChatAudioApiExceptionHandlers: ), ): with pytest.raises(CompletionRequestError): - method(api, trial_app_chat) + method(api, account, trial_app_chat) class TestTrialChatTextApiExceptionHandlers: - def test_app_config_broken(self, app: Flask, trial_app_chat, account): + def test_app_config_broken(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: api = module.TrialChatTextApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"text": "hello", "voice": "en-US"}), - patch.object(module, "current_user", account), patch.object( module.AudioService, "transcript_tts", @@ -1088,15 +1029,14 @@ class TestTrialChatTextApiExceptionHandlers: ), ): with pytest.raises(module.AppUnavailableError): - method(api, trial_app_chat) + method(api, account, trial_app_chat) - def test_unsupported_audio_type(self, app: Flask, trial_app_chat, account): + def test_unsupported_audio_type(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None: api = module.TrialChatTextApi() method = unwrap(api.post) with ( app.test_request_context("/", json={"text": "hello", "voice": "en-US"}), - patch.object(module, "current_user", account), patch.object( module.AudioService, "transcript_tts", @@ -1104,4 +1044,4 @@ class TestTrialChatTextApiExceptionHandlers: ), ): with pytest.raises(module.UnsupportedAudioTypeError): - method(api, trial_app_chat) + method(api, account, trial_app_chat) diff --git a/api/tests/unit_tests/controllers/console/tag/test_tags.py b/api/tests/unit_tests/controllers/console/tag/test_tags.py index 3630f1bfec7..dc3dd00a6c0 100644 --- a/api/tests/unit_tests/controllers/console/tag/test_tags.py +++ b/api/tests/unit_tests/controllers/console/tag/test_tags.py @@ -2,6 +2,14 @@ from types import SimpleNamespace from unittest.mock import MagicMock, PropertyMock, patch import pytest +from sqlalchemy.orm import Session + + +class SessionMatcher: + def __eq__(self, other): + return isinstance(other, Session) + + from flask import Flask from werkzeug.exceptions import Forbidden @@ -125,7 +133,7 @@ class TestTagListApi: ): result, status = method(api, "tenant-1") - get_tags_mock.assert_called_once_with("snippet", "tenant-1", None) + get_tags_mock.assert_called_once_with(SessionMatcher(), "snippet", "tenant-1", None) assert status == 200 assert result == [{"id": "1", "name": "snippet-tag", "type": "snippet", "binding_count": "1"}] diff --git a/api/tests/unit_tests/controllers/console/test_feature.py b/api/tests/unit_tests/controllers/console/test_feature.py index d92454d6d84..3e804583a6e 100644 --- a/api/tests/unit_tests/controllers/console/test_feature.py +++ b/api/tests/unit_tests/controllers/console/test_feature.py @@ -1,32 +1,36 @@ +from inspect import unwrap + from pytest_mock import MockerFixture -from werkzeug.exceptions import Unauthorized + +from models import Account +from services.feature_service import FeatureModel, LimitationModel, SystemFeatureModel -def unwrap(func): - """ - Recursively unwrap decorated functions. - """ - while hasattr(func, "__wrapped__"): - func = func.__wrapped__ - return func +def make_account() -> Account: + account = Account(name="Alice", email="alice@example.com") + account.id = "account-1" + return account class TestFeatureApi: def test_get_tenant_features_success(self, mocker: MockerFixture): from controllers.console.feature import FeatureApi + features = FeatureModel( + knowledge_rate_limit=42, + vector_space=LimitationModel(size=1, limit=2), + ) get_features = mocker.patch("controllers.console.feature.FeatureService.get_features") - get_features.return_value.model_dump.return_value = { - "features": {"feature_a": True}, - "vector_space": {"size": 1, "limit": 2}, - } + get_features.return_value = features api = FeatureApi() raw_get = unwrap(FeatureApi.get) result = raw_get(api, "tenant_123") - assert result == {"features": {"feature_a": True}} + expected = features.model_dump() + expected.pop("vector_space") + assert result == expected get_features.assert_called_once_with("tenant_123", exclude_vector_space=True) @@ -35,7 +39,7 @@ class TestFeatureVectorSpaceApi: from controllers.console.feature import FeatureVectorSpaceApi get_vector_space = mocker.patch("controllers.console.feature.FeatureService.get_vector_space") - get_vector_space.return_value.model_dump.return_value = {"size": 5120, "limit": 20480} + get_vector_space.return_value = LimitationModel(size=5120, limit=20480) api = FeatureVectorSpaceApi() @@ -85,22 +89,23 @@ class TestSystemFeatureApi: from controllers.console.feature import SystemFeatureApi - fake_user = mocker.Mock() - fake_user.is_authenticated = True - - mocker.patch( - "controllers.console.feature.current_user", - fake_user, + account = make_account() + current_account = mocker.patch( + "controllers.console.feature.current_account_with_tenant_optional", + return_value=(account, "tenant-123"), + ) + system_features = SystemFeatureModel(is_allow_register=True) + get_system_features = mocker.patch( + "controllers.console.feature.FeatureService.get_system_features", + return_value=system_features, ) - - mocker.patch( - "controllers.console.feature.FeatureService.get_system_features" - ).return_value.model_dump.return_value = {"features": {"sys_feature": True}} api = SystemFeatureApi() result = api.get() - assert result == {"features": {"sys_feature": True}} + assert result == system_features.model_dump() + current_account.assert_called_once_with() + get_system_features.assert_called_once_with(is_authenticated=True) def test_get_system_features_unauthenticated(self, mocker: MockerFixture): """ @@ -109,19 +114,19 @@ class TestSystemFeatureApi: from controllers.console.feature import SystemFeatureApi - fake_user = mocker.Mock() - type(fake_user).is_authenticated = mocker.PropertyMock(side_effect=Unauthorized()) - - mocker.patch( - "controllers.console.feature.current_user", - fake_user, + current_account = mocker.patch( + "controllers.console.feature.current_account_with_tenant_optional", + return_value=(None, None), + ) + system_features = SystemFeatureModel(is_allow_register=False) + get_system_features = mocker.patch( + "controllers.console.feature.FeatureService.get_system_features", + return_value=system_features, ) - - mocker.patch( - "controllers.console.feature.FeatureService.get_system_features" - ).return_value.model_dump.return_value = {"features": {"sys_feature": False}} api = SystemFeatureApi() result = api.get() - assert result == {"features": {"sys_feature": False}} + assert result == system_features.model_dump() + current_account.assert_called_once_with() + get_system_features.assert_called_once_with(is_authenticated=False) diff --git a/api/tests/unit_tests/controllers/console/test_version.py b/api/tests/unit_tests/controllers/console/test_version.py index 8d8d324be1f..335c8692969 100644 --- a/api/tests/unit_tests/controllers/console/test_version.py +++ b/api/tests/unit_tests/controllers/console/test_version.py @@ -1,3 +1,4 @@ +import logging from unittest.mock import MagicMock, patch import controllers.console.version as version_module @@ -18,15 +19,15 @@ class TestHasNewVersion: ) assert result is False - def test_has_new_version_invalid_version(self): - with patch.object(version_module.logger, "warning") as log_warning: + def test_has_new_version_invalid_version(self, caplog): + with caplog.at_level(logging.WARNING, logger="controllers.console.version"): result = version_module._has_new_version( latest_version="invalid", current_version="1.0.0", ) assert result is False - log_warning.assert_called_once() + assert "Invalid version format" in caplog.text class TestCheckVersionUpdate: diff --git a/api/tests/unit_tests/controllers/console/test_wraps.py b/api/tests/unit_tests/controllers/console/test_wraps.py index fb2ef55fe80..937505dab28 100644 --- a/api/tests/unit_tests/controllers/console/test_wraps.py +++ b/api/tests/unit_tests/controllers/console/test_wraps.py @@ -1,3 +1,4 @@ +from typing import override from unittest.mock import MagicMock, patch import pytest @@ -36,6 +37,7 @@ class MockUser(UserMixin): self.id = user_id self.current_tenant_id = "tenant123" + @override def get_id(self) -> str: return self.id @@ -210,6 +212,7 @@ class TestModelValidationInjection: Handler().post() assert exc_info.value.code == 422 + assert exc_info.value.description is not None assert "count" in exc_info.value.description 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 2f9c7d4fd68..bc76560dcac 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_plugin.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_plugin.py @@ -1,5 +1,7 @@ import io +from datetime import datetime from inspect import unwrap +from typing import Any from unittest.mock import MagicMock, patch import pytest @@ -10,12 +12,14 @@ from werkzeug.exceptions import Forbidden from controllers.console.workspace.plugin import ( PluginAssetApi, PluginAutoUpgradeExcludePluginApi, + PluginCategoryListApi, + PluginChangeAutoUpgradeApi, PluginChangePermissionApi, - PluginChangePreferencesApi, PluginDebuggingKeyApi, PluginDeleteAllInstallTaskItemsApi, PluginDeleteInstallTaskApi, PluginDeleteInstallTaskItemApi, + PluginFetchAutoUpgradeApi, PluginFetchDynamicSelectOptionsApi, PluginFetchDynamicSelectOptionsWithCredentialsApi, PluginFetchInstallTaskApi, @@ -23,7 +27,6 @@ from controllers.console.workspace.plugin import ( PluginFetchManifestApi, PluginFetchMarketplacePkgApi, PluginFetchPermissionApi, - PluginFetchPreferencesApi, PluginIconApi, PluginInstallFromGithubApi, PluginInstallFromMarketplaceApi, @@ -43,6 +46,69 @@ from core.plugin.impl.exc import PluginDaemonClientSideError from models.account import Account, TenantAccountRole, TenantPluginAutoUpgradeStrategy, TenantPluginPermission +def _plugin_category_list_item(category: str = "tool") -> dict[str, Any]: + now = datetime(2023, 1, 1, 0, 0, 0) + return { + "id": "entity-1", + "created_at": now, + "updated_at": now, + "tenant_id": "t1", + "endpoints_setups": 0, + "endpoints_active": 0, + "runtime_type": "remote", + "source": "marketplace", + "meta": {}, + "plugin_id": "test-author/test-plugin", + "plugin_unique_identifier": "test-author/test-plugin:1.0.0@checksum", + "version": "1.0.0", + "checksum": "checksum", + "name": "test-plugin", + "installation_id": "entity-1", + "declaration": { + "version": "1.0.0", + "author": "test-author", + "name": "test-plugin", + "description": {"en_US": "Test plugin"}, + "icon": "icon.svg", + "label": {"en_US": "Test Plugin"}, + "category": category, + "created_at": now, + "resource": {"memory": 268435456, "permission": None}, + "plugins": {"tools": ["provider/test.yaml"]}, + "meta": {"version": "1.0.0"}, + "tool": { + "identity": { + "author": "test-author", + "name": "test-plugin", + "description": {"en_US": "Test plugin"}, + "icon": "icon.svg", + "label": {"en_US": "Test Plugin"}, + } + }, + }, + } + + +def _builtin_tool_provider_item() -> dict[str, Any]: + return { + "id": "builtin", + "author": "dify", + "name": "builtin", + "plugin_id": "", + "plugin_unique_identifier": "", + "description": {"en_US": "Builtin tool provider"}, + "icon": "icon.svg", + "icon_dark": "", + "label": {"en_US": "Builtin"}, + "type": "builtin", + "team_credentials": {}, + "is_team_authorization": False, + "allow_delete": True, + "tools": [], + "labels": [], + } + + def _account(role: TenantAccountRole = TenantAccountRole.OWNER) -> Account: account = Account(name="Test User", email="u1@example.com") account.id = "u1" @@ -142,6 +208,83 @@ class TestPluginListApi: mock_list_with_total.assert_called_once_with("t1", "u1", 1, 10) +class TestPluginCategoryListApi: + def test_plugin_category_list(self, app: Flask): + api = PluginCategoryListApi() + method = unwrap(api.get) + plugin_item = _plugin_category_list_item() + builtin_item = _builtin_tool_provider_item() + mock_list = MagicMock(list=[plugin_item], has_more=True) + + with ( + app.test_request_context("/?page=2&page_size=10"), + patch( + "controllers.console.workspace.plugin.PluginService.list_by_category", return_value=mock_list + ) as list_mock, + patch( + "controllers.console.workspace.plugin._list_hardcoded_builtin_tool_providers", + return_value=[builtin_item], + ) as builtin_mock, + ): + result = method(api, "t1", "tool") + + list_mock.assert_called_once() + assert list_mock.call_args.args[0] == "t1" + assert list_mock.call_args.args[1] == "tool" + assert list_mock.call_args.args[2] == 2 + assert list_mock.call_args.args[3] == 10 + assert result["plugins"][0]["id"] == "entity-1" + assert result["plugins"][0]["plugin_unique_identifier"] == "test-author/test-plugin:1.0.0@checksum" + assert result["builtin_tools"][0]["id"] == "builtin" + assert result["builtin_tools"][0]["type"] == "builtin" + assert result["has_more"] is True + assert "total" not in result + builtin_mock.assert_called_once_with("t1") + + def test_non_tool_category_does_not_include_builtin_tools(self, app: Flask): + api = PluginCategoryListApi() + method = unwrap(api.get) + mock_list = MagicMock(list=[_plugin_category_list_item(category="datasource")], has_more=False) + + with ( + app.test_request_context("/?page=1&page_size=10"), + patch("controllers.console.workspace.plugin.PluginService.list_by_category", return_value=mock_list), + patch("controllers.console.workspace.plugin._list_hardcoded_builtin_tool_providers") as builtin_mock, + ): + result = method(api, "t1", "datasource") + + assert result["plugins"][0]["id"] == "entity-1" + assert result["builtin_tools"] == [] + assert result["has_more"] is False + builtin_mock.assert_not_called() + + def test_invalid_category(self, app: Flask): + api = PluginCategoryListApi() + method = unwrap(api.get) + + with ( + app.test_request_context("/?page=1&page_size=10"), + ): + result = method(api, "t1", "unknown") + + assert result == ({"code": "invalid_param", "message": "invalid plugin category"}, 400) + + def test_daemon_error(self, app: Flask): + api = PluginCategoryListApi() + method = unwrap(api.get) + + with ( + app.test_request_context("/?page=1&page_size=10"), + patch( + "controllers.console.workspace.plugin.PluginService.list_by_category", + side_effect=PluginDaemonClientSideError("error"), + ), + ): + result = method(api, "t1", "tool") + + assert result == ({"code": "plugin_error", "message": "error"}, 400) + + class TestPluginIconApi: def test_plugin_icon(self, app: Flask): api = PluginIconApi() @@ -857,18 +1000,15 @@ class TestPluginFetchDynamicSelectOptionsWithCredentialsApi: assert result == ({"code": "plugin_error", "message": "error"}, 400) -class TestPluginChangePreferencesApi: +class TestPluginChangeAutoUpgradeApi: def test_success(self, app: Flask): - api = PluginChangePreferencesApi() + api = PluginChangeAutoUpgradeApi() method = unwrap(api.post) user = _account() payload = { - "permission": { - "install_permission": TenantPluginPermission.InstallPermission.EVERYONE, - "debug_permission": TenantPluginPermission.DebugPermission.EVERYONE, - }, + "category": TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL.value, "auto_upgrade": { "strategy_setting": TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY, "upgrade_time_of_day": 0, @@ -880,24 +1020,52 @@ class TestPluginChangePreferencesApi: with ( app.test_request_context("/", json=payload), - patch("controllers.console.workspace.plugin.PluginPermissionService.change_permission", return_value=True), - patch("controllers.console.workspace.plugin.PluginAutoUpgradeService.change_strategy", return_value=True), + patch( + "controllers.console.workspace.plugin.PluginAutoUpgradeService.change_strategy", return_value=True + ) as change, ): result = method(api, "t1", user) assert result["success"] is True + change.assert_called_once() - def test_permission_fail(self, app: Flask): - api = PluginChangePreferencesApi() + def test_success_with_model_category_auto_upgrade(self, app: Flask): + api = PluginChangeAutoUpgradeApi() method = unwrap(api.post) user = _account() payload = { - "permission": { - "install_permission": TenantPluginPermission.InstallPermission.EVERYONE, - "debug_permission": TenantPluginPermission.DebugPermission.EVERYONE, + "category": TenantPluginAutoUpgradeStrategy.PluginCategory.MODEL.value, + "auto_upgrade": { + "strategy_setting": TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST, + "upgrade_time_of_day": 3600, + "upgrade_mode": TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL, + "exclude_plugins": [], + "include_plugins": [], }, + } + + with ( + app.test_request_context("/", json=payload), + patch( + "controllers.console.workspace.plugin.PluginAutoUpgradeService.change_strategy", return_value=True + ) as change, + ): + result = method(api, "t1", user) + + assert result["success"] is True + change.assert_called_once() + assert change.call_args.kwargs["category"] == TenantPluginAutoUpgradeStrategy.PluginCategory.MODEL + + def test_auto_upgrade_fail(self, app: Flask): + api = PluginChangeAutoUpgradeApi() + method = unwrap(api.post) + + user = MagicMock(is_admin_or_owner=True) + + payload = { + "category": TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL.value, "auto_upgrade": { "strategy_setting": TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY, "upgrade_time_of_day": 0, @@ -909,24 +1077,20 @@ class TestPluginChangePreferencesApi: with ( app.test_request_context("/", json=payload), - patch("controllers.console.workspace.plugin.PluginPermissionService.change_permission", return_value=False), + patch("controllers.console.workspace.plugin.PluginAutoUpgradeService.change_strategy", return_value=False), ): result = method(api, "t1", user) assert result["success"] is False -class TestPluginFetchPreferencesApi: +class TestPluginFetchAutoUpgradeApi: def test_success(self, app: Flask): - api = PluginFetchPreferencesApi() + api = PluginFetchAutoUpgradeApi() method = unwrap(api.get) - permission = MagicMock( - install_permission=TenantPluginPermission.InstallPermission.EVERYONE, - debug_permission=TenantPluginPermission.DebugPermission.EVERYONE, - ) - auto_upgrade = MagicMock( + category=TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL, strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY, upgrade_time_of_day=1, upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE, @@ -935,18 +1099,16 @@ class TestPluginFetchPreferencesApi: ) with ( - app.test_request_context("/"), + app.test_request_context(f"/?category={TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL.value}"), patch( - "controllers.console.workspace.plugin.PluginPermissionService.get_permission", return_value=permission - ), - patch( - "controllers.console.workspace.plugin.PluginAutoUpgradeService.get_strategy", return_value=auto_upgrade + "controllers.console.workspace.plugin.PluginAutoUpgradeService.get_strategy", + return_value=auto_upgrade, ), ): result = method(api, "t1") - assert "permission" in result - assert "auto_upgrade" in result + assert result["category"] == TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL + assert result["auto_upgrade"]["upgrade_time_of_day"] == 1 class TestPluginAutoUpgradeExcludePluginApi: @@ -954,7 +1116,7 @@ class TestPluginAutoUpgradeExcludePluginApi: api = PluginAutoUpgradeExcludePluginApi() method = unwrap(api.post) - payload = {"plugin_id": "p"} + payload = {"plugin_id": "p", "category": TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL.value} with ( app.test_request_context("/", json=payload), @@ -968,7 +1130,7 @@ class TestPluginAutoUpgradeExcludePluginApi: api = PluginAutoUpgradeExcludePluginApi() method = unwrap(api.post) - payload = {"plugin_id": "p"} + payload = {"plugin_id": "p", "category": TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL.value} with ( app.test_request_context("/", json=payload), 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 68d5a879e48..4663d503e3d 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_workspace.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_workspace.py @@ -53,6 +53,12 @@ def make_tenant( return tenant +def make_membership(*, last_opened_at=None) -> MagicMock: + membership = MagicMock() + membership.last_opened_at = last_opened_at + return membership + + def make_account_with_tenant(tenant: Tenant) -> Account: account = make_account() account._current_tenant = tenant @@ -66,13 +72,17 @@ class TestTenantListApi: tenant1 = make_tenant("t1", name="Tenant 1") tenant2 = make_tenant("t2", name="Tenant 2") + last_opened_at = naive_utc_now() user = make_account() with ( app.test_request_context("/workspaces"), patch( - "controllers.console.workspace.workspace.TenantService.get_join_tenants", - return_value=[tenant1, tenant2], + "controllers.console.workspace.workspace.TenantService.get_workspaces_for_account", + return_value=[ + (tenant1, make_membership(last_opened_at=last_opened_at)), + (tenant2, make_membership()), + ], ), patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", False), patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", True), @@ -92,7 +102,9 @@ class TestTenantListApi: assert len(result["workspaces"]) == 2 assert result["workspaces"][0]["current"] is True assert result["workspaces"][0]["plan"] == CloudPlan.TEAM + assert result["workspaces"][0]["last_opened_at"] == int(last_opened_at.timestamp()) assert result["workspaces"][1]["plan"] == CloudPlan.PROFESSIONAL + assert result["workspaces"][1]["last_opened_at"] is None get_plan_bulk_mock.assert_called_once_with(["t1", "t2"]) get_features_mock.assert_not_called() @@ -116,8 +128,8 @@ class TestTenantListApi: with ( app.test_request_context("/workspaces"), patch( - "controllers.console.workspace.workspace.TenantService.get_join_tenants", - return_value=[tenant1, tenant2], + "controllers.console.workspace.workspace.TenantService.get_workspaces_for_account", + return_value=[(tenant1, make_membership()), (tenant2, make_membership())], ), patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", False), patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", True), @@ -159,8 +171,8 @@ class TestTenantListApi: with ( app.test_request_context("/workspaces"), patch( - "controllers.console.workspace.workspace.TenantService.get_join_tenants", - return_value=[tenant1, tenant2], + "controllers.console.workspace.workspace.TenantService.get_workspaces_for_account", + return_value=[(tenant1, make_membership()), (tenant2, make_membership())], ), patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", False), patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", True), @@ -198,8 +210,8 @@ class TestTenantListApi: with ( app.test_request_context("/workspaces"), patch( - "controllers.console.workspace.workspace.TenantService.get_join_tenants", - return_value=[tenant], + "controllers.console.workspace.workspace.TenantService.get_workspaces_for_account", + return_value=[(tenant, make_membership())], ), patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", False), patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", False), @@ -226,8 +238,8 @@ class TestTenantListApi: with ( app.test_request_context("/workspaces"), patch( - "controllers.console.workspace.workspace.TenantService.get_join_tenants", - return_value=[tenant1, tenant2], + "controllers.console.workspace.workspace.TenantService.get_workspaces_for_account", + return_value=[(tenant1, make_membership()), (tenant2, make_membership())], ), patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", True), patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", False), @@ -251,7 +263,7 @@ class TestTenantListApi: with ( app.test_request_context("/workspaces"), patch( - "controllers.console.workspace.workspace.TenantService.get_join_tenants", + "controllers.console.workspace.workspace.TenantService.get_workspaces_for_account", return_value=[], ), patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", True), diff --git a/api/tests/unit_tests/controllers/openapi/test_app_run_rate_limit.py b/api/tests/unit_tests/controllers/openapi/test_app_run_rate_limit.py new file mode 100644 index 00000000000..d9c468d0a4e --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_app_run_rate_limit.py @@ -0,0 +1,29 @@ +"""The openapi run boundary maps internal rate-limit exceptions to canonical 429s. + +Both render through the ErrorBody formatter: TooManyRequests -> code "too_many_requests" +(retryable throttle), InvokeRateLimitError -> code "rate_limit_error" (quota). +""" + +import pytest +from werkzeug.exceptions import TooManyRequests + +from controllers.openapi.app_run import _translate_service_errors +from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError +from core.errors.error import AppInvokeQuotaExceededError +from services.errors.llm import InvokeRateLimitError + + +def test_translate_maps_app_concurrency_to_too_many_requests(): + # Regression guard: this used to fall through to a 500 (it was not caught here). + with pytest.raises(TooManyRequests) as exc: + with _translate_service_errors(): + raise AppInvokeQuotaExceededError("internal: client_id=abc max=10") + assert exc.value.code == 429 + + +def test_translate_maps_workflow_quota_to_rate_limit_error(): + with pytest.raises(InvokeRateLimitHttpError) as exc: + with _translate_service_errors(): + raise InvokeRateLimitError("workflow quota exhausted") + assert exc.value.error_code == "rate_limit_error" + assert exc.value.code == 429 diff --git a/api/tests/unit_tests/controllers/openapi/test_contract.py b/api/tests/unit_tests/controllers/openapi/test_contract.py index 990437e37f4..b8773f56df8 100644 --- a/api/tests/unit_tests/controllers/openapi/test_contract.py +++ b/api/tests/unit_tests/controllers/openapi/test_contract.py @@ -208,3 +208,41 @@ def test_accepts_body_emits_expect_through_guard_stack(): apidoc = getattr(view, "__apidoc__", {}) assert apidoc.get("expect") # body schema advertised via @openapi_ns.expect + + +def _response_model_name(entry) -> str: + """Extract the model name from a flask-restx __apidoc__ response entry. + + flask-restx stores responses as ``(description, model, kwargs)`` tuples + where ``model.name`` is the registered schema name. + """ + if isinstance(entry, tuple) and len(entry) >= 2: + model = entry[1] + return getattr(model, "name", "") or "" + return "" + + +def test_accepts_documents_422_error_response(app): + from controllers.openapi._errors import ErrorBody + + @accepts(query=ContractQuery) + def view(*, query): + return query + + doc = getattr(view, "__apidoc__", {}) + responses = doc.get("responses", {}) + assert "422" in responses + assert _response_model_name(responses["422"]) == ErrorBody.__name__ + + +def test_returns_documents_default_error_response(app): + from controllers.openapi._errors import ErrorBody + + @returns(200, ContractResp) + def view(): + return ContractResp(value=1) + + doc = getattr(view, "__apidoc__", {}) + responses = doc.get("responses", {}) + assert "default" in responses + assert _response_model_name(responses["default"]) == ErrorBody.__name__ diff --git a/api/tests/unit_tests/controllers/openapi/test_error_contract.py b/api/tests/unit_tests/controllers/openapi/test_error_contract.py new file mode 100644 index 00000000000..45a577443b7 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_error_contract.py @@ -0,0 +1,351 @@ +"""Wire-contract tests for the canonical /openapi/v1 error body.""" + +from unittest.mock import MagicMock, patch + +import pytest +from werkzeug.exceptions import ( + BadGateway, + BadRequest, + Conflict, + Forbidden, + InternalServerError, + NotFound, + TooManyRequests, + Unauthorized, + UnprocessableEntity, +) + +from controllers.common.errors import ( + BlockedFileExtensionError, + FileTooLargeError, + NoFileUploadedError, + TooManyFilesError, + UnsupportedFileTypeError, +) +from controllers.openapi._errors import ( + ErrorBody, + ErrorDetail, + FilenameNotExists, + MemberLicenseExceeded, + MemberLimitExceeded, + OpenApiError, + OpenApiErrorCode, + OpenApiErrorFormatter, +) +from controllers.service_api.app.error import ( + AppUnavailableError, + CompletionRequestError, + ConversationCompletedError, + ProviderModelCurrentlyNotSupportError, + ProviderNotInitializeError, + ProviderQuotaExceededError, +) +from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError + + +@pytest.fixture +def fmt() -> OpenApiErrorFormatter: + return OpenApiErrorFormatter() + + +class TestErrorBodyModel: + def test_minimal_body_serializes_without_optional_fields(self): + body = ErrorBody(code=OpenApiErrorCode.NOT_FOUND, message="app not found", status=404) + + wire = body.model_dump(mode="json", exclude_none=True) + + assert wire == {"code": "not_found", "message": "app not found", "status": 404} + + def test_full_body_round_trips(self): + body = ErrorBody( + code=OpenApiErrorCode.INVALID_PARAM, + message="Request validation failed", + status=422, + hint="check the request payload", + details=[ErrorDetail(type="int_parsing", loc=["page"], msg="must be >= 1")], + ) + + wire = body.model_dump(mode="json", exclude_none=True) + + assert wire["details"] == [{"type": "int_parsing", "loc": ["page"], "msg": "must be >= 1"}] + assert ErrorBody.model_validate(wire) == body + + def test_code_field_is_open_string_for_forward_compat(self): + # Old CLIs must not hard-fail when a future server adds a code, so the + # schema type is str; enum membership is enforced by the formatter tests. + body = ErrorBody.model_validate({"code": "some_future_code", "message": "x", "status": 400}) + + assert body.code == "some_future_code" + + +class TestOpenApiErrorFormatter: + def test_plain_werkzeug_exception_maps_code_from_status(self, fmt): + e = NotFound("app not found") + data = {"code": "not_found", "message": "app not found", "status": 404} + + wire = fmt.finalize(e, data, 404) + + assert wire == {"code": "not_found", "message": "app not found", "status": 404} + + def test_422_maps_to_invalid_param(self, fmt): + e = UnprocessableEntity("workspace_id is required for name-based lookup") + data = {"code": "unprocessable_entity", "message": e.description, "status": 422} + + wire = fmt.finalize(e, data, 422) + + assert wire["code"] == "invalid_param" + + def test_flask_restx_abort_data_path_yields_canonical_body(self, fmt): + # Simulates _contract.py's abort(422, message=..., errors=...): flask_restx + # attaches kwargs to e.data, which handle_error would otherwise put on the + # wire verbatim (no code/status). + e = UnprocessableEntity() + e.data = { + "message": "Request validation failed", + "errors": [{"type": "int_parsing", "loc": ["page"], "msg": "must be >= 1", "extra": "drop me"}], + } + data = {"code": "unprocessable_entity", "message": e.description, "status": 422} + + wire = fmt.finalize(e, data, 422) + + assert wire["code"] == "invalid_param" + assert wire["message"] == "Request validation failed" + assert wire["status"] == 422 + assert wire["details"] == [{"type": "int_parsing", "loc": ["page"], "msg": "must be >= 1"}] + # the override channel now carries the canonical body + assert e.data == wire + + def test_finalize_is_idempotent(self, fmt): + e = UnprocessableEntity() + e.data = { + "message": "Request validation failed", + "errors": [{"type": "int_parsing", "loc": ["page"], "msg": "must be >= 1"}], + } + data = {"code": "unprocessable_entity", "message": e.description, "status": 422} + + first = fmt.finalize(e, data, 422) + second = fmt.finalize(e, data, 422) + + assert second == first + + def test_malformed_canonical_details_falls_back_instead_of_raising(self, fmt): + # finalize runs inside the framework error handler; a ValidationError + # escaping it would replace the response with an unformatted 500 + e = UnprocessableEntity() + e.data = {"message": "broken", "details": [{"bad": "shape"}]} + data = {"code": "unprocessable_entity", "message": "broken", "status": 422} + + wire = fmt.finalize(e, data, 422) + + assert wire == {"code": "invalid_param", "message": "Unprocessable Entity", "status": 422} + + def test_base_http_exception_error_code_wins_over_status_map(self, fmt): + e = ProviderQuotaExceededError() + data = dict(e.data) + + wire = fmt.finalize(e, data, 400) + + assert wire["code"] == "provider_quota_exceeded" + assert wire["status"] == 400 + + def test_hint_attribute_is_emitted(self, fmt): + e = Conflict("seat limit") + e.hint = "remove a member first" + data = {"code": "conflict", "message": "seat limit", "status": 409} + + wire = fmt.finalize(e, data, 409) + + assert wire["hint"] == "remove a member first" + + def test_params_shape_becomes_details(self, fmt): + e = ValueError("is required") + data = {"code": "invalid_param", "message": "is required", "params": "email", "status": 400} + + wire = fmt.finalize(e, data, 400) + + assert "params" not in wire + assert wire["details"] == [{"type": "invalid", "loc": ["email"], "msg": "is required"}] + + def test_catch_all_exception_never_leaks_str_e(self, fmt): + e = RuntimeError("postgres password=hunter2 connection refused") + data = {"message": str(e), "code": "unknown", "status": 500} + + wire = fmt.finalize(e, data, 500) + + assert wire["code"] == "internal_server_error" + assert "hunter2" not in wire["message"] + + def test_unmapped_status_falls_back_to_unknown(self, fmt): + from werkzeug.exceptions import Gone + + e = Gone() + data = {"code": "gone", "message": e.description, "status": 410} + + wire = fmt.finalize(e, data, 410) + + assert wire["code"] == "unknown" + + def test_openapi_error_subclass_is_throw_and_done(self, fmt): + # The dedicated throwable: subclass declares status + code + message once, + # call sites just `raise`; the formatter emits everything verbatim. + class TeapotError(OpenApiError): + code = 418 + error_code = OpenApiErrorCode.INVALID_PARAM + description = "kettle says no" + + e = TeapotError(details=[ErrorDetail(type="invalid", loc=["kettle"], msg="too hot")]) + data = {"code": "im_a_teapot", "message": e.description, "status": 418} + + wire = fmt.finalize(e, data, 418) + + assert wire["code"] == OpenApiErrorCode.INVALID_PARAM + assert wire["message"] == TeapotError.description + assert wire["details"] == [{"type": "invalid", "loc": ["kettle"], "msg": "too hot"}] + + def test_openapi_error_message_override(self, fmt): + e = OpenApiError("custom reason") + data = {"code": "bad_request", "message": e.description, "status": 400} + + wire = fmt.finalize(e, data, 400) + + assert wire["message"] == "custom reason" + assert wire["code"] == "bad_request" + + def test_every_emitted_code_is_an_enum_member(self, fmt): + # Guard against the formatter inventing codes outside the contract. + cases = [ + (NotFound("x"), {"code": "not_found", "message": "x", "status": 404}, 404), + (ProviderQuotaExceededError(), dict(ProviderQuotaExceededError().data), 400), + (ValueError("x"), {"code": "invalid_param", "message": "x", "status": 400}, 400), + ] + for e, data, status in cases: + wire = fmt.finalize(e, data, status) + assert wire["code"] in {c.value for c in OpenApiErrorCode} + + +class TestQuotaExceptions: + @pytest.mark.parametrize("exc_class", [MemberLimitExceeded, MemberLicenseExceeded]) + def test_quota_exception_carries_declared_code_and_message(self, fmt, exc_class): + # Single source: assertions read the class attributes, no re-typed strings. + e = exc_class() + data = {"code": "forbidden", "message": e.description, "status": 403} + + wire = fmt.finalize(e, data, 403) + + assert wire["code"] == exc_class.error_code + assert wire["message"] == exc_class.description + assert wire["hint"] == exc_class.hint + assert wire["status"] == 403 + + +class TestWireContract: + """End-to-end: request in, canonical JSON out, through the real openapi blueprint.""" + + def test_accepts_422_carries_code_status_details(self, openapi_app, bypass_pipeline): + client = openapi_app.test_client() + + resp = client.get("/openapi/v1/apps?page=0") + + assert resp.status_code == 422 + wire = resp.get_json() + ErrorBody.model_validate(wire) + assert wire["code"] == "invalid_param" + assert wire["status"] == 422 + assert wire["details"] + + def test_unknown_route_404_is_canonical_without_route_suggestions(self, openapi_app): + client = openapi_app.test_client() + + resp = client.get("/openapi/v1/definitely-not-a-route") + + assert resp.status_code == 404 + wire = resp.get_json() + ErrorBody.model_validate(wire) + assert wire["code"] == "not_found" + assert "did you mean" not in wire["message"].lower() + + def test_404_outside_blueprint_prefix_is_not_claimed(self, openapi_app): + # catch_all_404s wraps the app-level exception handler; the prefix + # guard must keep non-/openapi/v1 paths on the app's own 404 handling + client = openapi_app.test_client() + + resp = client.get("/console/definitely-not-a-route") + + assert resp.status_code == 404 + # not intercepted → Flask's default HTML 404, not the canonical JSON body + assert "application/json" not in (resp.content_type or "") + + @patch("controllers.openapi.oauth_device.DeviceFlowRedis") + def test_oauth_device_token_keeps_rfc8628_shape(self, mock_redis_cls, openapi_app): + store = MagicMock() + mock_redis_cls.return_value = store + store.record_poll.return_value = None # not SlowDownDecision.SLOW_DOWN + store.load_by_device_code.return_value = None # unknown code → expired_token + + client = openapi_app.test_client() + + resp = client.post( + "/openapi/v1/oauth/device/token", + json={"client_id": "difyctl", "device_code": "nope"}, + ) + + assert resp.status_code == 400 + wire = resp.get_json() + assert wire == {"error": "expired_token"} + + +ERROR_MATRIX = [ + (BadRequest("x"), 400, "bad_request"), + (Unauthorized("x"), 401, "unauthorized"), + (Forbidden("x"), 403, "forbidden"), + (NotFound("x"), 404, "not_found"), + (Conflict("x"), 409, "conflict"), + (UnprocessableEntity("x"), 422, "invalid_param"), + (InternalServerError(), 500, "internal_server_error"), + (BadGateway("x"), 502, "bad_gateway"), + (AppUnavailableError(), 400, "app_unavailable"), + (ConversationCompletedError(), 400, "conversation_completed"), + (ProviderNotInitializeError(), 400, "provider_not_initialize"), + (ProviderQuotaExceededError(), 400, "provider_quota_exceeded"), + (ProviderModelCurrentlyNotSupportError(), 400, "model_currently_not_support"), + (CompletionRequestError(), 400, "completion_request_error"), + (InvokeRateLimitHttpError(), 429, "rate_limit_error"), + (TooManyRequests("x"), 429, "too_many_requests"), # difyctl's classifyRateLimit keys retryability on this code + (FileTooLargeError(), 413, "file_too_large"), + (UnsupportedFileTypeError(), 415, "unsupported_file_type"), + (NoFileUploadedError(), 400, "no_file_uploaded"), + (TooManyFilesError(), 400, "too_many_files"), + (FilenameNotExists(), 400, "filename_not_exists"), + (BlockedFileExtensionError(), 400, "file_extension_blocked"), + (MemberLimitExceeded(), 403, "member_limit_exceeded"), + (MemberLicenseExceeded(), 403, "member_license_exceeded"), +] + + +class TestErrorMatrix: + @pytest.mark.parametrize( + ("exc", "status", "expected_code"), + ERROR_MATRIX, + ids=lambda v: type(v).__name__ if isinstance(v, Exception) else str(v), + ) + def test_every_known_error_path_yields_canonical_code(self, fmt, exc, status, expected_code): + data = dict(getattr(exc, "data", None) or {"message": str(exc), "status": status}) + + wire = fmt.finalize(exc, data, status) + + assert wire["code"] == expected_code + assert wire["status"] == status + assert wire["code"] in {c.value for c in OpenApiErrorCode} + ErrorBody.model_validate(wire) + + +class TestErrorCodeEnumRegistration: + def test_enum_registered_with_all_values(self): + from controllers.openapi import openapi_ns + from controllers.openapi._errors import OpenApiErrorCode + + model = openapi_ns.models.get("OpenApiErrorCode") + assert model is not None + schema = model.__schema__ + assert schema["type"] == "string" + assert set(schema["enum"]) == {member.value for member in OpenApiErrorCode} diff --git a/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py b/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py index 6bb13ad3227..4c09491ab59 100644 --- a/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py +++ b/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py @@ -29,9 +29,10 @@ import pytest from flask import Flask from flask.views import MethodView from pydantic import ValidationError -from werkzeug.exceptions import BadRequest, Forbidden, NotFound, UnprocessableEntity +from werkzeug.exceptions import BadRequest, NotFound, UnprocessableEntity from controllers.openapi import bp as openapi_bp +from controllers.openapi._errors import MemberLicenseExceeded, MemberLimitExceeded from controllers.openapi._models import MemberInvitePayload, MemberRoleUpdatePayload from controllers.openapi.workspaces import ( WorkspaceMemberApi, @@ -507,11 +508,7 @@ def _invite_request(app, ws_id: str, acct_id: uuid.UUID): def test_invite_blocked_by_saas_members_cap(app, bypass_pipeline, monkeypatch): - """SaaS billing plan member cap → 403 with `members.limit_exceeded`. - - Verifies the envelope shape the CLI error-mapper relies on (code + - message + hint on the wire body). - """ + """SaaS billing plan member cap → MemberLimitExceeded (403).""" ws_id = str(uuid.uuid4()) acct_id = uuid.uuid4() api = WorkspaceMembersApi() @@ -538,18 +535,14 @@ def test_invite_blocked_by_saas_members_cap(app, bypass_pipeline, monkeypatch): with _invite_request(app, ws_id, acct_id): _seed(_auth_ctx(account_id=acct_id)) - with pytest.raises(Forbidden) as exc_info: + with pytest.raises(MemberLimitExceeded): api.post.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id)) - body = exc_info.value.response.json - assert body["code"] == "members.limit_exceeded" - assert "Subscription member limit" in body["message"] - assert body["hint"] invite_mock.assert_not_called() def test_invite_blocked_by_ee_workspace_members_license(app, bypass_pipeline, monkeypatch): - """EE License workspace_members cap → 403 with `workspace_members.license_exceeded`. + """EE License workspace_members cap → MemberLicenseExceeded (403). Note: billing.enabled is False (EE without SaaS billing); only the license cap fires. @@ -584,13 +577,9 @@ def test_invite_blocked_by_ee_workspace_members_license(app, bypass_pipeline, mo with _invite_request(app, ws_id, acct_id): _seed(_auth_ctx(account_id=acct_id)) - with pytest.raises(Forbidden) as exc_info: + with pytest.raises(MemberLicenseExceeded): api.post.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id)) - body = exc_info.value.response.json - assert body["code"] == "workspace_members.license_exceeded" - assert "license" in body["message"].lower() - assert body["hint"] invite_mock.assert_not_called() diff --git a/api/tests/unit_tests/controllers/service_api/app/test_file_preview.py b/api/tests/unit_tests/controllers/service_api/app/test_file_preview.py index f5e8453c5cd..6fe1cbb71d2 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_file_preview.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_file_preview.py @@ -2,6 +2,7 @@ Unit tests for Service API File Preview endpoint """ +import logging import uuid from unittest.mock import Mock, patch @@ -348,8 +349,7 @@ class TestFilePreviewApi: assert "Storage error" in str(exc_info.value) - @patch("controllers.service_api.app.file_preview.logger") - def test_validate_file_ownership_unexpected_error_logging(self, mock_logger, file_preview_api: FilePreviewApi): + def test_validate_file_ownership_unexpected_error_logging(self, file_preview_api: FilePreviewApi, caplog): """Test that unexpected errors are logged properly""" file_id = str(uuid.uuid4()) app_id = str(uuid.uuid4()) @@ -359,14 +359,18 @@ class TestFilePreviewApi: mock_db.session.scalar.side_effect = Exception("Unexpected database error") # Execute and assert exception - with pytest.raises(FileAccessDeniedError) as exc_info: - file_preview_api._validate_file_ownership(file_id, app_id) + with caplog.at_level(logging.ERROR, logger="controllers.service_api.app.file_preview"): + with pytest.raises(FileAccessDeniedError) as exc_info: + file_preview_api._validate_file_ownership(file_id, app_id) # Verify error message assert "File access validation failed" in str(exc_info.value) - # Verify logging was called - mock_logger.exception.assert_called_once_with( - "Unexpected error during file ownership validation", - extra={"file_id": file_id, "app_id": app_id, "error": "Unexpected database error"}, - ) + # Verify logging was called with the structured context fields. The ``extra`` keys + # are attached to the LogRecord as attributes, so they are not in ``caplog.text``. + assert len(caplog.records) == 1 + record = caplog.records[0] + assert record.getMessage() == "Unexpected error during file ownership validation" + assert record.file_id == file_id + assert record.app_id == app_id + assert record.error == "Unexpected database error" diff --git a/api/tests/unit_tests/controllers/service_api/dataset/test_hit_testing.py b/api/tests/unit_tests/controllers/service_api/dataset/test_hit_testing.py index 4809cc0e8a2..f47de0c8d50 100644 --- a/api/tests/unit_tests/controllers/service_api/dataset/test_hit_testing.py +++ b/api/tests/unit_tests/controllers/service_api/dataset/test_hit_testing.py @@ -9,21 +9,21 @@ Strategy: - ``HitTestingApi.post`` is decorated with ``@cloud_edition_billing_rate_limit_check`` which preserves ``__wrapped__``. We call ``post.__wrapped__(self, ...)`` to skip the billing decorator and test the business logic directly. -- Base-class methods (``get_and_validate_dataset``, ``perform_hit_testing``) read - ``current_user`` from ``controllers.console.datasets.hit_testing_base``, so we - patch it there. +- ``validate_dataset_token`` installs the tenant owner account into Flask-Login's + request context before calling the handler, so direct method-call tests install + the same concrete account on ``g._login_user``. """ import uuid -from unittest.mock import Mock, patch +from unittest.mock import patch import pytest -from flask import Flask +from flask import Flask, g from werkzeug.exceptions import Forbidden, NotFound import services from controllers.service_api.dataset.hit_testing import HitTestingApi, HitTestingPayload -from models.account import Account +from models.account import Account, Tenant, TenantAccountRole from models.dataset import Dataset from services.entities.knowledge_entities.knowledge_entities import RetrievalModel @@ -131,13 +131,21 @@ class TestHitTestingApiPost: def _dataset(dataset_id: str, tenant_id: str) -> Dataset: return Dataset(id=dataset_id, tenant_id=tenant_id, name="Dataset", created_by="account-1") + @staticmethod + def _account(tenant_id: str) -> Account: + account = Account(name="Service API", email="service-api@example.com") + account.id = "account-1" + tenant = Tenant(name="Tenant") + tenant.id = tenant_id + account._current_tenant = tenant + account.role = TenantAccountRole.OWNER + return account + @patch("controllers.service_api.dataset.hit_testing.service_api_ns") @patch("controllers.console.datasets.hit_testing_base.HitTestingService") @patch("controllers.console.datasets.hit_testing_base.DatasetService") - @patch("controllers.console.datasets.hit_testing_base.current_user", new_callable=lambda: Mock(spec=Account)) def test_post_success( self, - mock_current_user, mock_dataset_svc, mock_hit_svc, mock_ns, @@ -148,6 +156,7 @@ class TestHitTestingApiPost: tenant_id = str(uuid.uuid4()) mock_dataset = self._dataset(dataset_id, tenant_id) + account = self._account(tenant_id) mock_dataset_svc.get_dataset.return_value = mock_dataset mock_dataset_svc.check_dataset_permission.return_value = None @@ -158,6 +167,8 @@ class TestHitTestingApiPost: mock_ns.payload = {"query": "test query"} with app.test_request_context(): + # TODO: the service APIs are NOT migrated yet, so we have to do the very dirty hack + g._login_user = account api = HitTestingApi() # Skip billing decorator via __wrapped__ response = HitTestingApi.post.__wrapped__(api, tenant_id, dataset_id) @@ -168,10 +179,8 @@ class TestHitTestingApiPost: @patch("controllers.service_api.dataset.hit_testing.service_api_ns") @patch("controllers.console.datasets.hit_testing_base.HitTestingService") @patch("controllers.console.datasets.hit_testing_base.DatasetService") - @patch("controllers.console.datasets.hit_testing_base.current_user", new_callable=lambda: Mock(spec=Account)) def test_post_with_retrieval_model( self, - mock_current_user, mock_dataset_svc, mock_hit_svc, mock_ns, @@ -182,6 +191,7 @@ class TestHitTestingApiPost: tenant_id = str(uuid.uuid4()) mock_dataset = self._dataset(dataset_id, tenant_id) + account = self._account(tenant_id) mock_dataset_svc.get_dataset.return_value = mock_dataset mock_dataset_svc.check_dataset_permission.return_value = None @@ -204,6 +214,8 @@ class TestHitTestingApiPost: } with app.test_request_context(): + # TODO: the service APIs are NOT migrated yet, so we have to do the very dirty hack + g._login_user = account api = HitTestingApi() response = HitTestingApi.post.__wrapped__(api, tenant_id, dataset_id) @@ -218,10 +230,8 @@ class TestHitTestingApiPost: @patch("controllers.service_api.dataset.hit_testing.service_api_ns") @patch("controllers.console.datasets.hit_testing_base.HitTestingService") @patch("controllers.console.datasets.hit_testing_base.DatasetService") - @patch("controllers.console.datasets.hit_testing_base.current_user", new_callable=lambda: Mock(spec=Account)) def test_post_preserves_retrieval_model_metadata_filtering_conditions( self, - mock_current_user, mock_dataset_svc, mock_hit_svc, mock_ns, @@ -232,6 +242,7 @@ class TestHitTestingApiPost: tenant_id = str(uuid.uuid4()) mock_dataset = self._dataset(dataset_id, tenant_id) + account = self._account(tenant_id) mock_dataset_svc.get_dataset.return_value = mock_dataset mock_dataset_svc.check_dataset_permission.return_value = None @@ -260,6 +271,8 @@ class TestHitTestingApiPost: } with app.test_request_context(): + # TODO: the service APIs are NOT migrated yet, so we have to do the very dirty hack + g._login_user = account api = HitTestingApi() HitTestingApi.post.__wrapped__(api, tenant_id, dataset_id) @@ -270,10 +283,8 @@ class TestHitTestingApiPost: @patch("controllers.service_api.dataset.hit_testing.service_api_ns") @patch("controllers.console.datasets.hit_testing_base.HitTestingService") @patch("controllers.console.datasets.hit_testing_base.DatasetService") - @patch("controllers.console.datasets.hit_testing_base.current_user", new_callable=lambda: Mock(spec=Account)) def test_post_prepares_nullable_list_fields( self, - mock_current_user, mock_dataset_svc, mock_hit_svc, mock_ns, @@ -284,6 +295,7 @@ class TestHitTestingApiPost: tenant_id = str(uuid.uuid4()) mock_dataset = self._dataset(dataset_id, tenant_id) + account = self._account(tenant_id) mock_dataset_svc.get_dataset.return_value = mock_dataset mock_dataset_svc.check_dataset_permission.return_value = None @@ -297,6 +309,8 @@ class TestHitTestingApiPost: mock_ns.payload = {"query": "legacy query"} with app.test_request_context(): + # TODO: the service APIs are NOT migrated yet, so we have to do the very dirty hack + g._login_user = account api = HitTestingApi() response = HitTestingApi.post.__wrapped__(api, tenant_id, dataset_id) @@ -312,10 +326,8 @@ class TestHitTestingApiPost: @patch("controllers.service_api.dataset.hit_testing.service_api_ns") @patch("controllers.console.datasets.hit_testing_base.DatasetService") - @patch("controllers.console.datasets.hit_testing_base.current_user", new_callable=lambda: Mock(spec=Account)) def test_post_dataset_not_found( self, - mock_current_user, mock_dataset_svc, mock_ns, app: Flask, @@ -323,21 +335,22 @@ class TestHitTestingApiPost: """Test hit testing with non-existent dataset.""" dataset_id = str(uuid.uuid4()) tenant_id = str(uuid.uuid4()) + account = self._account(tenant_id) mock_dataset_svc.get_dataset.return_value = None mock_ns.payload = {"query": "test query"} with app.test_request_context(): + # TODO: the service APIs are NOT migrated yet, so we have to do the very dirty hack + g._login_user = account api = HitTestingApi() with pytest.raises(NotFound): HitTestingApi.post.__wrapped__(api, tenant_id, dataset_id) @patch("controllers.service_api.dataset.hit_testing.service_api_ns") @patch("controllers.console.datasets.hit_testing_base.DatasetService") - @patch("controllers.console.datasets.hit_testing_base.current_user", new_callable=lambda: Mock(spec=Account)) def test_post_no_dataset_permission( self, - mock_current_user, mock_dataset_svc, mock_ns, app: Flask, @@ -347,6 +360,7 @@ class TestHitTestingApiPost: tenant_id = str(uuid.uuid4()) mock_dataset = self._dataset(dataset_id, tenant_id) + account = self._account(tenant_id) mock_dataset_svc.get_dataset.return_value = mock_dataset mock_dataset_svc.check_dataset_permission.side_effect = services.errors.account.NoPermissionError( @@ -355,6 +369,8 @@ class TestHitTestingApiPost: mock_ns.payload = {"query": "test query"} with app.test_request_context(): + # TODO: the service APIs are NOT migrated yet, so we have to do the very dirty hack + g._login_user = account api = HitTestingApi() with pytest.raises(Forbidden): HitTestingApi.post.__wrapped__(api, tenant_id, dataset_id) diff --git a/api/tests/unit_tests/controllers/test_swagger.py b/api/tests/unit_tests/controllers/test_swagger.py index 4e81763a20a..898eb2e86b7 100644 --- a/api/tests/unit_tests/controllers/test_swagger.py +++ b/api/tests/unit_tests/controllers/test_swagger.py @@ -1,20 +1,23 @@ -"""Swagger JSON rendering tests for Flask-RESTX API blueprints.""" +"""OpenAPI JSON rendering tests for Flask-RESTX API blueprints.""" + +import json +from collections.abc import Iterator import pytest from flask import Flask -def _definition_refs(value: object) -> set[str]: +def _schema_refs(value: object) -> set[str]: refs: set[str] = set() if isinstance(value, dict): ref = value.get("$ref") - if isinstance(ref, str) and ref.startswith("#/definitions/"): - refs.add(ref.removeprefix("#/definitions/")) + if isinstance(ref, str) and ref.startswith("#/components/schemas/"): + refs.add(ref.removeprefix("#/components/schemas/")) for item in value.values(): - refs.update(_definition_refs(item)) + refs.update(_schema_refs(item)) elif isinstance(value, list): for item in value: - refs.update(_definition_refs(item)) + refs.update(_schema_refs(item)) return refs @@ -31,6 +34,29 @@ def _parameters_by_name(operation: dict[str, object]) -> dict[str, dict[str, obj return result +def _get_operations(payload: dict[str, object]) -> Iterator[tuple[str, dict[str, object]]]: + paths = payload["paths"] + assert isinstance(paths, dict) + for path, path_item in paths.items(): + if not isinstance(path, str) or not isinstance(path_item, dict): + continue + operation = path_item.get("get") + if isinstance(operation, dict): + yield path, operation + + +def _multipart_form_schema(operation: dict[str, object]) -> dict[str, object]: + request_body = operation.get("requestBody") + assert isinstance(request_body, dict) + content = request_body.get("content") + assert isinstance(content, dict) + multipart = content.get("multipart/form-data") + assert isinstance(multipart, dict) + schema = multipart.get("schema") + assert isinstance(schema, dict) + return schema + + @pytest.mark.parametrize( ("first_kwargs", "second_kwargs"), [ @@ -53,7 +79,7 @@ def test_inline_model_name_includes_list_constraints( assert _inline_model_name(first_inline_model) != _inline_model_name(second_inline_model) -def test_swagger_json_endpoints_render(monkeypatch: pytest.MonkeyPatch): +def test_openapi_json_endpoints_render(monkeypatch: pytest.MonkeyPatch): from configs import dify_config from controllers.console import bp as console_bp from controllers.service_api import bp as service_api_bp @@ -70,17 +96,19 @@ def test_swagger_json_endpoints_render(monkeypatch: pytest.MonkeyPatch): client = app.test_client() - for route in ("/console/api/swagger.json", "/api/swagger.json", "/v1/swagger.json"): + for route in ("/console/api/openapi.json", "/api/openapi.json", "/v1/openapi.json"): response = client.get(route) assert response.status_code == 200 payload = response.get_json() - assert payload["swagger"] == "2.0" + assert payload["openapi"].startswith("3.") assert "paths" in payload - assert "definitions" in payload - assert isinstance(payload["definitions"], dict) - missing_refs = _definition_refs(payload) - set(payload["definitions"]) - assert not sorted(ref for ref in missing_refs if ref.startswith("_AnonymousInlineModel")) + assert "schemas" in payload["components"] + assert isinstance(payload["components"]["schemas"], dict) + missing_refs = _schema_refs(payload) - set(payload["components"]["schemas"]) + assert not missing_refs + get_request_body_paths = [path for path, operation in _get_operations(payload) if "requestBody" in operation] + assert not get_request_body_paths assert app.config["RESTX_INCLUDE_ALL_MODELS"] is True @@ -96,17 +124,17 @@ def test_service_document_file_routes_document_multipart_form_data(monkeypatch: app.config["RESTX_INCLUDE_ALL_MODELS"] = True app.register_blueprint(service_api_bp) - payload = app.test_client().get("/v1/swagger.json").get_json() + payload = app.test_client().get("/v1/openapi.json").get_json() paths = payload["paths"] create_operation = paths["/datasets/{dataset_id}/document/create-by-file"]["post"] - create_params = _parameters_by_name(create_operation) - assert create_operation["consumes"] == ["multipart/form-data"] - assert create_params["file"]["in"] == "formData" - assert create_params["file"]["type"] == "file" - assert create_params["file"]["required"] is True - assert create_params["data"]["in"] == "formData" - assert create_params["data"]["type"] == "string" + create_schema = _multipart_form_schema(create_operation) + create_properties = create_schema["properties"] + assert isinstance(create_properties, dict) + assert create_properties["file"] == {"type": "string", "format": "binary"} + assert create_properties["data"] == {"type": "string"} + assert create_schema["required"] == ["file"] + assert create_operation["requestBody"]["required"] is True for path in ( "/datasets/{dataset_id}/documents/{document_id}", @@ -114,13 +142,13 @@ def test_service_document_file_routes_document_multipart_form_data(monkeypatch: "/datasets/{dataset_id}/documents/{document_id}/update_by_file", ): update_operation = paths[path]["patch" if path.endswith("{document_id}") else "post"] - update_params = _parameters_by_name(update_operation) - assert update_operation["consumes"] == ["multipart/form-data"] - assert update_params["file"]["in"] == "formData" - assert update_params["file"]["type"] == "file" - assert update_params["file"]["required"] is False - assert update_params["data"]["in"] == "formData" - assert update_params["data"]["type"] == "string" + update_schema = _multipart_form_schema(update_operation) + update_properties = update_schema["properties"] + assert isinstance(update_properties, dict) + assert update_properties["file"] == {"type": "string", "format": "binary"} + assert update_properties["data"] == {"type": "string"} + assert "required" not in update_schema + assert update_operation["requestBody"]["required"] is False def test_service_document_list_documents_query_params_render(monkeypatch: pytest.MonkeyPatch): @@ -134,7 +162,7 @@ def test_service_document_list_documents_query_params_render(monkeypatch: pytest app.config["RESTX_INCLUDE_ALL_MODELS"] = True app.register_blueprint(service_api_bp) - payload = app.test_client().get("/v1/swagger.json").get_json() + payload = app.test_client().get("/v1/openapi.json").get_json() operation = payload["paths"]["/datasets/{dataset_id}/documents"]["get"] params = _parameters_by_name(operation) @@ -153,10 +181,46 @@ def test_console_account_avatar_query_param_renders_as_query(monkeypatch: pytest app.config["RESTX_INCLUDE_ALL_MODELS"] = True app.register_blueprint(console_bp) - payload = app.test_client().get("/console/api/swagger.json").get_json() + payload = app.test_client().get("/console/api/openapi.json").get_json() operation = payload["paths"]["/account/avatar"]["get"] params = _parameters_by_name(operation) assert "payload" not in params assert params["avatar"]["in"] == "query" assert params["avatar"]["required"] is True + + +def test_console_plugin_category_list_exported_schema_uses_typed_items(tmp_path): + from dev.generate_swagger_specs import generate_specs + + written_paths = generate_specs(tmp_path) + console_openapi_path = next(path for path in written_paths if path.name == "console-openapi.json") + payload = json.loads(console_openapi_path.read_text(encoding="utf-8")) + operation = payload["paths"]["/workspaces/current/plugin/{category}/list"]["get"] + response_ref = operation["responses"]["200"]["content"]["application/json"]["schema"]["$ref"].removeprefix( + "#/components/schemas/" + ) + schemas = payload["components"]["schemas"] + response_schema = schemas[response_ref] + + assert response_schema["properties"]["plugins"]["items"]["$ref"] == ( + "#/components/schemas/PluginCategoryInstalledPluginResponse" + ) + assert response_schema["properties"]["builtin_tools"]["items"]["$ref"] == ( + "#/components/schemas/PluginCategoryBuiltinToolProviderResponse" + ) + + installed_plugin_schema = schemas["PluginCategoryInstalledPluginResponse"] + for field in ( + "plugin_unique_identifier", + "source", + "version", + "declaration", + "endpoints_active", + "endpoints_setups", + ): + assert field in installed_plugin_schema["properties"] + + builtin_tool_schema = schemas["PluginCategoryBuiltinToolProviderResponse"] + for field in ("plugin_unique_identifier", "team_credentials", "type", "tools"): + assert field in builtin_tool_schema["properties"] diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py index 9726c939e94..b75f6d44943 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py @@ -562,7 +562,7 @@ class TestAdvancedChatGenerateTaskPipeline: assert list(pipeline._handle_human_input_form_timeout_event(timeout_event)) == ["timeout"] assert persisted == ["saved"] - def test_save_message_strips_markdown_and_sets_usage(self): + def test_save_message_preserves_full_answer_and_sets_usage(self): pipeline = _make_pipeline() pipeline._recorded_files = [ { @@ -572,7 +572,8 @@ class TestAdvancedChatGenerateTaskPipeline: "related_id": "file-id", } ] - pipeline._task_state.answer = "![img](url) hello" + # The answer is stored verbatim; markdown image links are never stripped. + pipeline._task_state.answer = "![img](http://example.com/file.png) hello ![inline](http://llm.com/img.jpg)" pipeline._task_state.is_streaming_response = True pipeline._task_state.first_token_time = pipeline._base_task_pipeline.start_at + 0.1 pipeline._task_state.last_token_time = pipeline._base_task_pipeline.start_at + 0.2 @@ -614,7 +615,7 @@ class TestAdvancedChatGenerateTaskPipeline: pipeline._save_message(session=_Session(), graph_runtime_state=graph_runtime_state) assert message.status == MessageStatus.NORMAL - assert message.answer == "hello" + assert message.answer == "![img](http://example.com/file.png) hello ![inline](http://llm.com/img.jpg)" assert message.message_metadata def test_handle_stop_event_saves_message_for_moderation(self, monkeypatch: pytest.MonkeyPatch): diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py b/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py index dcaa31e15eb..6c36815753e 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py @@ -201,17 +201,18 @@ class TestGenerateWorker: mocker.patch(f"{MODULE}.AgentAppRunner", return_value=runner) return runner - def _call(self, generator, mocker: MockerFixture, queue_manager): + def _call(self, generator, mocker: MockerFixture, queue_manager, *, is_resume=False, query="query"): generator._generate_worker( flask_app=mocker.MagicMock(), context=mocker.MagicMock(), application_generate_entity=mocker.MagicMock( - agent_id="a", agent_config_snapshot_id="s", model_conf=mocker.MagicMock(model="m") + agent_id="a", agent_config_snapshot_id="s", model_conf=mocker.MagicMock(model="m"), query=query ), queue_manager=queue_manager, conversation_id="conv", message_id="msg", user_from=UserFrom.END_USER, + is_resume=is_resume, ) def test_happy_path_runs_backend(self, generator, mocker: MockerFixture): @@ -227,6 +228,20 @@ class TestGenerateWorker: self._call(generator, mocker, queue_manager) runner.run.assert_not_called() + def test_resume_skips_input_guards_and_consumes_reply(self, generator, mocker: MockerFixture): + # ENG-638 (review): on resume the replayed query is NOT new end-user input. + # Input guards must be skipped, even if moderation/annotation would match, + # so the run continues and the human reply (deferred_tool_results) is used. + runner = self._wire(generator, mocker, handled=True) # guards WOULD short-circuit + queue_manager = mocker.MagicMock() + + self._call(generator, mocker, queue_manager, is_resume=True, query="the approved reply") + + generator._run_input_guards.assert_not_called() + runner.run.assert_called_once() + # the replayed paused-turn query flows straight to the runner (snapshot match) + assert runner.run.call_args.kwargs["query"] == "the approved reply" + def test_generate_task_stopped_is_swallowed(self, generator, mocker: MockerFixture): self._wire(generator, mocker, run_side_effect=GenerateTaskStoppedError()) queue_manager = mocker.MagicMock() @@ -238,3 +253,56 @@ class TestGenerateWorker: queue_manager = mocker.MagicMock() self._call(generator, mocker, queue_manager) assert queue_manager.publish_error.called + + +class TestResumeAfterFormSubmission: + """ENG-638: a resume turn re-sends the paused turn's original query so the + composition's user-prompt layer matches the suspended snapshot (never blank).""" + + def _wire(self, generator, mocker: MockerFixture): + generator._resolve_agent = mocker.MagicMock( + return_value=(mocker.MagicMock(id="agent1"), mocker.MagicMock(id="snap1"), mocker.MagicMock()) + ) + generator._init_generate_records = mocker.MagicMock( + return_value=(mocker.MagicMock(id="conv", mode="agent"), mocker.MagicMock(id="msg")) + ) + generator._handle_response = mocker.MagicMock(return_value=None) + mocker.patch(f"{MODULE}.ConversationService.get_conversation", return_value=mocker.MagicMock(id="conv")) + mocker.patch(f"{MODULE}.AgentAppConfigManager.get_app_config", return_value=mocker.MagicMock(variables=[])) + mocker.patch(f"{MODULE}.ModelConfigConverter.convert", return_value=mocker.MagicMock()) + mocker.patch(f"{MODULE}.TraceQueueManager", return_value=mocker.MagicMock()) + mocker.patch(f"{MODULE}.MessageBasedAppQueueManager", return_value=mocker.MagicMock()) + mocker.patch(f"{MODULE}.threading.Thread", return_value=mocker.MagicMock()) + return mocker.patch( + f"{MODULE}.AgentAppGenerateEntity", return_value=mocker.MagicMock(task_id="t", user_id="user") + ) + + def test_resume_resends_paused_turn_query(self, generator, mocker: MockerFixture): + entity = self._wire(generator, mocker) + db_mock = mocker.patch(f"{MODULE}.db") + db_mock.session.scalar.return_value = mocker.MagicMock(query="original question") + + generator.resume_after_form_submission( + app_model=mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent"), + user=DummyAccount("user"), + conversation_id="conv", + invoke_from=InvokeFrom.WEB_APP, + ) + + # The paused turn's query is re-sent verbatim — never blank. + assert entity.call_args.kwargs["query"] == "original question" + + def test_resume_falls_back_to_placeholder_when_no_paused_message(self, generator, mocker: MockerFixture): + entity = self._wire(generator, mocker) + db_mock = mocker.patch(f"{MODULE}.db") + db_mock.session.scalar.return_value = None + + generator.resume_after_form_submission( + app_model=mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent"), + user=DummyAccount("user"), + conversation_id="conv", + invoke_from=InvokeFrom.WEB_APP, + ) + + # No prior user message -> a non-blank placeholder, still never blank. + assert entity.call_args.kwargs["query"] == "(resumed)" diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py b/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py index e6b587bdde7..e696d4aaa0b 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py @@ -6,10 +6,12 @@ from __future__ import annotations from types import SimpleNamespace from typing import Any, override +from unittest.mock import MagicMock import pytest from agenton.compositor import CompositorSessionSnapshot -from dify_agent.protocol import CancelRunRequest, CancelRunResponse +from dify_agent.layers.ask_human import AskHumanToolResult +from dify_agent.protocol import CancelRunRequest, CancelRunResponse, RuntimeLayerSpec from clients.agent_backend import ( AgentBackendError, @@ -19,10 +21,11 @@ from clients.agent_backend import ( ) from core.app.apps.agent_app.app_runner import AgentAppRunner from core.app.apps.agent_app.runtime_request_builder import AgentAppRuntimeRequestBuilder -from core.app.apps.agent_app.session_store import AgentAppSessionScope +from core.app.apps.agent_app.session_store import AgentAppSessionScope, StoredAgentAppSession from core.app.apps.exc import GenerateTaskStoppedError from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom from core.app.entities.queue_entities import QueueLLMChunkEvent, QueueMessageEndEvent +from core.workflow.nodes.agent_v2.ask_human_resume import AskHumanResumeOutcome from models.agent_config_entities import AgentSoulConfig @@ -65,15 +68,47 @@ class _RecordingFakeAgentBackendRunClient(FakeAgentBackendRunClient): class _FakeSessionStore: - def __init__(self, loaded: CompositorSessionSnapshot | None = None) -> None: + def __init__( + self, + loaded: CompositorSessionSnapshot | None = None, + loaded_session: StoredAgentAppSession | None = None, + ) -> None: self.loaded = loaded - self.saved: list[tuple[AgentAppSessionScope, str, CompositorSessionSnapshot | None]] = [] + self._loaded_session = loaded_session + self.saved: list[ + tuple[ + AgentAppSessionScope, + str, + CompositorSessionSnapshot | None, + list[RuntimeLayerSpec], + str | None, + str | None, + ] + ] = [] def load_active_snapshot(self, scope: AgentAppSessionScope) -> CompositorSessionSnapshot | None: return self.loaded - def save_active_snapshot(self, *, scope, backend_run_id, snapshot) -> None: - self.saved.append((scope, backend_run_id, snapshot)) + def load_active_session(self, scope: AgentAppSessionScope) -> StoredAgentAppSession | None: + if self._loaded_session is not None: + return self._loaded_session + if self.loaded is None: + return None + return StoredAgentAppSession(scope=scope, session_snapshot=self.loaded, backend_run_id=None) + + def save_active_snapshot( + self, + *, + scope, + backend_run_id, + snapshot, + runtime_layer_specs, + pending_form_id=None, + pending_tool_call_id=None, + ) -> None: + self.saved.append( + (scope, backend_run_id, snapshot, list(runtime_layer_specs), pending_form_id, pending_tool_call_id) + ) def _soul() -> AgentSoulConfig: @@ -125,6 +160,16 @@ def _run(runner: AgentAppRunner, qm: _FakeQueueManager) -> None: ) +def _message_end(qm: _FakeQueueManager) -> QueueMessageEndEvent: + return next(e for e in qm.events if isinstance(e, QueueMessageEndEvent)) + + +def _saved_user_query(qm: _FakeQueueManager) -> str: + prompt_messages = _message_end(qm).llm_result.prompt_messages + assert len(prompt_messages) == 1 + return prompt_messages[0].content + + def test_successful_turn_publishes_chunk_and_message_end_and_saves_session(): client = FakeAgentBackendRunClient() # SUCCESS: output {"text": "hello agent"} store = _FakeSessionStore() @@ -140,13 +185,23 @@ def test_successful_turn_publishes_chunk_and_message_end_and_saves_session(): assert chunk_events[0].chunk.delta.message.content == "hello agent" assert end_events[0].llm_result.message.content == "hello agent" assert end_events[0].llm_result.model == "gpt-4o-mini" + assert _saved_user_query(qm) == "hello" # The conversation session snapshot is persisted for multi-turn continuity. assert store.saved - saved_scope, saved_run_id, saved_snapshot = store.saved[0] + saved_scope, saved_run_id, saved_snapshot, saved_specs, pending_form_id, pending_tool_call_id = store.saved[0] assert saved_scope.conversation_id == "conv-1" assert saved_scope.agent_config_snapshot_id == "snap-1" assert saved_run_id == "fake-run-1" assert saved_snapshot is not None + # A successful turn carries no ask_human pause correlation. + assert pending_form_id is None + assert pending_tool_call_id is None + assert [spec.name for spec in saved_specs] == [ + "agent_soul_prompt", + "agent_app_user_prompt", + "execution_context", + "history", + ] def test_prior_session_snapshot_is_threaded_into_request(): @@ -189,3 +244,69 @@ def test_extract_answer_handles_plain_string_and_dict(): assert AgentAppRunner._extract_answer("plain text") == "plain text" assert AgentAppRunner._extract_answer({"text": "hi"}) == "hi" assert AgentAppRunner._extract_answer({"a": 1}) == '{"a": 1}' + + +def test_ask_human_pauses_turn_creates_form_and_persists_correlation(): + # ENG-635/637: the PAUSED scenario emits a dify.ask_human deferred call, so + # the chat turn ends by creating a conversation-owned HITL form + saving the + # pause correlation, instead of crashing. Stub the form repo (DB-free). + client = FakeAgentBackendRunClient(scenario=FakeAgentBackendScenario.PAUSED) + store = _FakeSessionStore() + qm = _FakeQueueManager() + runner = _runner(client, store) + + fake_repo = MagicMock() + fake_repo.create_form.return_value = MagicMock(id="form-1") + runner._build_form_repository = lambda dify_context: fake_repo # type: ignore[assignment] + + _run(runner, qm) + + # The conversation-owned form was created and the agent's question surfaced. + fake_repo.create_form.assert_called_once() + created_params = fake_repo.create_form.call_args.args[0] + assert created_params.conversation_id == "conv-1" + assert created_params.workflow_execution_id is None + assert [e for e in qm.events if isinstance(e, QueueMessageEndEvent)] + assert _saved_user_query(qm) == "hello" + # The pause correlation is persisted so a form submission can resume the run. + assert store.saved + assert store.saved[0][4] == "form-1" + assert store.saved[0][5] == "fake-ask-human-1" + + +def test_submitted_form_resumes_turn_with_deferred_tool_results(monkeypatch): + # ENG-638: a turn that runs while a pending form is answered threads the + # human's reply into the request as deferred_tool_results. + snapshot = CompositorSessionSnapshot(layers=[]) + stored = StoredAgentAppSession( + scope=AgentAppSessionScope( + tenant_id="tenant-1", + app_id="app-1", + conversation_id="conv-1", + agent_id="agent-1", + agent_config_snapshot_id="snap-1", + ), + session_snapshot=snapshot, + backend_run_id="run-0", + pending_form_id="form-1", + pending_tool_call_id="call-1", + ) + store = _FakeSessionStore(loaded_session=stored) + submitted = AskHumanResumeOutcome(deferred_result=AskHumanToolResult(status="submitted", values={"ok": True})) + monkeypatch.setattr( + "core.app.apps.agent_app.app_runner.resolve_ask_human_form", + lambda **_kwargs: submitted, + ) + + client = FakeAgentBackendRunClient() # SUCCESS -> the resumed run completes + qm = _FakeQueueManager() + _run(_runner(client, store), qm) + + assert client.request is not None + assert client.request.deferred_tool_results is not None + assert set(client.request.deferred_tool_results.calls) == {"call-1"} + # ENG-638: the resume composition must keep the user-prompt layer so it + # matches the suspended snapshot's layer names (the agent backend rejects a + # mismatch). A resume therefore re-sends a non-blank query, never blank. + layer_names = [layer.name for layer in client.request.composition.layers] + assert "agent_app_user_prompt" in layer_names diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_input_guards.py b/api/tests/unit_tests/core/app/apps/agent_app/test_input_guards.py index e1c8d51b0d9..31bc4a10806 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_input_guards.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_input_guards.py @@ -65,6 +65,13 @@ def _answer_text(events: list[Any]) -> str: return end.llm_result.message.content +def _saved_user_query(events: list[Any]) -> str: + end = next(e for e in events if isinstance(e, QueueMessageEndEvent)) + prompt_messages = end.llm_result.prompt_messages + assert len(prompt_messages) == 1 + return prompt_messages[0].content + + class TestRunInputGuards: def test_no_guards_passes_through(self, monkeypatch): _patch_moderation(monkeypatch, returns=(False, {}, "hello")) @@ -113,6 +120,7 @@ class TestRunInputGuards: assert handled is True assert any(isinstance(e, QueueLLMChunkEvent) for e in qm.events) assert _answer_text(qm.events) == "blocked preset answer" + assert _saved_user_query(qm.events) == "forbidden" def test_annotation_hit_short_circuits(self, monkeypatch): _patch_moderation(monkeypatch, returns=(False, {}, "what is your name")) @@ -131,3 +139,4 @@ class TestRunInputGuards: assert len(annotation_events) == 1 assert annotation_events[0].message_annotation_id == "anno-1" assert _answer_text(qm.events) == "I am the annotated Iris." + assert _saved_user_query(qm.events) == "what is your name" diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py b/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py index a7287d3f2ba..83f9b697b75 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py @@ -186,3 +186,52 @@ class TestAgentAppRuntimeRequestBuilder: "dify_tool_names": [], "cli_tool_count": 1, } + + +# ── ENG-623: drive declaration on the Agent App surface ────────────────────── + + +def _soul_with_model_and_skill() -> AgentSoulConfig: + from models.agent_config_entities import AgentSkillRefConfig + + soul = _soul_with_model() + soul.skills_files.skills = [ + AgentSkillRefConfig.model_validate( + { + "id": "abc", + "name": "Tender Analyzer", + "description": "Parses RFPs.", + "skill_md_key": "tender-analyzer/SKILL.md", + } + ) + ] + return soul + + +class TestAgentAppDriveLayer: + def test_drive_layer_injected_when_flag_enabled(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr( + "core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True + ) + builder = AgentAppRuntimeRequestBuilder( + credentials_provider=_FakeCredentialsProvider(), + plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] + ) + + result = builder.build(_ctx(_soul_with_model_and_skill())) + + drive = next(layer for layer in result.request.composition.layers if layer.name == "drive") + assert drive.type == "dify.drive" + assert drive.config.drive_ref == "agent-agent-1" + assert [skill.skill_md_key for skill in drive.config.skills] == ["tender-analyzer/SKILL.md"] + # injected right after execution_context, mirroring the workflow surface + names = [layer.name for layer in result.request.composition.layers] + assert names.index("drive") == names.index("execution_context") + 1 + + def test_no_drive_layer_when_flag_disabled(self): + builder = AgentAppRuntimeRequestBuilder( + credentials_provider=_FakeCredentialsProvider(), + plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] + ) + result = builder.build(_ctx(_soul_with_model_and_skill())) + assert all(layer.name != "drive" for layer in result.request.composition.layers) diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_session_store.py b/api/tests/unit_tests/core/app/apps/agent_app/test_session_store.py index 03247087ec5..65a7e0bc8ec 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_session_store.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_session_store.py @@ -13,6 +13,7 @@ import pytest from agenton.compositor import CompositorSessionSnapshot from agenton.compositor.schemas import LayerSessionSnapshot from agenton.layers.base import LifecycleState +from dify_agent.protocol import RuntimeLayerSpec from sqlalchemy import delete from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore, AgentAppSessionScope @@ -44,6 +45,13 @@ def _snapshot(messages: int = 1) -> CompositorSessionSnapshot: ) +def _runtime_layer_specs() -> list[RuntimeLayerSpec]: + return [ + RuntimeLayerSpec(name="execution_context", type="dify.execution_context", config={"tenant_id": "tenant-1"}), + RuntimeLayerSpec(name="history", type="pydantic_ai.history"), + ] + + @pytest.fixture(autouse=True) def _create_table() -> Generator[None, None, None]: engine = session_factory.get_session_maker().kw["bind"] @@ -61,7 +69,12 @@ def test_load_returns_none_when_no_row(): def test_save_creates_conversation_owned_row_and_round_trips(): store = AgentAppRuntimeSessionStore() - store.save_active_snapshot(scope=_scope(), backend_run_id="run-1", snapshot=_snapshot(messages=2)) + store.save_active_snapshot( + scope=_scope(), + backend_run_id="run-1", + snapshot=_snapshot(messages=2), + runtime_layer_specs=_runtime_layer_specs(), + ) loaded = store.load_active_snapshot(_scope()) assert loaded is not None @@ -76,19 +89,36 @@ def test_save_creates_conversation_owned_row_and_round_trips(): assert row.agent_config_snapshot_id == "snap-1" assert row.workflow_run_id is None # conversation owner leaves workflow cols NULL assert row.backend_run_id == "run-1" + assert "execution_context" in row.composition_layer_specs + assert "history" in row.composition_layer_specs def test_save_is_noop_when_snapshot_missing(): store = AgentAppRuntimeSessionStore() - store.save_active_snapshot(scope=_scope(), backend_run_id="run-x", snapshot=None) + store.save_active_snapshot( + scope=_scope(), + backend_run_id="run-x", + snapshot=None, + runtime_layer_specs=_runtime_layer_specs(), + ) with session_factory.create_session() as session: assert session.query(AgentRuntimeSession).count() == 0 def test_second_turn_updates_same_conversation_row(): store = AgentAppRuntimeSessionStore() - store.save_active_snapshot(scope=_scope(), backend_run_id="run-1", snapshot=_snapshot(messages=1)) - store.save_active_snapshot(scope=_scope(), backend_run_id="run-2", snapshot=_snapshot(messages=3)) + store.save_active_snapshot( + scope=_scope(), + backend_run_id="run-1", + snapshot=_snapshot(messages=1), + runtime_layer_specs=_runtime_layer_specs(), + ) + store.save_active_snapshot( + scope=_scope(), + backend_run_id="run-2", + snapshot=_snapshot(messages=3), + runtime_layer_specs=_runtime_layer_specs(), + ) with session_factory.create_session() as session: rows = session.query(AgentRuntimeSession).all() assert len(rows) == 1 @@ -97,11 +127,21 @@ def test_second_turn_updates_same_conversation_row(): def test_mark_cleaned_then_load_returns_none_and_save_resurrects(): store = AgentAppRuntimeSessionStore() - store.save_active_snapshot(scope=_scope(), backend_run_id="run-1", snapshot=_snapshot()) + store.save_active_snapshot( + scope=_scope(), + backend_run_id="run-1", + snapshot=_snapshot(), + runtime_layer_specs=_runtime_layer_specs(), + ) store.mark_cleaned(scope=_scope(), backend_run_id="cleanup-1") assert store.load_active_snapshot(_scope()) is None # Re-entry revives the row. - store.save_active_snapshot(scope=_scope(), backend_run_id="run-2", snapshot=_snapshot(messages=2)) + store.save_active_snapshot( + scope=_scope(), + backend_run_id="run-2", + snapshot=_snapshot(messages=2), + runtime_layer_specs=_runtime_layer_specs(), + ) with session_factory.create_session() as session: row = session.query(AgentRuntimeSession).one() assert row.status == AgentRuntimeSessionStatus.ACTIVE @@ -111,8 +151,18 @@ def test_mark_cleaned_then_load_returns_none_and_save_resurrects(): def test_distinct_conversations_do_not_collide(): store = AgentAppRuntimeSessionStore() - store.save_active_snapshot(scope=_scope(conversation_id="conv-A"), backend_run_id="a", snapshot=_snapshot()) - store.save_active_snapshot(scope=_scope(conversation_id="conv-B"), backend_run_id="b", snapshot=_snapshot()) + store.save_active_snapshot( + scope=_scope(conversation_id="conv-A"), + backend_run_id="a", + snapshot=_snapshot(), + runtime_layer_specs=_runtime_layer_specs(), + ) + store.save_active_snapshot( + scope=_scope(conversation_id="conv-B"), + backend_run_id="b", + snapshot=_snapshot(), + runtime_layer_specs=_runtime_layer_specs(), + ) assert store.load_active_snapshot(_scope(conversation_id="conv-A")) is not None assert store.load_active_snapshot(_scope(conversation_id="conv-B")) is not None with session_factory.create_session() as session: @@ -125,9 +175,13 @@ def test_distinct_agent_config_snapshots_keep_only_latest_active_session(): scope=_scope(agent_config_snapshot_id="snap-1"), backend_run_id="a", snapshot=_snapshot(), + runtime_layer_specs=_runtime_layer_specs(), ) store.save_active_snapshot( - scope=_scope(agent_config_snapshot_id="snap-2"), backend_run_id="b", snapshot=_snapshot(messages=2) + scope=_scope(agent_config_snapshot_id="snap-2"), + backend_run_id="b", + snapshot=_snapshot(messages=2), + runtime_layer_specs=_runtime_layer_specs(), ) assert store.load_active_snapshot(_scope(agent_config_snapshot_id="snap-1")) is None @@ -139,62 +193,83 @@ def test_distinct_agent_config_snapshots_keep_only_latest_active_session(): assert [row.status for row in rows] == [AgentRuntimeSessionStatus.CLEANED, AgentRuntimeSessionStatus.ACTIVE] -def test_load_for_conversation_resolves_without_agent_or_config_scope(): +def test_load_active_session_for_conversation_resolves_without_agent_or_config_scope(): store = AgentAppRuntimeSessionStore() - store.save_active_snapshot(scope=_scope(), backend_run_id="run-1", snapshot=_snapshot(messages=2)) + store.save_active_snapshot( + scope=_scope(), + backend_run_id="run-1", + snapshot=_snapshot(messages=2), + runtime_layer_specs=_runtime_layer_specs(), + ) - # The inspector only knows tenant/app/conversation, not the agent config version. - loaded = store.load_active_snapshot_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1") + loaded = store.load_active_session_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1") assert loaded is not None - assert loaded.layers[0].runtime_state["messages"] == [ + assert loaded.session_snapshot.layers[0].runtime_state["messages"] == [ {"role": "user", "content": "m0"}, {"role": "user", "content": "m1"}, ] + assert [spec.name for spec in loaded.runtime_layer_specs] == ["execution_context", "history"] -def test_load_for_conversation_uses_latest_active_snapshot_after_config_change(): +def test_load_active_session_for_conversation_uses_latest_active_snapshot_after_config_change(): store = AgentAppRuntimeSessionStore() store.save_active_snapshot( - scope=_scope(agent_config_snapshot_id="snap-1"), backend_run_id="a", snapshot=_snapshot() + scope=_scope(agent_config_snapshot_id="snap-1"), + backend_run_id="a", + snapshot=_snapshot(), + runtime_layer_specs=_runtime_layer_specs(), ) store.save_active_snapshot( - scope=_scope(agent_config_snapshot_id="snap-2"), backend_run_id="b", snapshot=_snapshot(messages=3) + scope=_scope(agent_config_snapshot_id="snap-2"), + backend_run_id="b", + snapshot=_snapshot(messages=3), + runtime_layer_specs=_runtime_layer_specs(), ) - loaded = store.load_active_snapshot_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1") + loaded = store.load_active_session_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1") assert loaded is not None - assert loaded.layers[0].runtime_state["messages"] == [ + assert loaded.session_snapshot.layers[0].runtime_state["messages"] == [ {"role": "user", "content": "m0"}, {"role": "user", "content": "m1"}, {"role": "user", "content": "m2"}, ] -def test_load_for_conversation_returns_none_when_cleaned_or_absent(): +def test_load_active_session_for_conversation_returns_none_when_cleaned_or_absent(): store = AgentAppRuntimeSessionStore() assert ( - store.load_active_snapshot_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1") + store.load_active_session_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1") is None ) - store.save_active_snapshot(scope=_scope(), backend_run_id="run-1", snapshot=_snapshot()) + store.save_active_snapshot( + scope=_scope(), + backend_run_id="run-1", + snapshot=_snapshot(), + runtime_layer_specs=_runtime_layer_specs(), + ) store.mark_cleaned(scope=_scope(), backend_run_id="cleanup-1") assert ( - store.load_active_snapshot_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1") + store.load_active_session_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1") is None ) -def test_load_for_conversation_isolates_other_conversations(): +def test_load_active_session_for_conversation_isolates_other_conversations(): store = AgentAppRuntimeSessionStore() - store.save_active_snapshot(scope=_scope(conversation_id="conv-A"), backend_run_id="a", snapshot=_snapshot()) + store.save_active_snapshot( + scope=_scope(conversation_id="conv-A"), + backend_run_id="a", + snapshot=_snapshot(), + runtime_layer_specs=_runtime_layer_specs(), + ) assert ( - store.load_active_snapshot_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-B") + store.load_active_session_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-B") is None ) assert ( - store.load_active_snapshot_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-A") + store.load_active_session_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-A") is not None ) diff --git a/api/tests/unit_tests/core/app/layers/test_timeslice_layer.py b/api/tests/unit_tests/core/app/layers/test_timeslice_layer.py index 1ac9a4d8c0c..191c103a8ac 100644 --- a/api/tests/unit_tests/core/app/layers/test_timeslice_layer.py +++ b/api/tests/unit_tests/core/app/layers/test_timeslice_layer.py @@ -1,3 +1,4 @@ +import logging from unittest.mock import Mock, patch from core.app.layers.timeslice_layer import TimeSliceLayer @@ -64,21 +65,19 @@ class TestTimeSliceLayer: scheduler.remove_job.assert_called_once_with("job-1") - def test_checker_job_handles_resource_limit_without_command_channel(self): + def test_checker_job_handles_resource_limit_without_command_channel(self, caplog): scheduler = Mock() scheduler.running = True cfs_plan_scheduler = Mock(plan=Mock()) cfs_plan_scheduler.can_schedule.return_value = SchedulerCommand.RESOURCE_LIMIT_REACHED - with ( - patch("core.app.layers.timeslice_layer.TimeSliceLayer.scheduler", scheduler), - patch("core.app.layers.timeslice_layer.logger") as mock_logger, - ): + with patch("core.app.layers.timeslice_layer.TimeSliceLayer.scheduler", scheduler): layer = TimeSliceLayer(cfs_plan_scheduler=cfs_plan_scheduler) - layer._checker_job("job-1") + with caplog.at_level(logging.ERROR, logger="core.app.layers.timeslice_layer"): + layer._checker_job("job-1") scheduler.remove_job.assert_called_once_with("job-1") - mock_logger.exception.assert_called_once() + assert any(record.levelno == logging.ERROR for record in caplog.records) def test_checker_job_sends_pause_command(self): scheduler = Mock() diff --git a/api/tests/unit_tests/core/app/layers/test_trigger_post_layer.py b/api/tests/unit_tests/core/app/layers/test_trigger_post_layer.py index 320a3bc42cc..88f4a6cc31e 100644 --- a/api/tests/unit_tests/core/app/layers/test_trigger_post_layer.py +++ b/api/tests/unit_tests/core/app/layers/test_trigger_post_layer.py @@ -1,10 +1,15 @@ +import logging from datetime import UTC, datetime, timedelta from types import SimpleNamespace from unittest.mock import Mock, patch from core.app.layers.trigger_post_layer import TriggerPostLayer from core.workflow.system_variables import build_system_variables -from graphon.graph_events import GraphRunFailedEvent, GraphRunSucceededEvent +from graphon.graph_events import ( + GraphRunAbortedEvent, + GraphRunFailedEvent, + GraphRunSucceededEvent, +) from graphon.runtime import VariablePool from models.enums import WorkflowTriggerStatus @@ -59,7 +64,58 @@ class TestTriggerPostLayer: repo.update.assert_called_once_with(trigger_log) session.commit.assert_called_once() - def test_on_event_handles_missing_trigger_log(self): + def test_on_event_updates_trigger_log_for_aborted_event(self): + trigger_log = SimpleNamespace( + status=None, + workflow_run_id=None, + outputs=None, + error=None, + elapsed_time=None, + total_tokens=None, + finished_at=None, + ) + runtime_state = SimpleNamespace( + outputs={"partial": "ok"}, + variable_pool=VariablePool.from_bootstrap( + system_variables=build_system_variables(workflow_execution_id="run-1") + ), + total_tokens=7, + ) + + with ( + patch("core.app.layers.trigger_post_layer.session_factory") as mock_session_factory, + patch("core.app.layers.trigger_post_layer.SQLAlchemyWorkflowTriggerLogRepository") as mock_repo_cls, + patch("core.app.layers.trigger_post_layer.datetime") as mock_datetime, + ): + mock_datetime.now.return_value = datetime(2026, 2, 20, tzinfo=UTC) + + session = Mock() + mock_session_factory.create_session.return_value.__enter__.return_value = session + + repo = Mock() + repo.get_by_id.return_value = trigger_log + mock_repo_cls.return_value = repo + + layer = TriggerPostLayer( + cfs_plan_scheduler_entity=Mock(), + start_time=datetime(2026, 2, 20, tzinfo=UTC) - timedelta(seconds=10), + trigger_log_id="log-1", + ) + layer.initialize(runtime_state, Mock()) + + layer.on_event(GraphRunAbortedEvent(reason="timeout")) + + assert trigger_log.status == WorkflowTriggerStatus.FAILED + assert trigger_log.workflow_run_id == "run-1" + assert trigger_log.outputs is not None + assert trigger_log.error == "timeout" + assert trigger_log.elapsed_time is not None + assert trigger_log.total_tokens == 7 + assert trigger_log.finished_at is not None + repo.update.assert_called_once_with(trigger_log) + session.commit.assert_called_once() + + def test_on_event_handles_missing_trigger_log(self, caplog): runtime_state = SimpleNamespace( outputs={}, variable_pool=VariablePool.from_bootstrap( @@ -71,7 +127,6 @@ class TestTriggerPostLayer: with ( patch("core.app.layers.trigger_post_layer.session_factory") as mock_session_factory, patch("core.app.layers.trigger_post_layer.SQLAlchemyWorkflowTriggerLogRepository") as mock_repo_cls, - patch("core.app.layers.trigger_post_layer.logger") as mock_logger, ): session = Mock() mock_session_factory.create_session.return_value.__enter__.return_value = session @@ -87,9 +142,10 @@ class TestTriggerPostLayer: ) layer.initialize(runtime_state, Mock()) - layer.on_event(GraphRunFailedEvent(error="boom")) + with caplog.at_level(logging.ERROR, logger="core.app.layers.trigger_post_layer"): + layer.on_event(GraphRunFailedEvent(error="boom")) - mock_logger.exception.assert_called_once() + assert any(record.levelno == logging.ERROR for record in caplog.records) session.commit.assert_not_called() def test_on_event_ignores_non_status_events(self): diff --git a/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline_core.py b/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline_core.py index 0f2c79f9fc7..18382f053be 100644 --- a/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline_core.py +++ b/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline_core.py @@ -1,7 +1,9 @@ from __future__ import annotations +from collections.abc import Generator, Sequence from datetime import UTC, datetime -from types import SimpleNamespace +from threading import Thread +from typing import cast from unittest.mock import Mock import pytest @@ -13,8 +15,11 @@ from core.app.app_config.entities import ( ModelConfigEntity, PromptTemplateEntity, ) +from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom from core.app.entities.app_invoke_entities import ChatAppGenerateEntity, CompletionAppGenerateEntity, InvokeFrom from core.app.entities.queue_entities import ( + AppQueueEvent, + MessageQueueMessage, QueueAgentMessageEvent, QueueAgentThoughtEvent, QueueAnnotationReplyEvent, @@ -26,23 +31,33 @@ from core.app.entities.queue_entities import ( QueuePingEvent, QueueRetrieverResourcesEvent, QueueStopEvent, + WorkflowQueueMessage, ) from core.app.entities.task_entities import ( + AgentMessageStreamResponse, + AgentThoughtStreamResponse, ChatbotAppStreamResponse, CompletionAppStreamResponse, ErrorStreamResponse, MessageAudioEndStreamResponse, MessageAudioStreamResponse, MessageEndStreamResponse, + MessageFileStreamResponse, + MessageReplaceStreamResponse, + MessageStreamResponse, PingStreamResponse, + StreamEvent, ) from core.app.task_pipeline.easy_ui_based_generate_task_pipeline import EasyUIBasedGenerateTaskPipeline -from core.base.tts import AudioTrunk +from core.base.tts import AppGeneratorTTSPublisher, AudioTrunk from core.ops.entities.trace_entity import TraceTaskName -from graphon.file import FileTransferMethod +from core.ops.ops_trace_manager import TraceQueueManager +from extensions.storage.storage_type import StorageType +from graphon.file import FileTransferMethod, FileType from graphon.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk, LLMResultChunkDelta, LLMUsage from graphon.model_runtime.entities.message_entities import AssistantPromptMessage, TextPromptMessageContent -from models.model import AppMode +from models.enums import CreatorUserRole +from models.model import AppMode, Conversation, Message, MessageAgentThought, MessageFile, UploadFile class _DummyModelConf: @@ -50,6 +65,150 @@ class _DummyModelConf: self.model = "mock" +class _FakeDb: + engine: object = object() + + +class _UnknownQueueEvent: + pass + + +class _AnnotationReply: + def __init__(self, content: str) -> None: + self.content = content + + +class _ModelConfigMode: + def __init__(self, mode: str) -> None: + self.mode = mode + + +class _ProviderModelBundle: + def __init__(self, model_type_instance: object) -> None: + self.model_type_instance = model_type_instance + + +class _AudioPublisher: + def __init__(self, status: str, audio: str) -> None: + self._audio = AudioTrunk(status, audio) + + def check_and_get_audio(self) -> AudioTrunk: + return self._audio + + +class _TraceManagerDouble: + def __init__(self) -> None: + self.add_trace_task = Mock() + + +class _FakeQueueManager(AppQueueManager): + def __init__(self) -> None: + self._events: list[MessageQueueMessage | WorkflowQueueMessage] = [] + self.published_events: list[AppQueueEvent] = [] + + def set_events(self, events: list[MessageQueueMessage | WorkflowQueueMessage]) -> None: + self._events = events + + def listen(self) -> Generator[MessageQueueMessage | WorkflowQueueMessage]: + yield from self._events + + def publish(self, event: AppQueueEvent, pub_from: PublishFrom) -> None: + self.published_events.append(event) + + def _publish(self, event: AppQueueEvent, pub_from: PublishFrom) -> None: + self.published_events.append(event) + + +def _queue_message(event: AppQueueEvent) -> MessageQueueMessage: + return MessageQueueMessage( + task_id="task", + app_mode=AppMode.CHAT.value, + message_id="msg", + conversation_id="conv", + event=event, + ) + + +def _unknown_queue_message() -> MessageQueueMessage: + return MessageQueueMessage.model_construct( + task_id="task", + app_mode=AppMode.CHAT.value, + message_id="msg", + conversation_id="conv", + event=cast(AppQueueEvent, _UnknownQueueEvent()), + ) + + +def _make_conversation(app_mode: AppMode) -> Conversation: + conversation = Conversation() + conversation.id = "conv" + conversation.mode = app_mode + return conversation + + +def _make_message() -> Message: + message = Message() + message.id = "msg" + message.created_at = datetime.now(UTC) + return message + + +def _message_file( + *, + file_id: str, + transfer_method: FileTransferMethod, + url: str | None, + upload_file_id: str | None, + file_type: FileType = FileType.IMAGE, +) -> MessageFile: + message_file = MessageFile( + message_id="msg", + type=file_type, + transfer_method=transfer_method, + created_by_role=CreatorUserRole.ACCOUNT, + created_by="user", + url=url, + upload_file_id=upload_file_id, + ) + message_file.id = file_id + return message_file + + +def _upload_file(*, file_id: str, name: str, mime_type: str, size: int, extension: str) -> UploadFile: + upload_file = UploadFile( + tenant_id="tenant", + storage_type=StorageType.LOCAL, + key=f"uploads/{file_id}", + name=name, + size=size, + extension=extension, + mime_type=mime_type, + created_by_role=CreatorUserRole.ACCOUNT, + created_by="user", + created_at=datetime.now(UTC), + used=False, + ) + upload_file.id = file_id + return upload_file + + +def _agent_thought() -> MessageAgentThought: + thought = MessageAgentThought( + message_id="msg", + position=1, + created_by_role=CreatorUserRole.ACCOUNT, + created_by="user", + thought="t", + observation="o", + tool="tool", + tool_labels_str="{}", + tool_input="input", + message_files="[]", + ) + thought.id = "thought" + return thought + + def _make_app_config(app_mode: AppMode) -> EasyUIBasedAppConfig: return EasyUIBasedAppConfig( tenant_id="tenant", @@ -89,14 +248,42 @@ def _make_entity(entity_cls, app_mode: AppMode): ) +def _make_pipeline( + entity_cls: type[ChatAppGenerateEntity] | type[CompletionAppGenerateEntity] = ChatAppGenerateEntity, + app_mode: AppMode = AppMode.CHAT, + *, + stream: bool = True, +) -> tuple[EasyUIBasedGenerateTaskPipeline, _FakeQueueManager]: + queue_manager = _FakeQueueManager() + pipeline = EasyUIBasedGenerateTaskPipeline( + application_generate_entity=_make_entity(entity_cls, app_mode), + queue_manager=queue_manager, + conversation=_make_conversation(app_mode), + message=_make_message(), + stream=stream, + ) + return pipeline, queue_manager + + +def _set_queue_events( + pipeline: EasyUIBasedGenerateTaskPipeline, + events: Sequence[MessageQueueMessage | WorkflowQueueMessage], +) -> None: + cast(_FakeQueueManager, pipeline.queue_manager).set_events(list(events)) + + +def _set_method(obj: object, name: str, value: object) -> None: + object.__setattr__(obj, name, value) + + class TestEasyUiBasedGenerateTaskPipeline: def test_to_blocking_response_chat(self): - conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) - message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) + conversation = _make_conversation(AppMode.CHAT) + message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), - queue_manager=SimpleNamespace(), + queue_manager=_FakeQueueManager(), conversation=conversation, message=message, stream=False, @@ -111,12 +298,12 @@ class TestEasyUiBasedGenerateTaskPipeline: assert response.data.answer == "answer" def test_to_blocking_response_completion(self): - conversation = SimpleNamespace(id="conv", mode=AppMode.COMPLETION) - message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) + conversation = _make_conversation(AppMode.COMPLETION) + message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( application_generate_entity=_make_entity(CompletionAppGenerateEntity, AppMode.COMPLETION), - queue_manager=SimpleNamespace(), + queue_manager=_FakeQueueManager(), conversation=conversation, message=message, stream=False, @@ -131,12 +318,12 @@ class TestEasyUiBasedGenerateTaskPipeline: assert response.data.answer == "answer" def test_listen_audio_msg_returns_none_when_no_publisher(self): - conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) - message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) + conversation = _make_conversation(AppMode.CHAT) + message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), - queue_manager=SimpleNamespace(), + queue_manager=_FakeQueueManager(), conversation=conversation, message=message, stream=False, @@ -145,12 +332,12 @@ class TestEasyUiBasedGenerateTaskPipeline: assert pipeline._listen_audio_msg(publisher=None, task_id="task") is None def test_process_stream_response_handles_chunks_and_end(self, monkeypatch: pytest.MonkeyPatch): - conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) - message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) + conversation = _make_conversation(AppMode.CHAT) + message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), - queue_manager=SimpleNamespace(), + queue_manager=_FakeQueueManager(), conversation=conversation, message=message, stream=True, @@ -174,19 +361,24 @@ class TestEasyUiBasedGenerateTaskPipeline: ) events = [ - SimpleNamespace(event=QueueLLMChunkEvent(chunk=chunk)), - SimpleNamespace(event=QueueMessageReplaceEvent(text="replace", reason="output_moderation")), - SimpleNamespace(event=QueuePingEvent()), - SimpleNamespace(event=QueueMessageEndEvent(llm_result=llm_result)), + _queue_message(QueueLLMChunkEvent(chunk=chunk)), + _queue_message(QueueMessageReplaceEvent(text="replace", reason="output_moderation")), + _queue_message(QueuePingEvent()), + _queue_message(QueueMessageEndEvent(llm_result=llm_result)), ] - pipeline.queue_manager.listen = lambda: iter(events) - pipeline._message_cycle_manager.get_message_event_type = lambda message_id: None - pipeline._message_cycle_manager.message_to_stream_response = lambda **kwargs: "chunk" - pipeline._message_cycle_manager.message_replace_to_stream_response = lambda **kwargs: "replace" - pipeline.handle_output_moderation_when_task_finished = lambda completion: None - pipeline._message_end_to_stream_response = lambda: "end" - pipeline._save_message = lambda **kwargs: None + _set_queue_events(pipeline, events) + + def _message_event_type(message_id: str) -> StreamEvent: + return StreamEvent.MESSAGE + + def _message_end() -> MessageEndStreamResponse: + return MessageEndStreamResponse(task_id="task", id="msg") + + _set_method(pipeline._message_cycle_manager, "get_message_event_type", _message_event_type) + _set_method(pipeline, "handle_output_moderation_when_task_finished", lambda completion: None) + _set_method(pipeline, "_message_end_to_stream_response", _message_end) + _set_method(pipeline, "_save_message", lambda **kwargs: None) class _Session: def __init__(self, *args, **kwargs): @@ -207,28 +399,30 @@ class TestEasyUiBasedGenerateTaskPipeline: ) monkeypatch.setattr( "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.db", - SimpleNamespace(engine=object()), + _FakeDb(), ) responses = list(pipeline._process_stream_response(publisher=None)) - assert "chunk" in responses - assert "replace" in responses + message_response = next(item for item in responses if isinstance(item, MessageStreamResponse)) + assert message_response.answer == "hiyo" + replace_response = next(item for item in responses if isinstance(item, MessageReplaceStreamResponse)) + assert replace_response.answer == "replace" assert any(isinstance(item, PingStreamResponse) for item in responses) - assert responses[-1] == "end" + assert isinstance(responses[-1], MessageEndStreamResponse) + assert pipeline._task_state.llm_result.message.content == "done" def test_handle_output_moderation_chunk_directs_output(self): - conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) - message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) + conversation = _make_conversation(AppMode.CHAT) + message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), - queue_manager=SimpleNamespace(), + queue_manager=_FakeQueueManager(), conversation=conversation, message=message, stream=True, ) - events: list[object] = [] class _Moderation: def should_direct_output(self): @@ -237,18 +431,18 @@ class TestEasyUiBasedGenerateTaskPipeline: def get_final_output(self): return "final" - pipeline.output_moderation_handler = _Moderation() - pipeline.queue_manager.publish = lambda event, publish_from: events.append(event) + _set_method(pipeline, "output_moderation_handler", _Moderation()) result = pipeline._handle_output_moderation_chunk("token") assert result is True + events = cast(_FakeQueueManager, pipeline.queue_manager).published_events assert any(isinstance(event, QueueLLMChunkEvent) for event in events) assert any(isinstance(event, QueueStopEvent) for event in events) def test_handle_stop_updates_usage(self, monkeypatch: pytest.MonkeyPatch): - conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) - message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) + conversation = _make_conversation(AppMode.CHAT) + message = _make_message() class _ModelType: def calc_response_usage(self, model, credentials, prompt_tokens, completion_tokens): @@ -263,7 +457,7 @@ class TestEasyUiBasedGenerateTaskPipeline: def __init__(self) -> None: self.model = "mock" self.credentials = {} - self.provider_model_bundle = SimpleNamespace(model_type_instance=_ModelType()) + self.provider_model_bundle = _ProviderModelBundle(model_type_instance=_ModelType()) app_config = _make_app_config(AppMode.CHAT) application_generate_entity = ChatAppGenerateEntity.model_construct( @@ -286,7 +480,7 @@ class TestEasyUiBasedGenerateTaskPipeline: pipeline = EasyUIBasedGenerateTaskPipeline( application_generate_entity=application_generate_entity, - queue_manager=SimpleNamespace(), + queue_manager=_FakeQueueManager(), conversation=conversation, message=message, stream=False, @@ -315,46 +509,41 @@ class TestEasyUiBasedGenerateTaskPipeline: assert pipeline._task_state.llm_result.usage.completion_tokens == 5 def test_record_files_builds_file_payloads(self, monkeypatch: pytest.MonkeyPatch): - conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) - message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) + conversation = _make_conversation(AppMode.CHAT) + message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), - queue_manager=SimpleNamespace(), + queue_manager=_FakeQueueManager(), conversation=conversation, message=message, stream=False, ) message_files = [ - SimpleNamespace( - id="mf-1", - message_id="msg", + _message_file( + file_id="mf-1", transfer_method=FileTransferMethod.REMOTE_URL, url="http://example.com/a.png", upload_file_id=None, - type="image", ), - SimpleNamespace( - id="mf-2", - message_id="msg", + _message_file( + file_id="mf-2", transfer_method=FileTransferMethod.LOCAL_FILE, url="", upload_file_id="upload-1", - type="image", ), - SimpleNamespace( - id="mf-3", - message_id="msg", + _message_file( + file_id="mf-3", transfer_method=FileTransferMethod.TOOL_FILE, url="tool/file.bin", upload_file_id=None, - type="file", + file_type=FileType.CUSTOM, ), ] upload_files = [ - SimpleNamespace( - id="upload-1", + _upload_file( + file_id="upload-1", name="local.png", mime_type="image/png", size=123, @@ -389,7 +578,7 @@ class TestEasyUiBasedGenerateTaskPipeline: ) monkeypatch.setattr( "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.db", - SimpleNamespace(engine=object()), + _FakeDb(), ) monkeypatch.setattr( "core.app.task_pipeline.message_file_utils.file_helpers.get_signed_file_url", @@ -407,12 +596,12 @@ class TestEasyUiBasedGenerateTaskPipeline: assert len(files) == 3 def test_process_stream_response_handles_annotation_and_error(self, monkeypatch: pytest.MonkeyPatch): - conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) - message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) + conversation = _make_conversation(AppMode.CHAT) + message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), - queue_manager=SimpleNamespace(), + queue_manager=_FakeQueueManager(), conversation=conversation, message=message, stream=True, @@ -428,20 +617,36 @@ class TestEasyUiBasedGenerateTaskPipeline: ) events = [ - SimpleNamespace(event=QueueAnnotationReplyEvent(message_annotation_id="ann")), - SimpleNamespace(event=QueueAgentThoughtEvent(agent_thought_id="thought")), - SimpleNamespace(event=QueueMessageFileEvent(message_file_id="file")), - SimpleNamespace(event=QueueAgentMessageEvent(chunk=agent_chunk)), - SimpleNamespace(event=QueueErrorEvent(error=ValueError("boom"))), + _queue_message(QueueAnnotationReplyEvent(message_annotation_id="ann")), + _queue_message(QueueAgentThoughtEvent(agent_thought_id="thought")), + _queue_message(QueueMessageFileEvent(message_file_id="file")), + _queue_message(QueueAgentMessageEvent(chunk=agent_chunk)), + _queue_message(QueueErrorEvent(error=ValueError("boom"))), ] - pipeline.queue_manager.listen = lambda: iter(events) - pipeline._message_cycle_manager.handle_annotation_reply = lambda event: SimpleNamespace(content="annotated") - pipeline._agent_thought_to_stream_response = lambda event: "thought" - pipeline._message_cycle_manager.message_file_to_stream_response = lambda event: "file" - pipeline._agent_message_to_stream_response = lambda **kwargs: "agent" - pipeline.handle_error = lambda **kwargs: ValueError("boom") - pipeline.error_to_stream_response = lambda err: err + _set_queue_events(pipeline, events) + + def _agent_thought_response(event: QueueAgentThoughtEvent) -> AgentThoughtStreamResponse: + return AgentThoughtStreamResponse(task_id="task", id=event.agent_thought_id, position=1) + + def _file_response(event: QueueMessageFileEvent) -> MessageFileStreamResponse: + return MessageFileStreamResponse( + task_id="task", id=event.message_file_id, type="image", belongs_to="user", url="file" + ) + + def _agent_message_response(answer: str, message_id: str) -> AgentMessageStreamResponse: + return AgentMessageStreamResponse(task_id="task", id=message_id, answer=answer) + + _set_method( + pipeline._message_cycle_manager, + "handle_annotation_reply", + lambda event: _AnnotationReply(content="annotated"), + ) + _set_method(pipeline, "_agent_thought_to_stream_response", _agent_thought_response) + _set_method(pipeline._message_cycle_manager, "message_file_to_stream_response", _file_response) + _set_method(pipeline, "_agent_message_to_stream_response", _agent_message_response) + _set_method(pipeline, "handle_error", lambda **kwargs: ValueError("boom")) + _set_method(pipeline, "error_to_stream_response", lambda err: ErrorStreamResponse(task_id="task", err=err)) class _Session: def __init__(self, *args, **kwargs): @@ -462,39 +667,32 @@ class TestEasyUiBasedGenerateTaskPipeline: ) monkeypatch.setattr( "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.db", - SimpleNamespace(engine=object()), + _FakeDb(), ) responses = list(pipeline._process_stream_response(publisher=None)) - assert "thought" in responses - assert "file" in responses - assert "agent" in responses - assert isinstance(responses[-1], ValueError) + assert any(isinstance(response, AgentThoughtStreamResponse) for response in responses) + assert any(isinstance(response, MessageFileStreamResponse) for response in responses) + agent_response = next(response for response in responses if isinstance(response, AgentMessageStreamResponse)) + assert agent_response.answer == "agent" + assert isinstance(responses[-1], ErrorStreamResponse) + assert isinstance(responses[-1].err, ValueError) assert pipeline._task_state.llm_result.message.content == "annotatedagent" def test_agent_thought_to_stream_response_returns_payload(self, monkeypatch: pytest.MonkeyPatch): - conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) - message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) + conversation = _make_conversation(AppMode.CHAT) + message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), - queue_manager=SimpleNamespace(), + queue_manager=_FakeQueueManager(), conversation=conversation, message=message, stream=True, ) - agent_thought = SimpleNamespace( - id="thought", - position=1, - thought="t", - observation="o", - tool="tool", - tool_labels={}, - tool_input="input", - files=[], - ) + agent_thought = _agent_thought() class _Session: def __init__(self, *args, **kwargs): @@ -515,7 +713,7 @@ class TestEasyUiBasedGenerateTaskPipeline: ) monkeypatch.setattr( "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.db", - SimpleNamespace(engine=object()), + _FakeDb(), ) response = pipeline._agent_thought_to_stream_response(QueueAgentThoughtEvent(agent_thought_id="thought")) @@ -524,18 +722,22 @@ class TestEasyUiBasedGenerateTaskPipeline: assert response.id == "thought" def test_process_routes_to_stream_and_starts_conversation_name_generation(self): - conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) - message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) + conversation = _make_conversation(AppMode.CHAT) + message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), - queue_manager=SimpleNamespace(), + queue_manager=_FakeQueueManager(), conversation=conversation, message=message, stream=True, ) pipeline._message_cycle_manager.generate_conversation_name = Mock(return_value=object()) - pipeline._wrapper_process_stream_response = lambda trace_manager: iter(["payload"]) - pipeline._to_stream_response = lambda generator: "streamed" + _set_method( + pipeline, + "_wrapper_process_stream_response", + lambda trace_manager: iter([PingStreamResponse(task_id="task")]), + ) + _set_method(pipeline, "_to_stream_response", lambda generator: "streamed") result = pipeline.process() @@ -545,18 +747,22 @@ class TestEasyUiBasedGenerateTaskPipeline: ) def test_process_routes_to_blocking_for_completion_mode(self): - conversation = SimpleNamespace(id="conv", mode=AppMode.COMPLETION) - message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) + conversation = _make_conversation(AppMode.COMPLETION) + message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( application_generate_entity=_make_entity(CompletionAppGenerateEntity, AppMode.COMPLETION), - queue_manager=SimpleNamespace(), + queue_manager=_FakeQueueManager(), conversation=conversation, message=message, stream=False, ) pipeline._message_cycle_manager.generate_conversation_name = Mock() - pipeline._wrapper_process_stream_response = lambda trace_manager: iter(["payload"]) - pipeline._to_blocking_response = lambda generator: "blocking" + _set_method( + pipeline, + "_wrapper_process_stream_response", + lambda trace_manager: iter([PingStreamResponse(task_id="task")]), + ) + _set_method(pipeline, "_to_blocking_response", lambda generator: "blocking") result = pipeline.process() @@ -564,11 +770,11 @@ class TestEasyUiBasedGenerateTaskPipeline: pipeline._message_cycle_manager.generate_conversation_name.assert_not_called() def test_to_blocking_response_raises_error_stream_exception(self): - conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) - message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) + conversation = _make_conversation(AppMode.CHAT) + message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), - queue_manager=SimpleNamespace(), + queue_manager=_FakeQueueManager(), conversation=conversation, message=message, stream=False, @@ -581,11 +787,11 @@ class TestEasyUiBasedGenerateTaskPipeline: pipeline._to_blocking_response(_gen()) def test_to_blocking_response_raises_when_generator_ends_without_message_end(self): - conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) - message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) + conversation = _make_conversation(AppMode.CHAT) + message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), - queue_manager=SimpleNamespace(), + queue_manager=_FakeQueueManager(), conversation=conversation, message=message, stream=False, @@ -598,11 +804,11 @@ class TestEasyUiBasedGenerateTaskPipeline: pipeline._to_blocking_response(_gen()) def test_to_stream_response_wraps_completion_stream_events(self): - conversation = SimpleNamespace(id="conv", mode=AppMode.COMPLETION) - message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) + conversation = _make_conversation(AppMode.COMPLETION) + message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( application_generate_entity=_make_entity(CompletionAppGenerateEntity, AppMode.COMPLETION), - queue_manager=SimpleNamespace(), + queue_manager=_FakeQueueManager(), conversation=conversation, message=message, stream=True, @@ -617,11 +823,11 @@ class TestEasyUiBasedGenerateTaskPipeline: assert response.message_id == "msg" def test_to_stream_response_wraps_chat_stream_events(self): - conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) - message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) + conversation = _make_conversation(AppMode.CHAT) + message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), - queue_manager=SimpleNamespace(), + queue_manager=_FakeQueueManager(), conversation=conversation, message=message, stream=True, @@ -636,16 +842,19 @@ class TestEasyUiBasedGenerateTaskPipeline: assert response.conversation_id == "conv" def test_listen_audio_msg_returns_audio_response_for_non_finish_audio(self): - conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) - message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) + conversation = _make_conversation(AppMode.CHAT) + message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), - queue_manager=SimpleNamespace(), + queue_manager=_FakeQueueManager(), conversation=conversation, message=message, stream=True, ) - publisher = SimpleNamespace(check_and_get_audio=lambda: AudioTrunk("responding", "abc")) + publisher = cast( + AppGeneratorTTSPublisher, + _AudioPublisher(status="responding", audio="abc"), + ) response = pipeline._listen_audio_msg(publisher=publisher, task_id="task") @@ -653,45 +862,49 @@ class TestEasyUiBasedGenerateTaskPipeline: assert response.audio == "abc" def test_listen_audio_msg_returns_none_for_finish_audio(self): - conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) - message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) + conversation = _make_conversation(AppMode.CHAT) + message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), - queue_manager=SimpleNamespace(), + queue_manager=_FakeQueueManager(), conversation=conversation, message=message, stream=True, ) - publisher = SimpleNamespace(check_and_get_audio=lambda: AudioTrunk("finish", "abc")) + publisher = cast( + AppGeneratorTTSPublisher, + _AudioPublisher(status="finish", audio="abc"), + ) assert pipeline._listen_audio_msg(publisher=publisher, task_id="task") is None def test_wrapper_process_stream_response_without_tts_publisher(self): - conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) - message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) + conversation = _make_conversation(AppMode.CHAT) + message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), - queue_manager=SimpleNamespace(), + queue_manager=_FakeQueueManager(), conversation=conversation, message=message, stream=True, ) - pipeline._process_stream_response = lambda publisher, trace_manager: iter(["payload"]) + payload = PingStreamResponse(task_id="task") + _set_method(pipeline, "_process_stream_response", lambda publisher, trace_manager: iter([payload])) responses = list(pipeline._wrapper_process_stream_response()) - assert responses == ["payload"] + assert responses == [payload] def test_wrapper_process_stream_response_with_tts_publisher(self, monkeypatch: pytest.MonkeyPatch): - conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) - message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) + conversation = _make_conversation(AppMode.CHAT) + message = _make_message() entity = _make_entity(ChatAppGenerateEntity, AppMode.CHAT) entity.app_config.app_model_config_dict = { "text_to_speech": {"autoPlay": "enabled", "enabled": True, "voice": "v", "language": "en"} } pipeline = EasyUIBasedGenerateTaskPipeline( application_generate_entity=entity, - queue_manager=SimpleNamespace(), + queue_manager=_FakeQueueManager(), conversation=conversation, message=message, stream=True, @@ -703,8 +916,9 @@ class TestEasyUiBasedGenerateTaskPipeline: inline_audio = MessageAudioStreamResponse(task_id="task", audio="inline") audio_calls = iter([inline_audio, None]) - pipeline._listen_audio_msg = lambda publisher, task_id: next(audio_calls) - pipeline._process_stream_response = lambda publisher, trace_manager: iter(["payload"]) + payload = PingStreamResponse(task_id="task") + _set_method(pipeline, "_listen_audio_msg", lambda publisher, task_id: next(audio_calls)) + _set_method(pipeline, "_process_stream_response", lambda publisher, trace_manager: iter([payload])) monkeypatch.setattr( "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.AppGeneratorTTSPublisher", lambda tenant_id, voice, language: _Publisher(), @@ -713,19 +927,19 @@ class TestEasyUiBasedGenerateTaskPipeline: responses = list(pipeline._wrapper_process_stream_response()) assert responses[0] == inline_audio - assert responses[1] == "payload" + assert responses[1] == payload assert isinstance(responses[-1], MessageAudioEndStreamResponse) def test_wrapper_process_stream_response_timeout_yields_audio_chunk(self, monkeypatch: pytest.MonkeyPatch): - conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) - message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) + conversation = _make_conversation(AppMode.CHAT) + message = _make_message() entity = _make_entity(ChatAppGenerateEntity, AppMode.CHAT) entity.app_config.app_model_config_dict = { "text_to_speech": {"autoPlay": "enabled", "enabled": True, "voice": "v", "language": "en"} } pipeline = EasyUIBasedGenerateTaskPipeline( application_generate_entity=entity, - queue_manager=SimpleNamespace(), + queue_manager=_FakeQueueManager(), conversation=conversation, message=message, stream=True, @@ -744,7 +958,7 @@ class TestEasyUiBasedGenerateTaskPipeline: clock["value"] += 0.1 return clock["value"] - pipeline._process_stream_response = lambda publisher, trace_manager: iter([]) + _set_method(pipeline, "_process_stream_response", lambda publisher, trace_manager: iter([])) monkeypatch.setattr( "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.AppGeneratorTTSPublisher", lambda tenant_id, voice, language: _Publisher(), @@ -758,24 +972,30 @@ class TestEasyUiBasedGenerateTaskPipeline: assert isinstance(responses[-1], MessageAudioEndStreamResponse) def test_process_stream_response_handles_stop_event_and_output_replacement(self, monkeypatch: pytest.MonkeyPatch): - conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) - message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) + conversation = _make_conversation(AppMode.CHAT) + message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), - queue_manager=SimpleNamespace(), + queue_manager=_FakeQueueManager(), conversation=conversation, message=message, stream=True, ) pipeline._task_state.llm_result.message.content = "raw answer" - pipeline.queue_manager.listen = lambda: iter( - [SimpleNamespace(event=QueueStopEvent(stopped_by=QueueStopEvent.StopBy.USER_MANUAL))] - ) + _set_queue_events(pipeline, [_queue_message(QueueStopEvent(stopped_by=QueueStopEvent.StopBy.USER_MANUAL))]) pipeline._handle_stop = Mock() - pipeline.handle_output_moderation_when_task_finished = lambda answer: "moderated answer" - pipeline._message_cycle_manager.message_replace_to_stream_response = lambda answer: f"replace:{answer}" - pipeline._save_message = lambda **kwargs: None - pipeline._message_end_to_stream_response = lambda: "end" + _set_method(pipeline, "handle_output_moderation_when_task_finished", lambda answer: "moderated answer") + _set_method( + pipeline._message_cycle_manager, + "message_replace_to_stream_response", + lambda answer: MessageReplaceStreamResponse(task_id="task", answer=answer, reason=""), + ) + _set_method(pipeline, "_save_message", lambda **kwargs: None) + _set_method( + pipeline, + "_message_end_to_stream_response", + lambda: MessageEndStreamResponse(task_id="task", id="msg"), + ) class _Session: def __init__(self, *args, **kwargs): @@ -793,20 +1013,22 @@ class TestEasyUiBasedGenerateTaskPipeline: monkeypatch.setattr("core.app.task_pipeline.easy_ui_based_generate_task_pipeline.Session", _Session) monkeypatch.setattr( "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.db", - SimpleNamespace(engine=object()), + _FakeDb(), ) responses = list(pipeline._process_stream_response(publisher=None)) - assert responses == ["replace:moderated answer", "end"] + assert isinstance(responses[0], MessageReplaceStreamResponse) + assert responses[0].answer == "moderated answer" + assert isinstance(responses[1], MessageEndStreamResponse) pipeline._handle_stop.assert_called_once() def test_process_stream_response_handles_retriever_unknown_and_empty_chunk(self): - conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) - message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) + conversation = _make_conversation(AppMode.CHAT) + message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), - queue_manager=SimpleNamespace(), + queue_manager=_FakeQueueManager(), conversation=conversation, message=message, stream=True, @@ -823,12 +1045,13 @@ class TestEasyUiBasedGenerateTaskPipeline: handled["retriever"] += 1 pipeline._message_cycle_manager.handle_retriever_resources = _handle_retriever_resources - pipeline.queue_manager.listen = lambda: iter( + _set_queue_events( + pipeline, [ - SimpleNamespace(event=retriever_event), - SimpleNamespace(event=SimpleNamespace()), - SimpleNamespace(event=QueueLLMChunkEvent(chunk=chunk)), - ] + _queue_message(retriever_event), + _unknown_queue_message(), + _queue_message(QueueLLMChunkEvent(chunk=chunk)), + ], ) responses = list(pipeline._process_stream_response(publisher=None)) @@ -837,11 +1060,11 @@ class TestEasyUiBasedGenerateTaskPipeline: assert handled["retriever"] == 1 def test_process_stream_response_skips_when_output_moderation_directs_chunk(self): - conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) - message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) + conversation = _make_conversation(AppMode.CHAT) + message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), - queue_manager=SimpleNamespace(), + queue_manager=_FakeQueueManager(), conversation=conversation, message=message, stream=True, @@ -852,76 +1075,103 @@ class TestEasyUiBasedGenerateTaskPipeline: delta=LLMResultChunkDelta(index=0, message=AssistantPromptMessage(content="x")), ) pipeline._handle_output_moderation_chunk = lambda text: True - pipeline.queue_manager.listen = lambda: iter([SimpleNamespace(event=QueueLLMChunkEvent(chunk=chunk))]) + _set_queue_events(pipeline, [_queue_message(QueueLLMChunkEvent(chunk=chunk))]) responses = list(pipeline._process_stream_response(publisher=None)) assert responses == [] def test_process_stream_response_ignores_unsupported_chunk_content_types(self): - conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) - message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) + conversation = _make_conversation(AppMode.CHAT) + message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), - queue_manager=SimpleNamespace(), + queue_manager=_FakeQueueManager(), conversation=conversation, message=message, stream=True, ) - chunk = SimpleNamespace( + chunk = LLMResultChunk.model_construct( prompt_messages=[], - delta=SimpleNamespace(message=SimpleNamespace(content=[object(), "ok"])), - ) - pipeline._message_cycle_manager.get_message_event_type = lambda message_id: None - pipeline._message_cycle_manager.message_to_stream_response = lambda **kwargs: kwargs["answer"] - pipeline.queue_manager.listen = lambda: iter( - [SimpleNamespace(event=QueueLLMChunkEvent.model_construct(chunk=chunk))] + delta=LLMResultChunkDelta.model_construct( + message=AssistantPromptMessage.model_construct(content=[object(), "ok"]) + ), ) + _set_method(pipeline._message_cycle_manager, "get_message_event_type", lambda message_id: StreamEvent.MESSAGE) + _set_queue_events(pipeline, [_queue_message(QueueLLMChunkEvent.model_construct(chunk=chunk))]) responses = list(pipeline._process_stream_response(publisher=None)) - assert responses == ["ok"] + assert len(responses) == 1 + assert isinstance(responses[0], MessageStreamResponse) + assert responses[0].answer == "ok" + assert pipeline._task_state.llm_result.message.content == "ok" - def test_process_stream_response_reaches_post_loop_branch_with_thread_reference(self): - conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) - message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) + def test_process_stream_response_skips_none_chunk_content(self): + conversation = _make_conversation(AppMode.CHAT) + message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), - queue_manager=SimpleNamespace(), + queue_manager=_FakeQueueManager(), conversation=conversation, message=message, stream=True, ) - pipeline._conversation_name_generate_thread = object() - pipeline.queue_manager.listen = lambda: iter([]) + chunk = LLMResultChunk( + model="mock", + prompt_messages=[], + delta=LLMResultChunkDelta(index=0, message=AssistantPromptMessage(content=None)), + ) + pipeline._message_cycle_manager.message_to_stream_response = Mock() + _set_queue_events(pipeline, [_queue_message(QueueLLMChunkEvent(chunk=chunk))]) + + responses = list(pipeline._process_stream_response(publisher=None)) + + assert responses == [] + pipeline._message_cycle_manager.message_to_stream_response.assert_not_called() + assert pipeline._task_state.llm_result.message.content == "" + + def test_process_stream_response_reaches_post_loop_branch_with_thread_reference(self): + conversation = _make_conversation(AppMode.CHAT) + message = _make_message() + pipeline = EasyUIBasedGenerateTaskPipeline( + application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), + queue_manager=_FakeQueueManager(), + conversation=conversation, + message=message, + stream=True, + ) + pipeline._conversation_name_generate_thread = Thread() + _set_queue_events(pipeline, []) assert list(pipeline._process_stream_response(publisher=None)) == [] def test_save_message_persists_fields_and_emits_trace(self, monkeypatch: pytest.MonkeyPatch): - conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) - message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) + conversation = _make_conversation(AppMode.CHAT) + message = _make_message() application_generate_entity = _make_entity(ChatAppGenerateEntity, AppMode.CHAT) application_generate_entity.extras = {"trace_session_id": "session-1"} pipeline = EasyUIBasedGenerateTaskPipeline( application_generate_entity=application_generate_entity, - queue_manager=SimpleNamespace(), + queue_manager=_FakeQueueManager(), conversation=conversation, message=message, stream=False, ) pipeline.start_at = 10.0 - pipeline._model_config = SimpleNamespace(mode="chat") + _set_method(pipeline, "_model_config", _ModelConfigMode(mode="chat")) pipeline._task_state.llm_result.prompt_messages = [AssistantPromptMessage(content="prompt")] pipeline._task_state.llm_result.message = AssistantPromptMessage(content=" {{name}} hello ") pipeline._task_state.llm_result.usage = LLMUsage.from_metadata( {"prompt_tokens": 3, "completion_tokens": 5, "total_price": "1.23"} ) - message_obj = SimpleNamespace(id="msg") - conversation_obj = SimpleNamespace(id="conv") + message_obj = _make_message() + conversation_obj = _make_conversation(AppMode.CHAT) session = Mock() session.scalar.side_effect = [message_obj, conversation_obj] - trace_manager = SimpleNamespace(add_trace_task=Mock()) + trace_manager_double = _TraceManagerDouble() + trace_manager = cast(TraceQueueManager, trace_manager_double) sent_payloads: list[tuple[tuple[object, ...], dict[str, object]]] = [] monkeypatch.setattr( @@ -949,8 +1199,8 @@ class TestEasyUiBasedGenerateTaskPipeline: assert message_obj.message == "serialized-prompt" assert message_obj.answer == "hello" assert message_obj.provider_response_latency == 5.0 - trace_manager.add_trace_task.assert_called_once() - trace_task = trace_manager.add_trace_task.call_args.args[0] + trace_manager_double.add_trace_task.assert_called_once() + trace_task = trace_manager_double.add_trace_task.call_args.args[0] assert trace_task.trace_type == TraceTaskName.MESSAGE_TRACE assert trace_task.conversation_id == "conv" assert trace_task.message_id == "msg" @@ -958,11 +1208,11 @@ class TestEasyUiBasedGenerateTaskPipeline: assert len(sent_payloads) == 1 def test_save_message_raises_when_message_not_found(self): - conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) - message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) + conversation = _make_conversation(AppMode.CHAT) + message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), - queue_manager=SimpleNamespace(), + queue_manager=_FakeQueueManager(), conversation=conversation, message=message, stream=False, @@ -974,27 +1224,27 @@ class TestEasyUiBasedGenerateTaskPipeline: pipeline._save_message(session=session) def test_save_message_raises_when_conversation_not_found(self): - conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) - message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) + conversation = _make_conversation(AppMode.CHAT) + message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), - queue_manager=SimpleNamespace(), + queue_manager=_FakeQueueManager(), conversation=conversation, message=message, stream=False, ) session = Mock() - session.scalar.side_effect = [SimpleNamespace(id="msg"), None] + session.scalar.side_effect = [_make_message(), None] with pytest.raises(ValueError, match="Conversation conv not found"): pipeline._save_message(session=session) def test_message_end_to_stream_response_includes_usage_metadata(self, monkeypatch: pytest.MonkeyPatch): - conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) - message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) + conversation = _make_conversation(AppMode.CHAT) + message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), - queue_manager=SimpleNamespace(), + queue_manager=_FakeQueueManager(), conversation=conversation, message=message, stream=False, @@ -1021,20 +1271,21 @@ class TestEasyUiBasedGenerateTaskPipeline: monkeypatch.setattr("core.app.task_pipeline.easy_ui_based_generate_task_pipeline.Session", _Session) monkeypatch.setattr( "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.db", - SimpleNamespace(engine=object()), + _FakeDb(), ) response = pipeline._message_end_to_stream_response() assert response.id == "msg" - assert response.metadata["usage"]["prompt_tokens"] == 1 + usage_metadata = cast(dict[str, object], response.metadata["usage"]) + assert usage_metadata["prompt_tokens"] == 1 def test_record_files_returns_none_when_message_has_no_files(self, monkeypatch: pytest.MonkeyPatch): - conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) - message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) + conversation = _make_conversation(AppMode.CHAT) + message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), - queue_manager=SimpleNamespace(), + queue_manager=_FakeQueueManager(), conversation=conversation, message=message, stream=False, @@ -1060,7 +1311,7 @@ class TestEasyUiBasedGenerateTaskPipeline: monkeypatch.setattr("core.app.task_pipeline.easy_ui_based_generate_task_pipeline.Session", _Session) monkeypatch.setattr( "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.db", - SimpleNamespace(engine=object()), + _FakeDb(), ) response = pipeline._message_end_to_stream_response() @@ -1068,39 +1319,36 @@ class TestEasyUiBasedGenerateTaskPipeline: assert response.files is None def test_record_files_handles_local_fallback_and_tool_url_variants(self, monkeypatch: pytest.MonkeyPatch): - conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) - message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) + conversation = _make_conversation(AppMode.CHAT) + message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), - queue_manager=SimpleNamespace(), + queue_manager=_FakeQueueManager(), conversation=conversation, message=message, stream=False, ) message_files = [ - SimpleNamespace( - id="mf-local-fallback", - message_id="msg", + _message_file( + file_id="mf-local-fallback", transfer_method=FileTransferMethod.LOCAL_FILE, url="", upload_file_id="upload-missing", - type="file", + file_type=FileType.CUSTOM, ), - SimpleNamespace( - id="mf-tool-http", - message_id="msg", + _message_file( + file_id="mf-tool-http", transfer_method=FileTransferMethod.TOOL_FILE, url="http://cdn.example.com/file.txt?x=1", upload_file_id=None, - type="file", + file_type=FileType.CUSTOM, ), - SimpleNamespace( - id="mf-tool-noext", - message_id="msg", + _message_file( + file_id="mf-tool-noext", transfer_method=FileTransferMethod.TOOL_FILE, url="tool/path/toolid", upload_file_id=None, - type="file", + file_type=FileType.CUSTOM, ), ] @@ -1128,7 +1376,7 @@ class TestEasyUiBasedGenerateTaskPipeline: monkeypatch.setattr("core.app.task_pipeline.easy_ui_based_generate_task_pipeline.Session", _Session) monkeypatch.setattr( "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.db", - SimpleNamespace(engine=object()), + _FakeDb(), ) monkeypatch.setattr( "core.app.task_pipeline.message_file_utils.file_helpers.get_signed_file_url", @@ -1148,11 +1396,11 @@ class TestEasyUiBasedGenerateTaskPipeline: assert files[2]["extension"] == ".bin" def test_agent_message_to_stream_response_builds_payload(self): - conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) - message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) + conversation = _make_conversation(AppMode.CHAT) + message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), - queue_manager=SimpleNamespace(), + queue_manager=_FakeQueueManager(), conversation=conversation, message=message, stream=True, @@ -1164,11 +1412,11 @@ class TestEasyUiBasedGenerateTaskPipeline: assert response.answer == "hello" def test_agent_thought_to_stream_response_returns_none_when_not_found(self, monkeypatch: pytest.MonkeyPatch): - conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) - message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) + conversation = _make_conversation(AppMode.CHAT) + message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), - queue_manager=SimpleNamespace(), + queue_manager=_FakeQueueManager(), conversation=conversation, message=message, stream=True, @@ -1190,7 +1438,7 @@ class TestEasyUiBasedGenerateTaskPipeline: monkeypatch.setattr("core.app.task_pipeline.easy_ui_based_generate_task_pipeline.Session", _Session) monkeypatch.setattr( "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.db", - SimpleNamespace(engine=object()), + _FakeDb(), ) response = pipeline._agent_thought_to_stream_response(QueueAgentThoughtEvent(agent_thought_id="missing")) @@ -1198,11 +1446,11 @@ class TestEasyUiBasedGenerateTaskPipeline: assert response is None def test_handle_output_moderation_chunk_appends_token_when_not_directing(self): - conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) - message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) + conversation = _make_conversation(AppMode.CHAT) + message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), - queue_manager=SimpleNamespace(), + queue_manager=_FakeQueueManager(), conversation=conversation, message=message, stream=True, @@ -1216,7 +1464,7 @@ class TestEasyUiBasedGenerateTaskPipeline: def append_new_token(self, text): appended_tokens.append(text) - pipeline.output_moderation_handler = _Moderation() + _set_method(pipeline, "output_moderation_handler", _Moderation()) result = pipeline._handle_output_moderation_chunk("next-token") diff --git a/api/tests/unit_tests/core/app/task_pipeline/test_message_cycle_manager_optimization.py b/api/tests/unit_tests/core/app/task_pipeline/test_message_cycle_manager_optimization.py index 92fe3cbec67..4324fdf8844 100644 --- a/api/tests/unit_tests/core/app/task_pipeline/test_message_cycle_manager_optimization.py +++ b/api/tests/unit_tests/core/app/task_pipeline/test_message_cycle_manager_optimization.py @@ -1,5 +1,6 @@ """Unit tests for the message cycle manager optimization.""" +import logging from types import SimpleNamespace from unittest.mock import Mock, patch @@ -344,7 +345,7 @@ class TestMessageCycleManagerOptimization: db_session.close.assert_called_once() mock_redis.setex.assert_called_once() - def test_generate_conversation_name_worker_falls_back_when_generation_fails(self, message_cycle_manager): + def test_generate_conversation_name_worker_falls_back_when_generation_fails(self, message_cycle_manager, caplog): """Fallback to truncated query when LLM generation fails.""" flask_app = Flask(__name__) conversation = SimpleNamespace( @@ -362,19 +363,19 @@ class TestMessageCycleManagerOptimization: patch("core.app.task_pipeline.message_cycle_manager.redis_client") as mock_redis, patch("core.app.task_pipeline.message_cycle_manager.LLMGenerator") as mock_llm_generator, patch("core.app.task_pipeline.message_cycle_manager.dify_config") as mock_dify_config, - patch("core.app.task_pipeline.message_cycle_manager.logger") as mock_logger, ): mock_db.session = db_session mock_redis.get.return_value = None mock_llm_generator.generate_conversation_name.side_effect = RuntimeError("generation failed") mock_dify_config.DEBUG = True - message_cycle_manager._generate_conversation_name_worker(flask_app, "conv-1", long_query) + with caplog.at_level(logging.ERROR, logger="core.app.task_pipeline.message_cycle_manager"): + message_cycle_manager._generate_conversation_name_worker(flask_app, "conv-1", long_query) assert conversation.name == (long_query[:47] + "...") db_session.commit.assert_called_once() db_session.close.assert_called_once() - mock_logger.exception.assert_called_once() + assert any(record.levelno == logging.ERROR for record in caplog.records) def test_handle_annotation_reply_sets_metadata(self, message_cycle_manager): """Populate task metadata from annotation reply events. diff --git a/api/tests/unit_tests/core/app/test_invoke_from.py b/api/tests/unit_tests/core/app/test_invoke_from.py index e0a8344d2f6..33a4d2edf11 100644 --- a/api/tests/unit_tests/core/app/test_invoke_from.py +++ b/api/tests/unit_tests/core/app/test_invoke_from.py @@ -7,3 +7,19 @@ def test_openapi_variant_present(): def test_openapi_distinct_from_service_api(): assert InvokeFrom.OPENAPI != InvokeFrom.SERVICE_API + + +def test_runs_as_account_only_for_console_contexts(): + # Console contexts (studio debugger / explore) run as the signed-in Account. + assert InvokeFrom.DEBUGGER.runs_as_account() is True + assert InvokeFrom.EXPLORE.runs_as_account() is True + # Everything else is attributed to an end user. + for invoke_from in ( + InvokeFrom.WEB_APP, + InvokeFrom.SERVICE_API, + InvokeFrom.OPENAPI, + InvokeFrom.TRIGGER, + InvokeFrom.PUBLISHED_PIPELINE, + InvokeFrom.VALIDATION, + ): + assert invoke_from.runs_as_account() is False diff --git a/api/tests/unit_tests/core/entities/test_entities_provider_configuration.py b/api/tests/unit_tests/core/entities/test_entities_provider_configuration.py index 1b714d68307..07ba9314977 100644 --- a/api/tests/unit_tests/core/entities/test_entities_provider_configuration.py +++ b/api/tests/unit_tests/core/entities/test_entities_provider_configuration.py @@ -433,10 +433,9 @@ def test_get_model_type_instance_and_schema_delegate_to_factory() -> None: mock_model_type_instance = Mock() mock_schema = _build_ai_model("gpt-4o") mock_factory = Mock() - mock_factory.get_provider_schema.return_value = configuration.provider - mock_factory.get_model_schema.return_value = mock_schema mock_assembly = Mock() mock_assembly.model_runtime = Mock() + mock_assembly.model_runtime.get_model_schema.return_value = mock_schema mock_assembly.model_provider_factory = mock_factory with ( @@ -455,13 +454,12 @@ def test_get_model_type_instance_and_schema_delegate_to_factory() -> None: assert model_type_instance is mock_model_type_instance assert model_schema is mock_schema assert mock_assembly_builder.call_count == 2 - mock_factory.get_provider_schema.assert_called_once_with(provider="openai") mock_model_builder.assert_called_once_with( runtime=mock_assembly.model_runtime, provider_schema=configuration.provider, model_type=ModelType.LLM, ) - mock_factory.get_model_schema.assert_called_once_with( + mock_assembly.model_runtime.get_model_schema.assert_called_once_with( provider="openai", model_type=ModelType.LLM, model="gpt-4o", @@ -472,18 +470,13 @@ def test_get_model_type_instance_and_schema_delegate_to_factory() -> None: def test_get_model_type_instance_and_schema_reuse_bound_runtime_factory() -> None: configuration = _build_provider_configuration() bound_runtime = Mock() + bound_runtime.get_model_schema.return_value = _build_ai_model("gpt-4o") configuration.bind_model_runtime(bound_runtime) mock_model_type_instance = Mock() - mock_schema = _build_ai_model("gpt-4o") - mock_factory = Mock() - mock_factory.get_provider_schema.return_value = configuration.provider - mock_factory.get_model_schema.return_value = mock_schema with ( - patch( - "core.entities.provider_configuration.ModelProviderFactory", return_value=mock_factory - ) as mock_factory_cls, + patch("core.entities.provider_configuration.ModelProviderFactory") as mock_factory_cls, patch("core.entities.provider_configuration.create_plugin_model_assembly") as mock_assembly_builder, patch( "core.entities.provider_configuration.create_model_type_instance", @@ -494,16 +487,20 @@ def test_get_model_type_instance_and_schema_reuse_bound_runtime_factory() -> Non model_schema = configuration.get_model_schema(ModelType.LLM, "gpt-4o", {"api_key": "x"}) assert model_type_instance is mock_model_type_instance - assert model_schema is mock_schema - assert mock_factory_cls.call_count == 2 - mock_factory_cls.assert_called_with(runtime=bound_runtime) + assert model_schema == bound_runtime.get_model_schema.return_value + mock_factory_cls.assert_not_called() mock_assembly_builder.assert_not_called() - mock_factory.get_provider_schema.assert_called_once_with(provider="openai") mock_model_builder.assert_called_once_with( runtime=bound_runtime, provider_schema=configuration.provider, model_type=ModelType.LLM, ) + bound_runtime.get_model_schema.assert_called_once_with( + provider="openai", + model_type=ModelType.LLM, + model="gpt-4o", + credentials={"api_key": "x"}, + ) def test_get_provider_model_returns_none_when_model_not_found() -> None: @@ -544,6 +541,99 @@ def test_get_provider_models_system_deduplicates_sorts_and_filters_active() -> N assert [model.model for model in active_models] == ["b-model"] +def test_get_provider_models_system_filters_requested_model() -> None: + configuration = _build_provider_configuration() + provider_schema = ProviderEntity( + provider="openai", + label=I18nObject(en_US="OpenAI"), + supported_model_types=[ModelType.LLM], + configurate_methods=[ConfigurateMethod.PREDEFINED_MODEL], + models=[_build_ai_model("a-model"), _build_ai_model("target-model"), _build_ai_model("b-model")], + ) + mock_factory = Mock() + mock_factory.get_provider_schema.return_value = provider_schema + + with patch( + "core.entities.provider_configuration.create_plugin_model_assembly", + return_value=SimpleNamespace(model_runtime=Mock(), model_provider_factory=mock_factory), + ): + models = configuration.get_provider_models( + model_type=ModelType.LLM, + only_active=False, + model="target-model", + ) + + assert [model.model for model in models] == ["target-model"] + + +def test_get_provider_models_system_customizable_filters_requested_restricted_model() -> None: + provider = ProviderEntity( + provider="openai", + label=I18nObject(en_US="OpenAI"), + supported_model_types=[ModelType.LLM], + configurate_methods=[ConfigurateMethod.CUSTOMIZABLE_MODEL], + ) + system_configuration = SystemConfiguration( + enabled=True, + credentials={"api_key": "test-key"}, + current_quota_type=ProviderQuotaType.TRIAL, + quota_configurations=[ + QuotaConfiguration( + quota_type=ProviderQuotaType.TRIAL, + quota_unit=QuotaUnit.TOKENS, + quota_limit=1_000, + quota_used=0, + is_valid=True, + restrict_models=[ + RestrictModel(model="target-model", base_model_name="base-model", model_type=ModelType.LLM), + RestrictModel(model="other-model", base_model_name="base-model", model_type=ModelType.LLM), + ], + ) + ], + ) + provider_schema = ProviderEntity( + provider="openai", + label=I18nObject(en_US="OpenAI"), + supported_model_types=[ModelType.LLM], + configurate_methods=[ConfigurateMethod.PREDEFINED_MODEL], + models=[], + ) + mock_factory = Mock() + mock_factory.get_provider_schema.return_value = provider_schema + + with patch("core.entities.provider_configuration.original_provider_configurate_methods", {}): + configuration = ProviderConfiguration( + tenant_id="tenant-1", + provider=provider, + preferred_provider_type=ProviderType.SYSTEM, + using_provider_type=ProviderType.SYSTEM, + system_configuration=system_configuration, + custom_configuration=CustomConfiguration(provider=None, models=[]), + model_settings=[], + ) + + with ( + patch( + "core.entities.provider_configuration.create_plugin_model_assembly", + return_value=SimpleNamespace(model_runtime=Mock(), model_provider_factory=mock_factory), + ), + patch.object( + ProviderConfiguration, + "get_model_schema", + side_effect=lambda *args, **kwargs: _build_ai_model(kwargs["model"]), + ) as mock_get_model_schema, + ): + models = configuration.get_provider_models( + model_type=ModelType.LLM, + only_active=False, + model="target-model", + ) + + assert [model.model for model in models] == ["target-model"] + mock_get_model_schema.assert_called_once() + assert mock_get_model_schema.call_args.kwargs["model"] == "target-model" + + def test_get_custom_provider_models_sets_status_for_removed_credentials_and_invalid_lb_configs() -> None: configuration = _build_provider_configuration() configuration.using_provider_type = ProviderType.CUSTOM @@ -611,6 +701,48 @@ def test_get_custom_provider_models_sets_status_for_removed_credentials_and_inva assert invalid_lb_map["custom-model"] is True +def test_get_custom_provider_models_filters_requested_base_model() -> None: + configuration = _build_provider_configuration() + configuration.using_provider_type = ProviderType.CUSTOM + configuration.custom_configuration.provider = CustomProviderConfiguration(credentials={"api_key": "provider-key"}) + provider_schema = ProviderEntity( + provider="openai", + label=I18nObject(en_US="OpenAI"), + supported_model_types=[ModelType.LLM], + configurate_methods=[ConfigurateMethod.PREDEFINED_MODEL], + models=[_build_ai_model("base-model"), _build_ai_model("target-model")], + ) + + models = configuration._get_custom_provider_models( + model_types=[ModelType.LLM], + provider_schema=provider_schema, + model_setting_map={}, + model="target-model", + ) + + assert [model.model for model in models] == ["target-model"] + + +def test_get_provider_models_reuses_cached_provider_schema() -> None: + configuration = _build_provider_configuration() + provider_schema = ProviderEntity( + provider="openai", + label=I18nObject(en_US="OpenAI"), + supported_model_types=[ModelType.LLM], + configurate_methods=[ConfigurateMethod.PREDEFINED_MODEL], + models=[_build_ai_model("a-model"), _build_ai_model("b-model")], + ) + configuration.provider = provider_schema + + with patch( + "core.entities.provider_configuration.create_plugin_model_assembly", + ) as mock_assembly_builder: + configuration.get_provider_models(model_type=ModelType.LLM, model="a-model") + configuration.get_provider_models(model_type=ModelType.LLM, model="b-model") + + mock_assembly_builder.assert_not_called() + + def test_validator_adds_predefined_model_for_customizable_provider_with_restrictions() -> None: provider = ProviderEntity( provider="openai", @@ -1402,25 +1534,22 @@ def test_system_and_custom_provider_model_helpers_cover_remaining_skip_paths() - return _build_ai_model("embed-model", model_type=ModelType.TEXT_EMBEDDING) return _build_ai_model("target") - with patch( - "core.entities.provider_configuration.original_provider_configurate_methods", - {"openai": [ConfigurateMethod.CUSTOMIZABLE_MODEL]}, - ): - with patch.object(ProviderConfiguration, "get_model_schema", side_effect=_system_schema): - system_models = configuration._get_system_provider_models( - model_types=[ModelType.LLM], - provider_schema=provider_schema, - model_setting_map={ - ModelType.LLM: { - "target": ModelSettings( - model="target", - model_type=ModelType.LLM, - enabled=False, - load_balancing_configs=[], - ) - } - }, - ) + configuration._original_provider_configurate_methods = (ConfigurateMethod.CUSTOMIZABLE_MODEL,) + with patch.object(ProviderConfiguration, "get_model_schema", side_effect=_system_schema): + system_models = configuration._get_system_provider_models( + model_types=[ModelType.LLM], + provider_schema=provider_schema, + model_setting_map={ + ModelType.LLM: { + "target": ModelSettings( + model="target", + model_type=ModelType.LLM, + enabled=False, + load_balancing_configs=[], + ) + } + }, + ) assert any(model.model == "target" and model.status == ModelStatus.DISABLED for model in system_models) configuration.using_provider_type = ProviderType.CUSTOM diff --git a/api/tests/unit_tests/core/mcp/session/test_base_session.py b/api/tests/unit_tests/core/mcp/session/test_base_session.py index 1dd916bcf12..72155515513 100644 --- a/api/tests/unit_tests/core/mcp/session/test_base_session.py +++ b/api/tests/unit_tests/core/mcp/session/test_base_session.py @@ -1,3 +1,4 @@ +import logging import queue import time from concurrent.futures import Future, ThreadPoolExecutor @@ -511,10 +512,8 @@ def test_receive_loop_http_error_unknown_id(streams): @pytest.mark.timeout(10) -def test_receive_loop_validation_error_notification(streams): - from core.mcp.session.base_session import logger - - with patch.object(logger, "warning") as mock_warning: +def test_receive_loop_validation_error_notification(streams, caplog): + with caplog.at_level(logging.WARNING, logger="core.mcp.session.base_session"): read_stream, write_stream = streams session = MockSession(read_stream, write_stream, ReceiveRequest, RootModel[MockNotification]) @@ -523,7 +522,7 @@ def test_receive_loop_validation_error_notification(streams): read_stream.put(SessionMessage(message=JSONRPCMessage.model_validate(notif_payload))) time.sleep(1.0) - assert mock_warning.called + assert "Failed to validate notification" in caplog.text @pytest.mark.timeout(5) @@ -571,16 +570,16 @@ def test_session_exit_timeout(streams): @pytest.mark.timeout(10) -def test_receive_loop_fatal_exception(streams): +def test_receive_loop_fatal_exception(streams, caplog): read_stream, write_stream = streams session = MockSession(read_stream, write_stream, ReceiveRequest, ReceiveNotification) with patch.object(read_stream, "get", side_effect=RuntimeError("Fatal loop error")): - with patch("core.mcp.session.base_session.logger") as mock_logger: + with caplog.at_level(logging.ERROR, logger="core.mcp.session.base_session"): with pytest.raises(RuntimeError, match="Fatal loop error"): with session: pass - mock_logger.exception.assert_called_with("Error in message processing loop") + assert "Error in message processing loop" in caplog.text @pytest.mark.timeout(5) diff --git a/api/tests/unit_tests/core/plugin/test_model_runtime_adapter.py b/api/tests/unit_tests/core/plugin/test_model_runtime_adapter.py index f9abc7d02a1..3fd885b28fb 100644 --- a/api/tests/unit_tests/core/plugin/test_model_runtime_adapter.py +++ b/api/tests/unit_tests/core/plugin/test_model_runtime_adapter.py @@ -28,6 +28,9 @@ class _FakeRedis: def get(self, key: str) -> str | None: return self._values.get(key) + def mget(self, keys: list[str]) -> list[str | None]: + return [self.get(key) for key in keys] + def setex(self, key: str, ttl: int, value: str) -> None: self._values[key] = value self.setex_calls.append((key, ttl, value)) @@ -36,6 +39,13 @@ class _FakeRedis: self._values.pop(key, None) +@pytest.fixture(autouse=True) +def clear_plugin_model_provider_memory_cache() -> None: + PluginService._plugin_model_providers_memory_cache.clear() + yield + PluginService._plugin_model_providers_memory_cache.clear() + + def _build_model_schema() -> AIModelEntity: return AIModelEntity( model="gpt-4o-mini", @@ -329,6 +339,7 @@ class TestPluginModelRuntime: "redis_client", SimpleNamespace( get=Mock(return_value=None), + mget=Mock(return_value=[None, None]), delete=Mock(), setex=Mock(), ), diff --git a/api/tests/unit_tests/core/plugin/test_plugin_manager.py b/api/tests/unit_tests/core/plugin/test_plugin_manager.py index 510aedd5516..1c5e1b9c0ca 100644 --- a/api/tests/unit_tests/core/plugin/test_plugin_manager.py +++ b/api/tests/unit_tests/core/plugin/test_plugin_manager.py @@ -33,6 +33,7 @@ from core.plugin.entities.plugin_daemon import ( PluginInstallTaskStartResponse, PluginInstallTaskStatus, PluginListResponse, + PluginListWithoutTotalResponse, PluginReadmeResponse, PluginVerification, ) @@ -123,6 +124,26 @@ class TestPluginDiscovery: assert call_args[1]["params"]["page_size"] == 5 assert result.total == 10 + def test_list_plugins_by_category(self, plugin_installer, mock_plugin_entity): + """Test category plugin listing without total.""" + mock_response = PluginListWithoutTotalResponse(list=[mock_plugin_entity], has_more=True) + + with patch.object( + plugin_installer, "_request_with_plugin_daemon_response", return_value=mock_response + ) as mock_request: + result = plugin_installer.list_plugins_by_category( + "test-tenant", category=PluginCategory.Tool, page=2, page_size=10 + ) + + mock_request.assert_called_once() + call_args = mock_request.call_args + assert call_args.args[1] == "plugin/test-tenant/management/tool/list" + assert call_args.args[2] is PluginListWithoutTotalResponse + assert call_args.kwargs["params"]["page"] == 2 + assert call_args.kwargs["params"]["page_size"] == 10 + assert result.list == [mock_plugin_entity] + assert result.has_more is True + def test_list_plugins_empty_result(self, plugin_installer): """Test plugin listing when no plugins are installed.""" # Arrange: Mock empty response diff --git a/api/tests/unit_tests/core/rag/embedding/test_cached_embedding.py b/api/tests/unit_tests/core/rag/embedding/test_cached_embedding.py index 051a1455aef..364f688c8e4 100644 --- a/api/tests/unit_tests/core/rag/embedding/test_cached_embedding.py +++ b/api/tests/unit_tests/core/rag/embedding/test_cached_embedding.py @@ -7,6 +7,7 @@ This test file covers the methods not fully tested in test_embedding_service.py: """ import base64 +import logging from decimal import Decimal from unittest.mock import Mock, patch @@ -188,7 +189,7 @@ class TestCacheEmbeddingMultimodalDocuments: assert len(result) == 3 assert result[0] == normalized_cached - def test_embed_multimodal_documents_nan_handling(self, mock_model_instance): + def test_embed_multimodal_documents_nan_handling(self, mock_model_instance, caplog): """Test handling of NaN values in multimodal embeddings.""" cache_embedding = CacheEmbedding(mock_model_instance) documents = [{"file_id": "valid"}, {"file_id": "nan"}] @@ -216,14 +217,14 @@ class TestCacheEmbeddingMultimodalDocuments: mock_session.scalar.return_value = None mock_model_instance.invoke_multimodal_embedding.return_value = embedding_result - with patch("core.rag.embedding.cached_embedding.logger") as mock_logger: + with caplog.at_level(logging.WARNING, logger="core.rag.embedding.cached_embedding"): result = cache_embedding.embed_multimodal_documents(documents) assert len(result) == 2 assert result[0] is not None assert result[1] is None - mock_logger.warning.assert_called_once() + assert any(record.levelno == logging.WARNING for record in caplog.records) def test_embed_multimodal_documents_large_batch(self, mock_model_instance): """Test embedding large batch of multimodal documents respecting MAX_CHUNKS.""" @@ -463,7 +464,7 @@ class TestCacheEmbeddingQueryErrors: model_instance.credentials = {"api_key": "test-key"} return model_instance - def test_embed_query_api_error_debug_mode(self, mock_model_instance): + def test_embed_query_api_error_debug_mode(self, mock_model_instance, caplog): """Test handling of API errors in debug mode.""" cache_embedding = CacheEmbedding(mock_model_instance) query = "test query" @@ -475,14 +476,14 @@ class TestCacheEmbeddingQueryErrors: with patch("core.rag.embedding.cached_embedding.dify_config") as mock_config: mock_config.DEBUG = True - with patch("core.rag.embedding.cached_embedding.logger") as mock_logger: + with caplog.at_level(logging.ERROR, logger="core.rag.embedding.cached_embedding"): with pytest.raises(RuntimeError) as exc_info: cache_embedding.embed_query(query) assert "API Error" in str(exc_info.value) - mock_logger.exception.assert_called() + assert any(record.levelno == logging.ERROR for record in caplog.records) - def test_embed_query_redis_set_error_debug_mode(self, mock_model_instance): + def test_embed_query_redis_set_error_debug_mode(self, mock_model_instance, caplog): """Test handling of Redis set errors in debug mode.""" cache_embedding = CacheEmbedding(mock_model_instance) query = "test query" @@ -514,11 +515,11 @@ class TestCacheEmbeddingQueryErrors: with patch("core.rag.embedding.cached_embedding.dify_config") as mock_config: mock_config.DEBUG = True - with patch("core.rag.embedding.cached_embedding.logger") as mock_logger: + with caplog.at_level(logging.ERROR, logger="core.rag.embedding.cached_embedding"): with pytest.raises(RuntimeError): cache_embedding.embed_query(query) - mock_logger.exception.assert_called() + assert any(record.levelno == logging.ERROR for record in caplog.records) class TestCacheEmbeddingInitialization: diff --git a/api/tests/unit_tests/core/rag/extractor/test_word_extractor.py b/api/tests/unit_tests/core/rag/extractor/test_word_extractor.py index 40885cfed2e..e85bb2f68e0 100644 --- a/api/tests/unit_tests/core/rag/extractor/test_word_extractor.py +++ b/api/tests/unit_tests/core/rag/extractor/test_word_extractor.py @@ -1,12 +1,14 @@ """Primarily used for testing merged cell scenarios""" import io +import logging import os import tempfile from collections import UserDict +from collections.abc import Generator from pathlib import Path from types import SimpleNamespace -from typing import override +from typing import Protocol, cast, override from unittest.mock import MagicMock import pytest @@ -18,6 +20,14 @@ import core.rag.extractor.word_extractor as we from core.rag.extractor.word_extractor import WordExtractor +class _TextOxmlElement(Protocol): + text: str | None + + +def _set_oxml_text(element: object, text: str) -> None: + cast(_TextOxmlElement, element).text = text + + def _generate_table_with_merged_cells(): doc = Document() @@ -190,8 +200,8 @@ def test_extract_images_from_docx_uses_internal_files_url(): from configs import dify_config # Mock the configuration values - original_files_url = getattr(dify_config, "FILES_URL", None) - original_internal_files_url = getattr(dify_config, "INTERNAL_FILES_URL", None) + original_files_url = dify_config.FILES_URL + original_internal_files_url = dify_config.INTERNAL_FILES_URL try: # Set both URLs - INTERNAL should take precedence @@ -233,7 +243,7 @@ def test_extract_hyperlinks(monkeypatch: pytest.MonkeyPatch): new_run = OxmlElement("w:r") t = OxmlElement("w:t") - t.text = "Dify" + _set_oxml_text(t, "Dify") new_run.append(t) hyperlink.append(new_run) p._p.append(hyperlink) @@ -286,7 +296,7 @@ def test_extract_legacy_hyperlinks(monkeypatch: pytest.MonkeyPatch): run2 = OxmlElement("w:r") instrText = OxmlElement("w:instrText") - instrText.text = ' HYPERLINK "http://example.com" ' + _set_oxml_text(instrText, ' HYPERLINK "http://example.com" ') run2.append(instrText) p._p.append(run2) @@ -298,7 +308,7 @@ def test_extract_legacy_hyperlinks(monkeypatch: pytest.MonkeyPatch): run4 = OxmlElement("w:r") t4 = OxmlElement("w:t") - t4.text = "Example" + _set_oxml_text(t4, "Example") run4.append(t4) p._p.append(run4) @@ -380,20 +390,27 @@ def test_close_is_idempotent(): extractor.temp_file.close.assert_called_once() -async def _async_close() -> None: - return None - - def test_close_closes_awaitable_close_result(): + class FakeAwaitable: + closed: bool = False + + def __await__(self) -> Generator[None, None, None]: + if False: + yield None + return None + + def close(self) -> None: + self.closed = True + extractor = object.__new__(WordExtractor) extractor._closed = False extractor.temp_file = MagicMock() - close_result = _async_close() + close_result = FakeAwaitable() extractor.temp_file.close = MagicMock(return_value=close_result) extractor.close() - assert close_result.cr_frame is None + assert close_result.closed is True extractor.temp_file.close.assert_called_once() @@ -506,7 +523,33 @@ def test_table_to_markdown_and_parse_helpers(monkeypatch: pytest.MonkeyPatch): assert extractor._parse_cell(cell, image_map) == "EXT-IMGINT-IMGplain" -def test_parse_docx_covers_drawing_shapes_hyperlink_error_and_table_branch(monkeypatch: pytest.MonkeyPatch): +def test_parse_docx_reads_real_paragraph_table_order(monkeypatch: pytest.MonkeyPatch): + doc = Document() + doc.add_paragraph("Before table") + table = doc.add_table(rows=2, cols=2) + table.cell(0, 0).text = "Header A" + table.cell(0, 1).text = "Header B" + table.cell(1, 0).text = "Cell A" + table.cell(1, 1).text = "Cell B" + doc.add_paragraph("After table") + + with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as tmp: + doc.save(tmp.name) + tmp_path = tmp.name + + extractor = object.__new__(WordExtractor) + monkeypatch.setattr(extractor, "_extract_images_from_docx", lambda doc: {}) + + try: + assert extractor.parse_docx(tmp_path) == ( + "Before table\n| Header A | Header B |\n| --- | --- |\n| Cell A | Cell B |\nAfter table" + ) + finally: + if os.path.exists(tmp_path): + os.remove(tmp_path) + + +def test_parse_docx_covers_drawing_shapes_hyperlink_error_and_table_branch(monkeypatch: pytest.MonkeyPatch, caplog): extractor = object.__new__(WordExtractor) ext_image_id = "ext-image" @@ -620,8 +663,15 @@ def test_parse_docx_covers_drawing_shapes_hyperlink_error_and_table_branch(monke self.element = element self.text = getattr(element, "text", "") - paragraph_main = SimpleNamespace( - _element=[ + class FakeParagraph: + def __init__(self, children): + self._element = children + + class FakeTable: + rows: list[object] = [] + + paragraph_main = FakeParagraph( + [ FakeChild( qn("w:r"), text="run-text", @@ -646,25 +696,23 @@ def test_parse_docx_covers_drawing_shapes_hyperlink_error_and_table_branch(monke ), ] ) - paragraph_empty = SimpleNamespace(_element=[FakeChild(qn("w:r"), text=" ")]) + paragraph_empty = FakeParagraph([FakeChild(qn("w:r"), text=" ")]) + table = FakeTable() fake_doc = SimpleNamespace( part=SimpleNamespace(rels=rels, related_parts={int_embed_id: internal_part}), - paragraphs=[paragraph_main, paragraph_empty], - tables=[SimpleNamespace(rows=[])], - element=SimpleNamespace( - body=[SimpleNamespace(tag="w:p"), SimpleNamespace(tag="w:p"), SimpleNamespace(tag="w:tbl")] - ), + iter_inner_content=lambda: iter([paragraph_main, paragraph_empty, table]), ) + monkeypatch.setattr(we, "Paragraph", FakeParagraph) + monkeypatch.setattr(we, "Table", FakeTable) monkeypatch.setattr(we, "DocxDocument", lambda _: fake_doc) monkeypatch.setattr(we, "Run", FakeRun) monkeypatch.setattr(extractor, "_extract_images_from_docx", lambda doc: image_map) monkeypatch.setattr(extractor, "_table_to_markdown", lambda table, image_map: "TABLE-MARKDOWN") - logger_exception = MagicMock() - monkeypatch.setattr(we.logger, "exception", logger_exception) - content = extractor.parse_docx("dummy.docx") + with caplog.at_level(logging.ERROR, logger="core.rag.extractor.word_extractor"): + content = extractor.parse_docx("dummy.docx") assert "[EXT]" in content assert "[INT]" in content @@ -672,7 +720,7 @@ def test_parse_docx_covers_drawing_shapes_hyperlink_error_and_table_branch(monke assert "[LinkText](https://example.com)" in content assert "BrokenLink" in content assert "TABLE-MARKDOWN" in content - logger_exception.assert_called_once() + assert any(record.levelno == logging.ERROR for record in caplog.records) def test_parse_cell_paragraph_hyperlink_in_table_cell_http(): @@ -688,7 +736,7 @@ def test_parse_cell_paragraph_hyperlink_in_table_cell_http(): run_elem = OxmlElement("w:r") t = OxmlElement("w:t") - t.text = "Dify" + _set_oxml_text(t, "Dify") run_elem.append(t) hyperlink.append(run_elem) p._p.append(hyperlink) @@ -728,7 +776,7 @@ def test_parse_cell_paragraph_hyperlink_in_table_cell_mailto(): run_elem = OxmlElement("w:r") t = OxmlElement("w:t") - t.text = "john@test.com" + _set_oxml_text(t, "john@test.com") run_elem.append(t) hyperlink.append(run_elem) p._p.append(hyperlink) diff --git a/api/tests/unit_tests/core/rag/extractor/watercrawl/test_watercrawl.py b/api/tests/unit_tests/core/rag/extractor/watercrawl/test_watercrawl.py index 95878fc688b..35e581ccc15 100644 --- a/api/tests/unit_tests/core/rag/extractor/watercrawl/test_watercrawl.py +++ b/api/tests/unit_tests/core/rag/extractor/watercrawl/test_watercrawl.py @@ -168,6 +168,13 @@ class TestWaterCrawlAPIClient: assert client.process_response(_response(200, {"ok": True})) == {"ok": True} assert client.process_response(_response(200, None)) == {} + def test_process_response_accepts_json_content_type_parameters(self): + client = WaterCrawlAPIClient(api_key="k") + + response = _response(200, {"ok": True}, content_type="application/json; charset=utf-8") + + assert client.process_response(response) == {"ok": True} + def test_process_response_octet_stream_returns_bytes(self): client = WaterCrawlAPIClient(api_key="k") assert ( @@ -242,11 +249,18 @@ class TestWaterCrawlAPIClient: client = WaterCrawlAPIClient(api_key="k") response = _response(200, {"markdown": "body"}) - monkeypatch.setattr(client_module.httpx, "get", lambda *args, **kwargs: response) + captured = {} + + def fake_get(*args, **kwargs): + captured.update(kwargs) + return response + + monkeypatch.setattr(client_module.httpx, "get", fake_get) result = client.download_result({"result": "https://example.com/result.json"}) assert result["result"] == {"markdown": "body"} + assert captured["timeout"] is not None response.close.assert_called_once() diff --git a/api/tests/unit_tests/core/rag/indexing/processor/test_paragraph_index_processor.py b/api/tests/unit_tests/core/rag/indexing/processor/test_paragraph_index_processor.py index 4ba4d54fa09..182930b19d1 100644 --- a/api/tests/unit_tests/core/rag/indexing/processor/test_paragraph_index_processor.py +++ b/api/tests/unit_tests/core/rag/indexing/processor/test_paragraph_index_processor.py @@ -234,20 +234,6 @@ class TestParagraphIndexProcessor: mock_keyword_cls.return_value.delete_by_ids.assert_called_once_with(["node-2"]) - def test_retrieve_filters_by_threshold(self, processor: ParagraphIndexProcessor, dataset: Mock) -> None: - accepted = SimpleNamespace(page_content="keep", metadata={"source": "a"}, score=0.9) - rejected = SimpleNamespace(page_content="drop", metadata={"source": "b"}, score=0.1) - - with patch( - "core.rag.index_processor.processor.paragraph_index_processor.RetrievalService.retrieve" - ) as mock_retrieve: - mock_retrieve.return_value = [accepted, rejected] - reranking_model = {"reranking_provider_name": "", "reranking_model_name": ""} - docs = processor.retrieve("semantic_search", "query", dataset, 5, 0.5, reranking_model) - - assert len(docs) == 1 - assert docs[0].metadata["score"] == 0.9 - def test_index_list_chunks_high_quality( self, processor: ParagraphIndexProcessor, dataset: Mock, dataset_document: Mock ) -> None: @@ -542,21 +528,24 @@ class TestParagraphIndexProcessor: session.scalars.return_value = scalars_result with ( - patch("core.rag.index_processor.processor.paragraph_index_processor.db.session", session), patch( "core.rag.index_processor.processor.paragraph_index_processor.build_from_mapping", return_value=SimpleNamespace(id="file-1"), ) as mock_builder, patch("core.rag.index_processor.processor.paragraph_index_processor.logger") as mock_logger, ): - files = ParagraphIndexProcessor._extract_images_from_text("tenant-1", text) + files = ParagraphIndexProcessor._extract_images_from_text("tenant-1", text, session) assert len(files) == 1 assert mock_builder.call_count == 1 mock_logger.warning.assert_not_called() def test_extract_images_from_text_returns_empty_when_no_matches(self) -> None: - assert ParagraphIndexProcessor._extract_images_from_text("tenant-1", "no images here") == [] + scalars_result = Mock() + scalars_result.all.return_value = [] + session = Mock() + session.scalars.return_value = scalars_result + assert ParagraphIndexProcessor._extract_images_from_text("tenant-1", "no images here", session) == [] def test_extract_images_from_text_logs_when_build_fails(self) -> None: text = "![img](/files/11111111-1111-1111-1111-111111111111/image-preview)" @@ -576,14 +565,13 @@ class TestParagraphIndexProcessor: session.scalars.return_value = scalars_result with ( - patch("core.rag.index_processor.processor.paragraph_index_processor.db.session", session), patch( "core.rag.index_processor.processor.paragraph_index_processor.build_from_mapping", side_effect=RuntimeError("build failed"), ), patch("core.rag.index_processor.processor.paragraph_index_processor.logger") as mock_logger, ): - files = ParagraphIndexProcessor._extract_images_from_text("tenant-1", text) + files = ParagraphIndexProcessor._extract_images_from_text("tenant-1", text, session) assert files == [] mock_logger.warning.assert_called_once() @@ -622,10 +610,9 @@ class TestParagraphIndexProcessor: session.execute.return_value = execute_result with ( - patch("core.rag.index_processor.processor.paragraph_index_processor.db.session", session), patch("core.rag.index_processor.processor.paragraph_index_processor.logger") as mock_logger, ): - files = ParagraphIndexProcessor._extract_images_from_segment_attachments("tenant-1", "seg-1") + files = ParagraphIndexProcessor._extract_images_from_segment_attachments("tenant-1", "seg-1", session) assert len(files) == 1 mock_logger.warning.assert_called_once() @@ -636,7 +623,6 @@ class TestParagraphIndexProcessor: session = Mock() session.execute.return_value = execute_result - with patch("core.rag.index_processor.processor.paragraph_index_processor.db.session", session): - empty_files = ParagraphIndexProcessor._extract_images_from_segment_attachments("tenant-1", "seg-1") + empty_files = ParagraphIndexProcessor._extract_images_from_segment_attachments("tenant-1", "seg-1", session) assert empty_files == [] diff --git a/api/tests/unit_tests/core/rag/indexing/processor/test_parent_child_index_processor.py b/api/tests/unit_tests/core/rag/indexing/processor/test_parent_child_index_processor.py index 8ef0e046ef6..7d339a7701f 100644 --- a/api/tests/unit_tests/core/rag/indexing/processor/test_parent_child_index_processor.py +++ b/api/tests/unit_tests/core/rag/indexing/processor/test_parent_child_index_processor.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock, Mock, patch import pytest from core.entities.knowledge_entities import PreviewDetail -from core.rag.entities import ParentMode +from core.rag.entities import ParentMode, Rule, Segmentation from core.rag.index_processor.constant.index_type import IndexTechniqueType from core.rag.index_processor.processor.parent_child_index_processor import ParentChildIndexProcessor from core.rag.models.document import AttachmentDocument, ChildDocument, Document @@ -293,29 +293,14 @@ class TestParentChildIndexProcessor: mock_summary.assert_called_once_with(dataset, None) - def test_retrieve_filters_by_score_threshold(self, processor: ParentChildIndexProcessor, dataset: Mock) -> None: - ok_result = SimpleNamespace(page_content="keep", metadata={"m": 1}, score=0.8) - low_result = SimpleNamespace(page_content="drop", metadata={"m": 2}, score=0.2) - - with patch( - "core.rag.index_processor.processor.parent_child_index_processor.RetrievalService.retrieve" - ) as mock_retrieve: - mock_retrieve.return_value = [ok_result, low_result] - reranking_model = {"reranking_provider_name": "", "reranking_model_name": ""} - docs = processor.retrieve("semantic_search", "query", dataset, 3, 0.5, reranking_model) - - assert len(docs) == 1 - assert docs[0].page_content == "keep" - assert docs[0].metadata["score"] == 0.8 - def test_split_child_nodes_requires_subchunk_segmentation(self, processor: ParentChildIndexProcessor) -> None: - rules = SimpleNamespace(subchunk_segmentation=None) + rules = Rule(subchunk_segmentation=None) with pytest.raises(ValueError, match="No subchunk segmentation found"): processor._split_child_nodes(Document(page_content="parent", metadata={}), rules, "custom", None) def test_split_child_nodes_generates_child_documents(self, processor: ParentChildIndexProcessor) -> None: - rules = SimpleNamespace(subchunk_segmentation=self._segmentation()) + rules = Rule(subchunk_segmentation=Segmentation(max_tokens=200, chunk_overlap=10, separator="\n")) splitter = Mock() splitter.split_documents.return_value = [ Document(page_content=".child-1", metadata={}), diff --git a/api/tests/unit_tests/core/rag/indexing/processor/test_qa_index_processor.py b/api/tests/unit_tests/core/rag/indexing/processor/test_qa_index_processor.py index 1f74ccb4387..30600e64651 100644 --- a/api/tests/unit_tests/core/rag/indexing/processor/test_qa_index_processor.py +++ b/api/tests/unit_tests/core/rag/indexing/processor/test_qa_index_processor.py @@ -258,19 +258,6 @@ class TestQAIndexProcessor: mock_summary.assert_called_once_with(dataset, None) vector.delete.assert_called_once() - def test_retrieve_filters_by_score_threshold(self, processor: QAIndexProcessor, dataset: Mock) -> None: - result_ok = SimpleNamespace(page_content="accepted", metadata={"source": "a"}, score=0.9) - result_low = SimpleNamespace(page_content="rejected", metadata={"source": "b"}, score=0.1) - - with patch("core.rag.index_processor.processor.qa_index_processor.RetrievalService.retrieve") as mock_retrieve: - mock_retrieve.return_value = [result_ok, result_low] - reranking_model = {"reranking_provider_name": "", "reranking_model_name": ""} - docs = processor.retrieve("semantic_search", "query", dataset, 5, 0.5, reranking_model) - - assert len(docs) == 1 - assert docs[0].page_content == "accepted" - assert docs[0].metadata["score"] == 0.9 - def test_index_adds_documents_and_vectors_for_high_quality( self, processor: QAIndexProcessor, dataset: Mock, dataset_document: Mock ) -> None: @@ -331,7 +318,7 @@ class TestQAIndexProcessor: def test_generate_summary_preview_returns_input(self, processor: QAIndexProcessor) -> None: preview_items = [PreviewDetail(content="Q1")] - assert processor.generate_summary_preview("tenant-1", preview_items, {}) is preview_items + assert processor.generate_summary_preview("tenant-1", preview_items, {"enable": False}) is preview_items def test_format_qa_document_ignores_blank_text(self, processor: QAIndexProcessor, fake_flask_app) -> None: all_qa_documents: list[Document] = [] diff --git a/api/tests/unit_tests/core/rag/indexing/test_index_processor_base.py b/api/tests/unit_tests/core/rag/indexing/test_index_processor_base.py index 21118cc688d..fe1109db301 100644 --- a/api/tests/unit_tests/core/rag/indexing/test_index_processor_base.py +++ b/api/tests/unit_tests/core/rag/indexing/test_index_processor_base.py @@ -9,7 +9,6 @@ from core.entities.knowledge_entities import PreviewDetail from core.rag.index_processor.constant.doc_type import DocType from core.rag.index_processor.index_processor_base import BaseIndexProcessor from core.rag.models.document import AttachmentDocument, Document -from core.rag.retrieval.retrieval_methods import RetrievalMethod class _ForwardingBaseIndexProcessor(BaseIndexProcessor): @@ -52,17 +51,6 @@ class _ForwardingBaseIndexProcessor(BaseIndexProcessor): def format_preview(self, chunks): return super().format_preview(chunks) - @override - def retrieve(self, retrieval_method, query, dataset, top_k, score_threshold, reranking_model): - return super().retrieve( - retrieval_method=retrieval_method, - query=query, - dataset=dataset, - top_k=top_k, - score_threshold=score_threshold, - reranking_model=reranking_model, - ) - class TestBaseIndexProcessor: @pytest.fixture @@ -75,7 +63,7 @@ class TestBaseIndexProcessor: with pytest.raises(NotImplementedError): processor.transform([]) with pytest.raises(NotImplementedError): - processor.generate_summary_preview("tenant", [PreviewDetail(content="c")], {}) + processor.generate_summary_preview("tenant", [PreviewDetail(content="c")], {"enable": False}) with pytest.raises(NotImplementedError): processor.load(Mock(), []) with pytest.raises(NotImplementedError): @@ -84,8 +72,6 @@ class TestBaseIndexProcessor: processor.index(Mock(), Mock(), {}) with pytest.raises(NotImplementedError): processor.format_preview([]) - with pytest.raises(NotImplementedError): - processor.retrieve(RetrievalMethod.SEMANTIC_SEARCH, "q", Mock(), 3, 0.5, {}) def test_get_splitter_validates_custom_length(self, processor: _ForwardingBaseIndexProcessor) -> None: with patch( diff --git a/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval.py b/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval.py index 4fbab8e56a1..46cf0f7ac49 100644 --- a/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval.py +++ b/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval.py @@ -1,7 +1,8 @@ import threading +from collections.abc import Generator from contextlib import contextmanager, nullcontext from types import SimpleNamespace -from typing import Any +from typing import Any, cast from unittest.mock import MagicMock, Mock, patch from uuid import uuid4 @@ -86,6 +87,14 @@ def create_mock_document( ) +def _dataset(**values: object) -> Dataset: + return cast(Dataset, SimpleNamespace(**values)) + + +def _metadata_condition() -> AppMetadataFilteringCondition: + return AppMetadataFilteringCondition(logical_operator="and", conditions=[]) + + def create_side_effect_for_search(documents: list[Document]): """ Create a side effect function for mocking search methods. @@ -2101,6 +2110,7 @@ class TestDocumentModel: doc = Document(page_content="Test content", vector=vector) assert doc.vector == vector + assert doc.vector is not None assert len(doc.vector) == 5 def test_document_with_external_provider(self): @@ -2914,14 +2924,14 @@ class TestProcessMetadataFilterFunc: return mock_string_access elif name in ["year", "price", "rating"]: return mock_float_access + elif name == "description": + return mock_null_access else: return mock_string_access mock_metadata_field.__getitem__ = MagicMock(side_effect=getitem_side_effect) mock_metadata_field.as_string.return_value = mock_string_access mock_metadata_field.as_float.return_value = mock_float_access - mock_metadata_field[metadata_name:str].is_ = mock_null_access.is_ - mock_metadata_field[metadata_name:str].isnot = mock_null_access.isnot return mock_metadata_field @@ -3933,11 +3943,19 @@ class TestDatasetRetrievalAdditionalHelpers: usage=None, ), ) - text, returned_usage = retrieval._handle_invoke_result(iter([chunk_1, chunk_2])) + + def _chunks() -> Generator[Any]: + yield chunk_1 + yield chunk_2 + + text, returned_usage = retrieval._handle_invoke_result(_chunks()) assert text == "hello world" assert returned_usage == usage - text_empty, usage_empty = retrieval._handle_invoke_result(iter([])) + def _empty_chunks() -> Generator[Any]: + yield from () + + text_empty, usage_empty = retrieval._handle_invoke_result(_empty_chunks()) assert text_empty == "" assert usage_empty == LLMUsage.empty_usage() @@ -4176,7 +4194,9 @@ class TestDatasetRetrievalAdditionalHelpers: ) assert mapping == {"d1": ["doc-1"]} assert condition is not None - assert condition.conditions[0].value == "Alice" + assert condition.conditions + first_condition = condition.conditions[0] + assert first_condition.value == "Alice" with patch("core.rag.retrieval.dataset_retrieval.db.session.scalars", return_value=scalars_result): with pytest.raises(ValueError, match="Invalid metadata filtering mode"): @@ -4666,7 +4686,7 @@ class TestSingleAndMultipleRetrieveCoverage: return DatasetRetrieval() def test_single_retrieve_external_path(self, retrieval: DatasetRetrieval) -> None: - dataset = SimpleNamespace( + dataset = _dataset( id="ds-1", name="External DS", description=None, @@ -4711,7 +4731,7 @@ class TestSingleAndMultipleRetrieveCoverage: assert retrieval.llm_usage.total_tokens == 2 def test_single_retrieve_dify_path_and_filters(self, retrieval: DatasetRetrieval) -> None: - dataset = SimpleNamespace( + dataset = _dataset( id="ds-1", name="Internal DS", description="dataset desc", @@ -4755,7 +4775,7 @@ class TestSingleAndMultipleRetrieveCoverage: model_config=Mock(), planning_strategy=PlanningStrategy.ROUTER, metadata_filter_document_ids={"ds-1": ["doc-1"]}, - metadata_condition=SimpleNamespace(), + metadata_condition=_metadata_condition(), ) assert results == [result_doc] @@ -4772,7 +4792,7 @@ class TestSingleAndMultipleRetrieveCoverage: user_from="workflow", query="python", available_datasets=[ - SimpleNamespace(id="ds-1", name="DS", description=None), + _dataset(id="ds-1", name="DS", description=None), ], model_instance=Mock(), model_config=Mock(), @@ -4781,7 +4801,7 @@ class TestSingleAndMultipleRetrieveCoverage: assert results == [] def test_single_retrieve_respects_metadata_filter_shortcuts(self, retrieval: DatasetRetrieval) -> None: - dataset = SimpleNamespace( + dataset = _dataset( id="ds-1", name="Internal DS", description="desc", @@ -4806,7 +4826,7 @@ class TestSingleAndMultipleRetrieveCoverage: model_config=Mock(), planning_strategy=PlanningStrategy.REACT_ROUTER, metadata_filter_document_ids=None, - metadata_condition=SimpleNamespace(), + metadata_condition=_metadata_condition(), ) missing_doc_ids = retrieval.single_retrieve( app_id="app-1", @@ -4841,8 +4861,8 @@ class TestSingleAndMultipleRetrieveCoverage: ) mixed = [ - SimpleNamespace(id="d1", indexing_technique="high_quality"), - SimpleNamespace(id="d2", indexing_technique="economy"), + _dataset(id="d1", indexing_technique="high_quality"), + _dataset(id="d2", indexing_technique="economy"), ] with pytest.raises(ValueError, match="different indexing technique"): retrieval.multiple_retrieve( @@ -4859,13 +4879,13 @@ class TestSingleAndMultipleRetrieveCoverage: ) high_quality_mismatch = [ - SimpleNamespace( + _dataset( id="d1", indexing_technique="high_quality", embedding_model="model-a", embedding_model_provider="provider-a", ), - SimpleNamespace( + _dataset( id="d2", indexing_technique="high_quality", embedding_model="model-b", @@ -4888,13 +4908,13 @@ class TestSingleAndMultipleRetrieveCoverage: def test_multiple_retrieve_threads_and_dedup(self, retrieval: DatasetRetrieval) -> None: datasets = [ - SimpleNamespace( + _dataset( id="d1", indexing_technique="high_quality", embedding_model="model-a", embedding_model_provider="provider-a", ), - SimpleNamespace( + _dataset( id="d2", indexing_technique="high_quality", embedding_model="model-a", @@ -4956,7 +4976,7 @@ class TestSingleAndMultipleRetrieveCoverage: def test_multiple_retrieve_propagates_thread_exception(self, retrieval: DatasetRetrieval) -> None: datasets = [ - SimpleNamespace( + _dataset( id="d1", indexing_technique="high_quality", embedding_model="model-a", diff --git a/api/tests/unit_tests/core/rag/splitter/test_text_splitter.py b/api/tests/unit_tests/core/rag/splitter/test_text_splitter.py index 976de10d89d..980139192c3 100644 --- a/api/tests/unit_tests/core/rag/splitter/test_text_splitter.py +++ b/api/tests/unit_tests/core/rag/splitter/test_text_splitter.py @@ -126,6 +126,7 @@ Run with coverage: """ import asyncio +import logging import string import sys import types @@ -644,13 +645,13 @@ class TestTextSplitterBasePaths: with pytest.raises(NotImplementedError): asyncio.run(splitter.atransform_documents([Document(page_content="x", metadata={})])) - def test_merge_splits_logs_warning_for_oversized_total(self): + def test_merge_splits_logs_warning_for_oversized_total(self, caplog): """Cover logger.warning path in _merge_splits.""" splitter = RecursiveCharacterTextSplitter(chunk_size=5, chunk_overlap=1) - with patch("core.rag.splitter.text_splitter.logger.warning") as mock_warning: + with caplog.at_level(logging.WARNING, logger="core.rag.splitter.text_splitter"): merged = splitter._merge_splits(["abcdefghij", "b"], "", [10, 1]) assert merged - mock_warning.assert_called_once() + assert any(record.levelno == logging.WARNING for record in caplog.records) # ============================================================================ diff --git a/api/tests/unit_tests/core/repositories/test_human_input_form_repository_impl.py b/api/tests/unit_tests/core/repositories/test_human_input_form_repository_impl.py index a2e10d924c2..5bd35e6d3c2 100644 --- a/api/tests/unit_tests/core/repositories/test_human_input_form_repository_impl.py +++ b/api/tests/unit_tests/core/repositories/test_human_input_form_repository_impl.py @@ -288,6 +288,7 @@ class _DummyForm: form_definition: str rendered_content: str expiration_time: datetime + conversation_id: str | None = None form_kind: HumanInputFormKind = HumanInputFormKind.RUNTIME created_at: datetime = dataclasses.field(default_factory=naive_utc_now) selected_action_id: str | None = None diff --git a/api/tests/unit_tests/core/repositories/test_human_input_repository.py b/api/tests/unit_tests/core/repositories/test_human_input_repository.py index edd8be86184..780687bec53 100644 --- a/api/tests/unit_tests/core/repositories/test_human_input_repository.py +++ b/api/tests/unit_tests/core/repositories/test_human_input_repository.py @@ -73,6 +73,7 @@ class _DummyForm: form_definition: str rendered_content: str expiration_time: datetime + conversation_id: str | None = None form_kind: HumanInputFormKind = HumanInputFormKind.RUNTIME created_at: datetime = dataclasses.field(default_factory=naive_utc_now) selected_action_id: str | None = None diff --git a/api/tests/unit_tests/core/tools/test_builtin_tool_provider.py b/api/tests/unit_tests/core/tools/test_builtin_tool_provider.py index b21a5c3e24a..649545ae686 100644 --- a/api/tests/unit_tests/core/tools/test_builtin_tool_provider.py +++ b/api/tests/unit_tests/core/tools/test_builtin_tool_provider.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Generator -from typing import Any +from typing import Any, override from unittest.mock import patch import pytest @@ -16,6 +16,7 @@ from core.tools.errors import ToolProviderNotFoundError class _FakeBuiltinTool(BuiltinTool): + @override def _invoke( self, user_id: str, @@ -30,6 +31,7 @@ class _FakeBuiltinTool(BuiltinTool): class _ConcreteBuiltinProvider(BuiltinToolProviderController): last_validation: tuple[str, dict[str, Any]] | None = None + @override def _validate_credentials(self, user_id: str, credentials: dict[str, Any]): self.last_validation = (user_id, credentials) diff --git a/api/tests/unit_tests/core/tools/test_mcp_tool.py b/api/tests/unit_tests/core/tools/test_mcp_tool.py index 1504889f01b..2e2b961bf2a 100644 --- a/api/tests/unit_tests/core/tools/test_mcp_tool.py +++ b/api/tests/unit_tests/core/tools/test_mcp_tool.py @@ -177,7 +177,7 @@ def _build_forwarding_tool(*, mode: str = "idp_token") -> MCPTool: def test_inject_forwarded_identity_stamps_custom_header(): - """The minted SSO token must be placed in X-Dify-SSO-Access-Token; the + """The minted SSO token must be placed in X-Dify-SSO-Token; the workspace-scoped Authorization header and any other custom headers must pass through untouched so provider credentials keep working.""" from core.tools.mcp_tool.tool import FORWARDED_IDENTITY_HEADER @@ -219,6 +219,38 @@ def test_inject_forwarded_identity_translates_token_error_to_invoke_error(): assert "Authorization" not in headers +def test_inject_forwarded_identity_sends_end_user_type_for_webapp(): + """A WEB_APP run forwards user_type=end_user so enterprise routes to the + published-webapp token store.""" + tool = _build_forwarding_tool() + tool.runtime = ToolRuntime(tenant_id="tenant-1", invoke_from=InvokeFrom.WEB_APP) + headers: dict[str, str] = {} + + with patch( + "services.enterprise.enterprise_service.EnterpriseService.issue_mcp_token", + return_value=("forwarded.jwt", 1900000000), + ) as issue: + tool._inject_forwarded_identity( + headers, user_id="eu-1", app_id="app-1", audience="https://mcp.example.com/mcp/" + ) + + assert issue.call_args.kwargs["user_type"] == "end_user" + + +def test_inject_forwarded_identity_sends_account_type_for_debugger(): + """A DEBUGGER/console run forwards user_type=account (the existing behaviour).""" + tool = _build_forwarding_tool() # built with InvokeFrom.DEBUGGER + headers: dict[str, str] = {} + + with patch( + "services.enterprise.enterprise_service.EnterpriseService.issue_mcp_token", + return_value=("forwarded.jwt", 1900000000), + ) as issue: + tool._inject_forwarded_identity(headers, user_id="acc-1", app_id=None, audience="https://mcp.example.com/mcp/") + + assert issue.call_args.kwargs["user_type"] == "account" + + def test_invoke_remote_mcp_tool_fails_closed_when_user_id_missing(): """When forwarding is enabled AND the deployment is enterprise, missing user_id must raise — never silently invoke as the static identity.""" diff --git a/api/tests/unit_tests/core/tools/test_tool_label_manager.py b/api/tests/unit_tests/core/tools/test_tool_label_manager.py index e13f430f9b1..ba9f8ba9e1b 100644 --- a/api/tests/unit_tests/core/tools/test_tool_label_manager.py +++ b/api/tests/unit_tests/core/tools/test_tool_label_manager.py @@ -1,7 +1,7 @@ from __future__ import annotations from types import SimpleNamespace -from typing import Any +from typing import Any, override from unittest.mock import MagicMock, PropertyMock, patch import pytest @@ -14,6 +14,7 @@ from core.tools.workflow_as_tool.provider import WorkflowToolProviderController # Create a mock class for testing abstract/base classes class _ConcreteBuiltinToolProviderController(BuiltinToolProviderController): + @override def _validate_credentials(self, user_id: str, credentials: dict[str, Any]): return None diff --git a/api/tests/unit_tests/core/tools/test_tool_provider_controller.py b/api/tests/unit_tests/core/tools/test_tool_provider_controller.py index 30b8494c921..9648305289f 100644 --- a/api/tests/unit_tests/core/tools/test_tool_provider_controller.py +++ b/api/tests/unit_tests/core/tools/test_tool_provider_controller.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Generator -from typing import Any +from typing import Any, override import pytest @@ -22,9 +22,11 @@ from core.tools.errors import ToolProviderCredentialValidationError class _DummyTool(Tool): + @override def tool_provider_type(self) -> ToolProviderType: return ToolProviderType.BUILT_IN + @override def _invoke( self, user_id: str, @@ -36,7 +38,8 @@ class _DummyTool(Tool): yield self.create_text_message("ok") -class _DummyController(ToolProviderController): +class _DummyController(ToolProviderController[ToolProviderEntity, Tool]): + @override def get_tool(self, tool_name: str) -> Tool: entity = ToolEntity( identity=ToolIdentity( diff --git a/api/tests/unit_tests/core/tools/workflow_as_tool/test_provider.py b/api/tests/unit_tests/core/tools/workflow_as_tool/test_provider.py index 5a585c609af..b876fa64b9c 100644 --- a/api/tests/unit_tests/core/tools/workflow_as_tool/test_provider.py +++ b/api/tests/unit_tests/core/tools/workflow_as_tool/test_provider.py @@ -1,19 +1,30 @@ from __future__ import annotations +import json from types import SimpleNamespace +from typing import Any, cast from unittest.mock import MagicMock, Mock, patch import pytest +from core.tools.__base.tool_runtime import ToolRuntime from core.tools.entities.common_entities import I18nObject from core.tools.entities.tool_entities import ( + ToolDescription, + ToolEntity, + ToolIdentity, ToolParameter, ToolProviderEntity, ToolProviderIdentity, ToolProviderType, ) from core.tools.workflow_as_tool.provider import WorkflowToolProviderController +from core.tools.workflow_as_tool.tool import WorkflowTool from graphon.variables.input_entities import VariableEntity, VariableEntityType +from models.account import Account +from models.model import App +from models.tools import WorkflowToolProvider +from models.workflow import Workflow, WorkflowType def _controller() -> WorkflowToolProviderController: @@ -30,6 +41,64 @@ def _controller() -> WorkflowToolProviderController: return WorkflowToolProviderController(entity=entity, provider_id="provider-1") +def _app() -> App: + return App(id="app-1") + + +def _account() -> Account: + return Account(name="Alice", email="alice@example.com") + + +def _workflow() -> Workflow: + return Workflow.new( + tenant_id="tenant-1", + app_id="app-1", + type=WorkflowType.WORKFLOW.value, + version="1", + graph=json.dumps({"nodes": []}), + features="{}", + created_by="user-1", + environment_variables=[], + conversation_variables=[], + rag_pipeline_variables=[], + ) + + +def _db_provider(*, parameter_configuration: str = "[]") -> WorkflowToolProvider: + return WorkflowToolProvider( + name="workflow_tool", + label="WF Provider", + icon="icon.svg", + app_id="app-1", + version="1", + user_id="user-1", + tenant_id="tenant-1", + description="desc", + parameter_configuration=parameter_configuration, + ) + + +def _workflow_tool(name: str = "workflow_tool") -> WorkflowTool: + return WorkflowTool( + workflow_as_tool_id="provider-1", + entity=ToolEntity( + identity=ToolIdentity( + author="author", + name=name, + label=I18nObject(en_US=name), + provider="provider-1", + ), + description=ToolDescription(human=I18nObject(en_US="desc"), llm="desc"), + parameters=[], + ), + runtime=ToolRuntime(tenant_id="tenant-1"), + workflow_app_id="app-1", + workflow_entities={"app": _app(), "workflow": _workflow()}, + version="1", + workflow_call_depth=0, + ) + + def _mock_session_with_begin() -> Mock: session = Mock() begin_cm = Mock() @@ -42,25 +111,18 @@ def _mock_session_with_begin() -> Mock: def test_get_db_provider_tool_builds_entity(): controller = _controller() session = Mock() - workflow = SimpleNamespace(graph_dict={"nodes": []}, features_dict={}) + workflow = _workflow() session.scalar.return_value = workflow - app = SimpleNamespace(id="app-1") - db_provider = SimpleNamespace( - id="provider-1", - app_id="app-1", - version="1", - label="WF Provider", - description="desc", - icon="icon.svg", - name="workflow_tool", - tenant_id="tenant-1", - user_id="user-1", - parameter_configurations=[ - SimpleNamespace(name="country", description="Country", form=ToolParameter.ToolParameterForm.FORM), - SimpleNamespace(name="files", description="files", form=ToolParameter.ToolParameterForm.FORM), - ], + app = _app() + db_provider = _db_provider( + parameter_configuration=json.dumps( + [ + {"name": "country", "description": "Country", "form": ToolParameter.ToolParameterForm.FORM.value}, + {"name": "files", "description": "files", "form": ToolParameter.ToolParameterForm.FORM.value}, + ] + ) ) - user = SimpleNamespace(name="Alice") + user = _account() variables = [ VariableEntity( variable="country", @@ -94,8 +156,9 @@ def test_get_db_provider_tool_builds_entity(): assert tool.entity.identity.name == "workflow_tool" # "json" output is reserved for ToolInvokeMessage.VariableMessage and filtered out. - assert tool.entity.output_schema["properties"] == {"answer": {"type": "string", "description": ""}} - assert "json" not in tool.entity.output_schema["properties"] + properties = cast(dict[str, Any], tool.entity.output_schema["properties"]) + assert properties == {"answer": {"type": "string", "description": ""}} + assert "json" not in properties assert tool.entity.parameters[0].type == ToolParameter.ToolParameterType.SELECT assert tool.entity.parameters[1].type == ToolParameter.ToolParameterType.SYSTEM_FILES assert controller.provider_type == ToolProviderType.WORKFLOW @@ -103,7 +166,7 @@ def test_get_db_provider_tool_builds_entity(): def test_get_tool_returns_hit_or_none(): controller = _controller() - tool = SimpleNamespace(entity=SimpleNamespace(identity=SimpleNamespace(name="workflow_tool"))) + tool = _workflow_tool() controller.tools = [tool] assert controller.get_tool("workflow_tool") is tool @@ -112,29 +175,16 @@ def test_get_tool_returns_hit_or_none(): def test_get_tools_returns_cached(): controller = _controller() - cached_tools = [SimpleNamespace(entity=SimpleNamespace(identity=SimpleNamespace(name="wf-cached")))] - controller.tools = cached_tools # type: ignore[assignment] + cached_tools = [_workflow_tool("wf-cached")] + controller.tools = cached_tools assert controller.get_tools("tenant-1") == cached_tools def test_from_db_builds_controller(): - controller = _controller() - - app = SimpleNamespace(id="app-1") - user = SimpleNamespace(name="Alice") - db_provider = SimpleNamespace( - id="provider-1", - app_id="app-1", - version="1", - user_id="user-1", - label="WF Provider", - description="desc", - icon="icon.svg", - name="workflow_tool", - tenant_id="tenant-1", - parameter_configurations=[], - ) + app = _app() + user = _account() + db_provider = _db_provider() session = _mock_session_with_begin() session.scalar.return_value = db_provider session.get.side_effect = [app, user] @@ -148,7 +198,7 @@ def test_from_db_builds_controller(): with patch.object( WorkflowToolProviderController, "_get_db_provider_tool", - return_value=SimpleNamespace(entity=SimpleNamespace(identity=SimpleNamespace(name="wf"))), + return_value=_workflow_tool("wf"), ): built = WorkflowToolProviderController.from_db(db_provider) assert isinstance(built, WorkflowToolProviderController) @@ -157,7 +207,7 @@ def test_from_db_builds_controller(): def test_get_tools_returns_empty_when_provider_missing(): controller = _controller() - controller.tools = None # type: ignore[assignment] + controller.tools = None with patch("core.tools.workflow_as_tool.provider.db") as mock_db: mock_db.engine = object() @@ -171,19 +221,8 @@ def test_get_tools_returns_empty_when_provider_missing(): def test_get_tools_raises_when_app_missing(): controller = _controller() - controller.tools = None # type: ignore[assignment] - db_provider = SimpleNamespace( - id="provider-1", - app_id="app-1", - version="1", - user_id="user-1", - label="WF Provider", - description="desc", - icon="icon.svg", - name="workflow_tool", - tenant_id="tenant-1", - parameter_configurations=[], - ) + controller.tools = None + db_provider = _db_provider() with patch("core.tools.workflow_as_tool.provider.db") as mock_db: mock_db.engine = object() diff --git a/api/tests/unit_tests/core/workflow/generator/test_prompts.py b/api/tests/unit_tests/core/workflow/generator/test_prompts.py index e1ba146c2aa..4b43f648e4a 100644 --- a/api/tests/unit_tests/core/workflow/generator/test_prompts.py +++ b/api/tests/unit_tests/core/workflow/generator/test_prompts.py @@ -10,6 +10,8 @@ when data is present, and (3) round-trip the raw catalogue text unchanged. from core.workflow.generator.prompts.builder_prompts import ( BUILDER_SYSTEM_PROMPT_ADVANCED_CHAT, BUILDER_SYSTEM_PROMPT_WORKFLOW, + compact_graph_for_builder, + format_builder_existing_graph_section, format_builder_tool_catalogue_section, format_plan_block, get_builder_system_prompt, @@ -182,3 +184,102 @@ class TestFormatPlanBlockParentHints: ] ) assert "parent='Ghost Container'" in out + + +class TestCompactGraphForBuilder: + """ + The refine-mode existing-graph JSON is the single biggest token sink in + the pipeline — and the builder echoes untouched nodes back, doubling the + cost. The compactor must drop canvas noise (recomputed in postprocess) + while keeping everything the builder genuinely has to preserve. + """ + + @staticmethod + def _graph() -> dict: + return { + "nodes": [ + { + "id": "node1", + "type": "custom", + "position": {"x": 80, "y": 282}, + "positionAbsolute": {"x": 80, "y": 282}, + "width": 244, + "height": 100, + "sourcePosition": "right", + "targetPosition": "left", + "selected": True, + "data": {"type": "start", "title": "Start", "variables": []}, + }, + { + "id": "iter1", + "type": "custom", + "position": {"x": 400, "y": 282}, + "width": 808, + "height": 204, + "data": {"type": "iteration", "title": "Per Item", "start_node_id": "iter1start"}, + }, + { + "id": "iter1start", + "type": "custom-iteration-start", + "parentId": "iter1", + "position": {"x": 60, "y": 78}, + "positionAbsolute": {"x": 460, "y": 360}, + "data": {"type": "iteration-start", "title": ""}, + }, + ], + "edges": [ + { + "id": "node1-source-iter1-target", + "source": "node1", + "target": "iter1", + "sourceHandle": "source", + "targetHandle": "target", + "type": "custom", + "zIndex": 0, + "data": {"sourceType": "start", "targetType": "iteration", "isInIteration": False}, + } + ], + "viewport": {"x": 0, "y": 0, "zoom": 0.7}, + } + + def test_drops_canvas_noise_from_top_level_nodes(self): + compact = compact_graph_for_builder(self._graph()) + start = next(n for n in compact["nodes"] if n["id"] == "node1") + for key in ("position", "positionAbsolute", "width", "height", "sourcePosition", "targetPosition", "selected"): + assert key not in start + # Semantics survive. + assert start["data"]["type"] == "start" + assert start["type"] == "custom" + + def test_keeps_container_size_but_not_position(self): + compact = compact_graph_for_builder(self._graph()) + container = next(n for n in compact["nodes"] if n["id"] == "iter1") + assert container["width"] == 808 + assert container["height"] == 204 + assert "position" not in container + + def test_keeps_child_relative_position(self): + compact = compact_graph_for_builder(self._graph()) + child = next(n for n in compact["nodes"] if n["id"] == "iter1start") + assert child["position"] == {"x": 60, "y": 78} + assert child["parentId"] == "iter1" + assert child["type"] == "custom-iteration-start" + assert "positionAbsolute" not in child + + def test_edges_keep_only_topology_fields(self): + compact = compact_graph_for_builder(self._graph()) + assert compact["edges"] == [ + {"source": "node1", "target": "iter1", "sourceHandle": "source", "targetHandle": "target"} + ] + + def test_viewport_is_dropped(self): + assert "viewport" not in compact_graph_for_builder(self._graph()) + + def test_existing_graph_section_embeds_the_compact_graph(self): + section = format_builder_existing_graph_section(self._graph()) + assert "Existing graph to refine" in section + assert "positionAbsolute" not in section + assert '"start_node_id":"iter1start"' in section + + def test_existing_graph_section_empty_for_create_mode(self): + assert format_builder_existing_graph_section(None) == "" diff --git a/api/tests/unit_tests/core/workflow/generator/test_runner.py b/api/tests/unit_tests/core/workflow/generator/test_runner.py index 0117f7d27ce..067fb1cf950 100644 --- a/api/tests/unit_tests/core/workflow/generator/test_runner.py +++ b/api/tests/unit_tests/core/workflow/generator/test_runner.py @@ -8,11 +8,13 @@ readable error envelope. """ import json +from typing import Any, cast from unittest.mock import MagicMock import pytest from core.workflow.generator.runner import WorkflowGenerator +from core.workflow.generator.types import GraphDict def _llm_result(text: str) -> MagicMock: @@ -2495,3 +2497,427 @@ class TestWorkflowGeneratorFileVariables: ] WorkflowGenerator._normalize_start_file_variables(nodes=nodes) assert "allowed_file_types" not in nodes[0]["data"]["variables"][0] + + +class TestWorkflowGeneratorIdSanitization: + """ + Beyond hyphens: the sanitize pass must handle ANY character the run-time + placeholder regex rejects (dots, spaces, unicode) and must stay + collision-safe when stripping makes two ids identical — silently merging + ``node-1`` and ``node1`` would point every reference at one node. + """ + + def test_collision_between_stripped_and_existing_id_gets_a_suffix(self): + nodes: list[dict[str, Any]] = [ + {"id": "node1", "data": {"type": "start", "variables": []}}, + { + "id": "node-1", + "data": { + "type": "llm", + "prompt_template": [{"role": "user", "text": "{{#node-1.text#}} and {{#node1.x#}}"}], + }, + }, + ] + edges = [{"id": "e", "source": "node1", "target": "node-1"}] + + WorkflowGenerator._sanitize_node_ids(nodes=nodes, edges=edges) + + ids = [n["id"] for n in nodes] + assert len(set(ids)) == 2 + assert ids[0] == "node1" + renamed = ids[1] + assert renamed != "node1" + # Edge target follows the rename; references to the untouched sibling + # stay untouched. + assert edges[0]["target"] == renamed + text = nodes[1]["data"]["prompt_template"][0]["text"] + assert f"{{{{#{renamed}.text#}}}}" in text + assert "{{#node1.x#}}" in text + + def test_sanitizes_dots_and_spaces(self): + nodes: list[dict[str, Any]] = [ + {"id": "step.one", "data": {"type": "start", "variables": []}}, + { + "id": "step two", + "data": {"type": "llm", "prompt_template": [{"role": "user", "text": "{{#step two.text#}}"}]}, + }, + ] + edges = [{"id": "e", "source": "step.one", "target": "step two"}] + + WorkflowGenerator._sanitize_node_ids(nodes=nodes, edges=edges) + + assert [n["id"] for n in nodes] == ["stepone", "steptwo"] + assert (edges[0]["source"], edges[0]["target"]) == ("stepone", "steptwo") + assert "{{#steptwo.text#}}" in nodes[1]["data"]["prompt_template"][0]["text"] + + def test_id_with_no_valid_characters_gets_a_fallback(self): + nodes = [ + {"id": "节点", "data": {"type": "start", "variables": []}}, + {"id": "node2", "data": {"type": "end", "outputs": []}}, + ] + edges = [{"id": "e", "source": "节点", "target": "node2"}] + + WorkflowGenerator._sanitize_node_ids(nodes=nodes, edges=edges) + + new_id = nodes[0]["id"] + assert new_id + assert new_id != "节点" + assert edges[0]["source"] == new_id + + +class TestWorkflowGeneratorLayeredLayout: + """ + Top-level layout is computed from topology (longest-path layering), not + array order: branches that run in parallel share a column and stack in + lanes, and a join lands to the right of its deepest input. + """ + + @staticmethod + def _node(node_id: str, node_type: str) -> dict: + return {"id": node_id, "type": "custom", "data": {"type": node_type, "title": node_id}} + + def test_diamond_branches_share_a_column_in_separate_lanes(self): + nodes = [ + self._node("start", "start"), + self._node("branch", "if-else"), + self._node("a", "llm"), + self._node("b", "llm"), + self._node("join", "variable-aggregator"), + ] + edges = [ + {"source": "start", "target": "branch"}, + {"source": "branch", "target": "a"}, + {"source": "branch", "target": "b"}, + {"source": "a", "target": "join"}, + {"source": "b", "target": "join"}, + ] + + WorkflowGenerator._layout_top_level_nodes(nodes=nodes, edges=edges) + + pos = {n["id"]: n["position"] for n in nodes} + assert pos["start"]["x"] < pos["branch"]["x"] < pos["a"]["x"] < pos["join"]["x"] + # The two arms share the column but not the lane. + assert pos["a"]["x"] == pos["b"]["x"] + assert pos["a"]["y"] != pos["b"]["y"] + + def test_out_of_order_node_array_still_flows_left_to_right(self): + # Builder emitted the array end-first; topology must win. + nodes = [ + self._node("end", "end"), + self._node("middle", "llm"), + self._node("start", "start"), + ] + edges = [ + {"source": "start", "target": "middle"}, + {"source": "middle", "target": "end"}, + ] + + WorkflowGenerator._layout_top_level_nodes(nodes=nodes, edges=edges) + + pos = {n["id"]: n["position"] for n in nodes} + assert pos["start"]["x"] < pos["middle"]["x"] < pos["end"]["x"] + + def test_join_lands_right_of_its_deepest_branch(self): + # start → a → b → join, start → join: BFS depth would put join at 1; + # longest-path layering must put it at 3. + nodes = [ + self._node("start", "start"), + self._node("a", "llm"), + self._node("b", "llm"), + self._node("join", "end"), + ] + edges = [ + {"source": "start", "target": "a"}, + {"source": "a", "target": "b"}, + {"source": "b", "target": "join"}, + {"source": "start", "target": "join"}, + ] + + WorkflowGenerator._layout_top_level_nodes(nodes=nodes, edges=edges) + + pos = {n["id"]: n["position"] for n in nodes} + assert pos["join"]["x"] > pos["b"]["x"] > pos["a"]["x"] > pos["start"]["x"] + + def test_container_children_are_not_repositioned(self): + nodes = [ + self._node("start", "start"), + self._node("iter", "iteration"), + { + "id": "inner", + "type": "custom", + "parentId": "iter", + "position": {"x": 60.0, "y": 78.0}, + "data": {"type": "llm", "title": "inner"}, + }, + self._node("end", "end"), + ] + edges = [ + {"source": "start", "target": "iter"}, + {"source": "iter", "target": "end"}, + ] + + WorkflowGenerator._layout_top_level_nodes(nodes=nodes, edges=edges) + + inner = next(n for n in nodes if n["id"] == "inner") + assert inner["position"] == {"x": 60.0, "y": 78.0} + + def test_cycle_members_are_parked_instead_of_hanging(self): + # A cycle must not hang the layout pass; its members get parked one + # layer past the laid-out nodes (validation flags the cycle itself). + nodes = [ + self._node("start", "start"), + self._node("a", "llm"), + self._node("b", "llm"), + ] + edges = [ + {"source": "start", "target": "a"}, + {"source": "a", "target": "b"}, + {"source": "b", "target": "a"}, + ] + + WorkflowGenerator._layout_top_level_nodes(nodes=nodes, edges=edges) + + for node in nodes: + assert "position" in node + + +class TestWorkflowGeneratorBranchHandleRepair: + """ + Edges leaving if-else / question-classifier on the default "source" + handle dangle off a handle that doesn't exist on the canvas. The repair + pass re-homes them onto unused branch handles when (and only when) the + assignment is unambiguous. + """ + + @staticmethod + def _if_else_node() -> dict: + return { + "id": "branch", + "data": { + "type": "if-else", + "cases": [{"case_id": "true", "conditions": []}], + }, + } + + def test_assigns_true_then_false_to_default_handle_edges(self): + nodes = [self._if_else_node()] + edges = [ + {"source": "branch", "target": "a", "sourceHandle": "source"}, + {"source": "branch", "target": "b"}, + ] + + WorkflowGenerator._repair_branch_edge_handles(nodes=nodes, edges=edges) + + assert edges[0]["sourceHandle"] == "true" + assert edges[1]["sourceHandle"] == "false" + + def test_respects_an_already_correct_handle(self): + nodes = [self._if_else_node()] + edges = [ + {"source": "branch", "target": "a", "sourceHandle": "true"}, + {"source": "branch", "target": "b", "sourceHandle": "source"}, + ] + + WorkflowGenerator._repair_branch_edge_handles(nodes=nodes, edges=edges) + + assert edges[0]["sourceHandle"] == "true" + assert edges[1]["sourceHandle"] == "false" + + def test_leaves_ambiguous_assignments_alone(self): + # Three default edges, only two free handles — guessing could swap + # the IF and ELSE arms, so the repair must not touch anything. + nodes = [self._if_else_node()] + edges = [ + {"source": "branch", "target": "a", "sourceHandle": "source"}, + {"source": "branch", "target": "b", "sourceHandle": "source"}, + {"source": "branch", "target": "c", "sourceHandle": "source"}, + ] + + WorkflowGenerator._repair_branch_edge_handles(nodes=nodes, edges=edges) + + assert all(e["sourceHandle"] == "source" for e in edges) + + def test_question_classifier_uses_class_ids(self): + nodes = [ + { + "id": "qc", + "data": { + "type": "question-classifier", + "classes": [{"id": "1", "name": "A"}, {"id": "2", "name": "B"}], + }, + } + ] + edges = [ + {"source": "qc", "target": "a"}, + {"source": "qc", "target": "b"}, + ] + + WorkflowGenerator._repair_branch_edge_handles(nodes=nodes, edges=edges) + + assert edges[0]["sourceHandle"] == "1" + assert edges[1]["sourceHandle"] == "2" + + def test_non_branch_nodes_are_untouched(self): + nodes = [{"id": "llm1", "data": {"type": "llm"}}] + edges = [{"source": "llm1", "target": "end", "sourceHandle": "source"}] + + WorkflowGenerator._repair_branch_edge_handles(nodes=nodes, edges=edges) + + assert edges[0]["sourceHandle"] == "source" + + +class TestWorkflowGeneratorGraphCycleValidation: + """A workflow graph must be a DAG; cycles hang or error the run.""" + + def test_self_loop_is_flagged_with_the_node_id(self): + graph = { + "nodes": [], + "edges": [{"source": "a", "target": "a"}], + "viewport": {"x": 0, "y": 0, "zoom": 0.7}, + } + errors = WorkflowGenerator._collect_edge_cycle_errors(graph=cast(GraphDict, graph), known_ids={"a"}) + assert len(errors) == 1 + assert errors[0]["code"] == "GRAPH_CYCLE" + assert errors[0]["node_id"] == "a" + + def test_two_node_cycle_is_flagged_once(self): + graph = { + "nodes": [], + "edges": [ + {"source": "start", "target": "a"}, + {"source": "a", "target": "b"}, + {"source": "b", "target": "a"}, + ], + "viewport": {"x": 0, "y": 0, "zoom": 0.7}, + } + errors = WorkflowGenerator._collect_edge_cycle_errors( + graph=cast(GraphDict, graph), known_ids={"start", "a", "b"} + ) + assert len(errors) == 1 + assert errors[0]["code"] == "GRAPH_CYCLE" + assert "a" in errors[0]["detail"] + assert "b" in errors[0]["detail"] + + def test_acyclic_graph_produces_no_errors(self): + graph = { + "nodes": [], + "edges": [ + {"source": "start", "target": "a"}, + {"source": "start", "target": "b"}, + {"source": "a", "target": "end"}, + {"source": "b", "target": "end"}, + ], + "viewport": {"x": 0, "y": 0, "zoom": 0.7}, + } + errors = WorkflowGenerator._collect_edge_cycle_errors( + graph=cast(GraphDict, graph), known_ids={"start", "a", "b", "end"} + ) + assert errors == [] + + def test_cyclic_builder_output_surfaces_graph_cycle_code(self): + planner = json.dumps( + { + "title": "t", + "description": "d", + "nodes": [ + {"label": "Start", "node_type": "start", "purpose": "x"}, + {"label": "LLM", "node_type": "llm", "purpose": "x"}, + {"label": "End", "node_type": "end", "purpose": "x"}, + ], + } + ) + builder = json.dumps( + { + "nodes": [ + { + "id": "node1", + "type": "custom", + "position": {"x": 0, "y": 0}, + "data": {"type": "start", "title": "Start", "variables": []}, + }, + { + "id": "node2", + "type": "custom", + "position": {"x": 0, "y": 0}, + "data": {"type": "llm", "title": "LLM"}, + }, + { + "id": "node3", + "type": "custom", + "position": {"x": 0, "y": 0}, + "data": {"type": "end", "title": "End", "outputs": []}, + }, + ], + "edges": [ + {"source": "node1", "target": "node2"}, + {"source": "node2", "target": "node2"}, + {"source": "node2", "target": "node3"}, + ], + "viewport": {"x": 0, "y": 0, "zoom": 0.7}, + } + ) + model_instance = MagicMock() + model_instance.invoke_llm.side_effect = [_llm_result(planner), _llm_result(builder)] + + result = WorkflowGenerator.generate_workflow_graph( + model_instance=model_instance, + model_parameters={}, + provider="openai", + model_name="gpt-4o", + model_mode="chat", + mode="workflow", + instruction="x", + ) + + assert any(e["code"] == "GRAPH_CYCLE" for e in result["errors"]) + + +class TestWorkflowGeneratorDuplicateNodeIds: + """Duplicate ids make every cross-reference ambiguous — fail loudly.""" + + def test_duplicate_ids_surface_dedicated_code(self): + planner = json.dumps( + { + "title": "t", + "description": "d", + "nodes": [ + {"label": "Start", "node_type": "start", "purpose": "x"}, + {"label": "End", "node_type": "end", "purpose": "x"}, + ], + } + ) + builder = json.dumps( + { + "nodes": [ + { + "id": "node1", + "type": "custom", + "position": {"x": 0, "y": 0}, + "data": {"type": "start", "title": "Start", "variables": []}, + }, + { + "id": "node1", + "type": "custom", + "position": {"x": 0, "y": 0}, + "data": {"type": "end", "title": "End", "outputs": []}, + }, + ], + "edges": [{"source": "node1", "target": "node1"}], + "viewport": {"x": 0, "y": 0, "zoom": 0.7}, + } + ) + model_instance = MagicMock() + model_instance.invoke_llm.side_effect = [_llm_result(planner), _llm_result(builder)] + + result = WorkflowGenerator.generate_workflow_graph( + model_instance=model_instance, + model_parameters={}, + provider="openai", + model_name="gpt-4o", + model_mode="chat", + mode="workflow", + instruction="x", + ) + + codes = {e["code"] for e in result["errors"]} + assert "DUPLICATE_NODE_ID" in codes diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py b/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py index a16a8b481a6..a393dab7d9d 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py @@ -9,7 +9,7 @@ from core.repositories.human_input_repository import ( HumanInputFormEntity, HumanInputFormRepository, ) -from core.workflow.node_runtime import DifyFileReferenceFactory, DifyHumanInputNodeRuntime +from core.workflow.node_runtime import DifyHumanInputNodeRuntime from core.workflow.system_variables import build_system_variables from graphon.entities import WorkflowStartReason from graphon.file import File, FileTransferMethod, FileType @@ -186,25 +186,29 @@ def _build_graph(runtime_state: GraphRuntimeState, repo: HumanInputFormRepositor ) human_a_config = {"id": "human_a", "data": human_data.model_dump()} + human_a_runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context) + human_a_runtime._file_reference_factory = _TestFileReferenceFactory() # type: ignore[attr-defined] human_a = HumanInputNode( node_id=human_a_config["id"], data=human_data, graph_init_params=graph_init_params, graph_runtime_state=runtime_state, form_repository=repo, - file_reference_factory=DifyFileReferenceFactory(graph_init_params.run_context), - runtime=DifyHumanInputNodeRuntime(graph_init_params.run_context), + file_reference_factory=_TestFileReferenceFactory(), + runtime=human_a_runtime, ) human_b_config = {"id": "human_b", "data": human_data.model_dump()} + human_b_runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context) + human_b_runtime._file_reference_factory = _TestFileReferenceFactory() # type: ignore[attr-defined] human_b = HumanInputNode( node_id=human_b_config["id"], data=human_data, graph_init_params=graph_init_params, graph_runtime_state=runtime_state, form_repository=repo, - file_reference_factory=DifyFileReferenceFactory(graph_init_params.run_context), - runtime=DifyHumanInputNodeRuntime(graph_init_params.run_context), + file_reference_factory=_TestFileReferenceFactory(), + runtime=human_b_runtime, ) end_data = EndNodeData( diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_table_runner.py b/api/tests/unit_tests/core/workflow/graph_engine/test_table_runner.py index 100b294f528..e9c9e04e17b 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_table_runner.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_table_runner.py @@ -24,6 +24,7 @@ from core.tools.utils.yaml_utils import _load_yaml_file from core.workflow.node_factory import DifyNodeFactory, get_default_root_node_id from core.workflow.system_variables import build_bootstrap_variables, build_system_variables from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool +from core.workflow.workflow_entry import iter_dify_graph_engine_events from graphon.entities import GraphInitParams from graphon.graph import Graph from graphon.graph_engine import GraphEngine, GraphEngineConfig @@ -386,7 +387,7 @@ class TableTestRunner: # Execute and collect events events: list[GraphEngineEvent] = [] - for event in engine.run(): + for event in iter_dify_graph_engine_events(engine): events.append(event) # Check execution success diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_tool_in_chatflow.py b/api/tests/unit_tests/core/workflow/graph_engine/test_tool_in_chatflow.py index ba1e74f3e0e..33e8f869a5a 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_tool_in_chatflow.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_tool_in_chatflow.py @@ -1,3 +1,4 @@ +from core.workflow.workflow_entry import iter_dify_graph_engine_events from graphon.graph_engine import GraphEngine, GraphEngineConfig from graphon.graph_engine.command_channels import InMemoryChannel from graphon.graph_events import ( @@ -31,20 +32,17 @@ def test_tool_in_chatflow(): config=GraphEngineConfig(), ) - events = list(engine.run()) + events = list(iter_dify_graph_engine_events(engine)) # Check for successful completion success_events = [e for e in events if isinstance(e, GraphRunSucceededEvent)] assert len(success_events) > 0, "Workflow should complete successfully" - # Check for streaming events stream_chunk_events = [e for e in events if isinstance(e, NodeRunStreamChunkEvent)] - stream_chunk_count = len(stream_chunk_events) - - assert stream_chunk_count == 1, f"Expected 1 streaming events, but got {stream_chunk_count}" - assert stream_chunk_events[0].chunk == "hello, dify!", ( - f"Expected chunk to be 'hello, dify!', but got {stream_chunk_events[0].chunk}" - ) + assert len(stream_chunk_events) > 0 + assert "".join(event.chunk for event in stream_chunk_events) == "hello, dify!" + assert stream_chunk_events[-1].is_final is True + assert success_events[-1].outputs["answer"] == "hello, dify!" def test_answer_can_render_llm_structured_output_in_chatflow(): @@ -88,7 +86,7 @@ def test_answer_can_render_llm_structured_output_in_chatflow(): config=GraphEngineConfig(), ) - events = list(engine.run()) + events = list(iter_dify_graph_engine_events(engine)) success_events = [e for e in events if isinstance(e, GraphRunSucceededEvent)] assert success_events, "Workflow should complete successfully" diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py index 81ca22dbb9e..b2a5ef3931e 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py @@ -1,29 +1,37 @@ from types import SimpleNamespace from typing import cast -from unittest.mock import patch +from unittest.mock import MagicMock, patch from agenton.compositor import CompositorSessionSnapshot +from dify_agent.layers.ask_human import AskHumanToolResult from dify_agent.protocol import RunStartedEvent, RunSucceededEvent, RunSucceededEventData from clients.agent_backend import ( AgentBackendRunEventAdapter, AgentBackendStreamInternalEvent, - CleanupLayerSpec, FakeAgentBackendRunClient, FakeAgentBackendScenario, + RuntimeLayerSpec, ) from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext, InvokeFrom, UserFrom from core.workflow.file_reference import build_file_reference from core.workflow.nodes.agent_v2 import DifyAgentNode +from core.workflow.nodes.agent_v2.ask_human_resume import AskHumanResumeOutcome from core.workflow.nodes.agent_v2.binding_resolver import WorkflowAgentBindingBundle, WorkflowAgentBindingResolver from core.workflow.nodes.agent_v2.entities import DifyAgentNodeData from core.workflow.nodes.agent_v2.output_adapter import WorkflowAgentOutputAdapter from core.workflow.nodes.agent_v2.runtime_request_builder import WorkflowAgentRuntimeRequestBuilder -from core.workflow.nodes.agent_v2.session_store import WorkflowAgentRuntimeSessionStore, WorkflowAgentSessionScope +from core.workflow.nodes.agent_v2.session_store import ( + StoredWorkflowAgentSession, + WorkflowAgentRuntimeSessionStore, + WorkflowAgentSessionScope, +) from graphon.entities import GraphInitParams +from graphon.entities.pause_reason import HumanInputRequired from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus from graphon.file import File, FileTransferMethod, FileType from graphon.node_events import PauseRequestedEvent, StreamCompletedEvent +from graphon.nodes.human_input.entities import UserActionConfig from graphon.runtime import GraphRuntimeState from graphon.variables.segments import ArrayFileSegment, FileSegment, StringSegment from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding @@ -113,12 +121,16 @@ class FakeBindingResolver(WorkflowAgentBindingResolver): class FakeSessionStore: def __init__(self, snapshot: CompositorSessionSnapshot | None = None) -> None: self.loaded_snapshot = snapshot + # ENG-638: set to simulate resume after a submitted/timed-out form. + self.loaded_session: StoredWorkflowAgentSession | None = None self.saved: list[ tuple[ WorkflowAgentSessionScope, str, CompositorSessionSnapshot | None, - list[CleanupLayerSpec], + list[RuntimeLayerSpec], + str | None, + str | None, ] ] = [] self.cleaned: list[tuple[WorkflowAgentSessionScope, str | None]] = [] @@ -126,15 +138,22 @@ class FakeSessionStore: def load_active_snapshot(self, scope: WorkflowAgentSessionScope) -> CompositorSessionSnapshot | None: return self.loaded_snapshot + def load_active_session(self, scope: WorkflowAgentSessionScope) -> StoredWorkflowAgentSession | None: + return self.loaded_session + def save_active_snapshot( self, *, scope: WorkflowAgentSessionScope, backend_run_id: str, snapshot: CompositorSessionSnapshot | None, - composition_layer_specs: list[CleanupLayerSpec], + runtime_layer_specs: list[RuntimeLayerSpec], + pending_form_id: str | None = None, + pending_tool_call_id: str | None = None, ) -> None: - self.saved.append((scope, backend_run_id, snapshot, list(composition_layer_specs))) + self.saved.append( + (scope, backend_run_id, snapshot, list(runtime_layer_specs), pending_form_id, pending_tool_call_id) + ) def mark_cleaned( self, @@ -378,10 +397,13 @@ def test_agent_node_saves_success_snapshot_and_reuses_existing_snapshot(): assert len(events) == 1 assert store.saved - scope, backend_run_id, saved_snapshot, saved_specs = store.saved[0] + scope, backend_run_id, saved_snapshot, saved_specs, pending_form_id, pending_tool_call_id = store.saved[0] assert scope.workflow_run_id == "workflow-run-1" assert backend_run_id == "fake-run-1" assert saved_snapshot is not None + # A successful terminal carries no ask_human pause correlation. + assert pending_form_id is None + assert pending_tool_call_id is None assert client.request is not None assert client.request.session_snapshot is existing_snapshot # Persist enough composition shape to replay a cleanup run; plugin layers @@ -462,13 +484,100 @@ def test_agent_node_paused_run_requests_workflow_pause_and_persists_snapshot(): store = FakeSessionStore() node = _node(scenario=FakeAgentBackendScenario.PAUSED, session_store=store) + # ENG-636: the PAUSED scenario emits a dify.ask_human deferred call, so the + # node now builds a HITL form and pauses with HumanInputRequired. Stub the + # form repository so the unit test stays DB-free. + fake_repo = MagicMock() + fake_repo.create_form.return_value = MagicMock(id="form-1") + node._build_human_input_form_repository = lambda *, dify_ctx, workflow_run_id: fake_repo # type: ignore[assignment] + events = list(node._run()) assert len(events) == 1 assert isinstance(events[0], PauseRequestedEvent) + assert isinstance(events[0].reason, HumanInputRequired) + assert events[0].reason.form_id == "form-1" + assert events[0].reason.node_id == "agent-node" + fake_repo.create_form.assert_called_once() assert store.saved assert store.saved[0][1] == "fake-run-1" assert store.saved[0][3], "paused agent run should still persist replayable layer specs" + # ENG-637: the awaiting form + deferred tool_call correlation is persisted. + assert store.saved[0][4] == "form-1" + assert store.saved[0][5] == "fake-ask-human-1" + + +def _pending_session(snapshot: CompositorSessionSnapshot) -> StoredWorkflowAgentSession: + return StoredWorkflowAgentSession( + scope=WorkflowAgentSessionScope( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_run_id="workflow-run-1", + node_id="agent-node", + node_execution_id="exec-1", + binding_id="binding-1", + agent_id="agent-1", + agent_config_snapshot_id="snapshot-1", + ), + session_snapshot=snapshot, + backend_run_id="run-0", + pending_form_id="form-1", + pending_tool_call_id="call-1", + ) + + +def test_agent_node_resumes_with_deferred_tool_results_after_submitted_form(monkeypatch): + # ENG-638: a submitted form re-enters _run; the human's answer is threaded + # into the second Agent run as deferred_tool_results. + snapshot = CompositorSessionSnapshot(layers=[]) + store = FakeSessionStore(snapshot=snapshot) + store.loaded_session = _pending_session(snapshot) + + def _fake_resolve(*, form_id: str, tenant_id: str, node_id: str) -> AskHumanResumeOutcome: + assert form_id == "form-1" + return AskHumanResumeOutcome(deferred_result=AskHumanToolResult(status="submitted", values={"note": "ok"})) + + monkeypatch.setattr("core.workflow.nodes.agent_v2.agent_node.resolve_ask_human_form", _fake_resolve) + + client = FakeAgentBackendRunClient() # SUCCESS scenario -> second run completes + node = _node(agent_backend_client=client, session_store=store) + + events = list(node._run()) + + assert client.request is not None + assert client.request.deferred_tool_results is not None + assert set(client.request.deferred_tool_results.calls) == {"call-1"} + assert any(isinstance(event, StreamCompletedEvent) for event in events) + + +def test_agent_node_repauses_when_resumed_form_still_waiting(monkeypatch): + snapshot = CompositorSessionSnapshot(layers=[]) + store = FakeSessionStore(snapshot=snapshot) + store.loaded_session = _pending_session(snapshot) + + repause = HumanInputRequired( + form_id="form-1", + form_content="Approve?", + inputs=[], + actions=[UserActionConfig(id="ok", title="OK")], + node_id="agent-node", + node_title="Budget review", + ) + monkeypatch.setattr( + "core.workflow.nodes.agent_v2.agent_node.resolve_ask_human_form", + lambda **_kwargs: AskHumanResumeOutcome(repause=repause), + ) + + client = FakeAgentBackendRunClient() + node = _node(agent_backend_client=client, session_store=store) + + events = list(node._run()) + + assert len(events) == 1 + assert isinstance(events[0], PauseRequestedEvent) + assert isinstance(events[0].reason, HumanInputRequired) + assert client.request is None # no second Agent run was created def test_agent_node_records_stream_usage_metadata(): diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_ask_human_hitl.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_ask_human_hitl.py new file mode 100644 index 00000000000..5f656f13a29 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_ask_human_hitl.py @@ -0,0 +1,338 @@ +"""Unit tests for the ask_human -> HITL form translation layer (ENG-636).""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +import pytest +from dify_agent.layers.ask_human import AskHumanToolArgs +from dify_agent.protocol import DeferredToolCallPayload + +from core.repositories.human_input_repository import FormCreateParams, HumanInputFormRepository +from core.workflow.human_input_adapter import ( + EmailDeliveryMethod, + ExternalRecipient, + InteractiveSurfaceDeliveryMethod, +) +from core.workflow.nodes.agent_v2.ask_human_hitl import ( + AskHumanFormBuildError, + ask_human_args_to_node_data, + build_ask_human_pause_reason, + build_delivery_methods, + parse_ask_human_args, +) +from graphon.nodes.human_input.entities import ( + FileInputConfig, + FileListInputConfig, + ParagraphInputConfig, + SelectInputConfig, +) +from graphon.nodes.human_input.enums import ButtonStyle, TimeoutUnit +from models.agent_config_entities import AgentHumanContactConfig + + +def _args(**overrides: Any) -> AskHumanToolArgs: + payload: dict[str, Any] = {"question": "Approve the budget?"} + payload.update(overrides) + return AskHumanToolArgs.model_validate(payload) + + +def _deferred_call(args: dict[str, Any], *, tool_name: str = "ask_human") -> DeferredToolCallPayload: + return DeferredToolCallPayload(tool_call_id="call-1", tool_name=tool_name, args=args) + + +def _fake_repository(form_id: str = "form-123") -> MagicMock: + repo = MagicMock(spec=HumanInputFormRepository) + repo.create_form.return_value = MagicMock(id=form_id) + return repo + + +# ─────────────────────────── parse_ask_human_args ─────────────────────────── + + +def test_parse_ask_human_args_from_mapping() -> None: + parsed = parse_ask_human_args({"question": "Need a decision"}) + assert isinstance(parsed, AskHumanToolArgs) + assert parsed.question == "Need a decision" + + +def test_parse_ask_human_args_passthrough() -> None: + original = _args() + assert parse_ask_human_args(original) is original + + +def test_parse_ask_human_args_invalid_payload_raises() -> None: + with pytest.raises(AskHumanFormBuildError): + parse_ask_human_args({"question": ""}) # blank question is rejected + + +def test_parse_ask_human_args_non_mapping_raises() -> None: + with pytest.raises(AskHumanFormBuildError): + parse_ask_human_args("not a mapping") + + +# ───────────────────────── ask_human_args_to_node_data ────────────────────── + + +def test_node_data_maps_every_field_type() -> None: + args = _args( + fields=[ + {"type": "paragraph", "name": "reason", "label": "Reason", "default": "n/a"}, + { + "type": "select", + "name": "tier", + "label": "Tier", + "options": [{"value": "t1", "label": "Tier 1"}, {"value": "t2", "label": "Tier 2"}], + "default": "t2", + }, + {"type": "file", "name": "doc", "label": "Document"}, + {"type": "file-list", "name": "evidence", "label": "Evidence", "max_files": 3}, + ], + ) + + node_data = ask_human_args_to_node_data(args, node_title="Budget review") + + assert [type(i) for i in node_data.inputs] == [ + ParagraphInputConfig, + SelectInputConfig, + FileInputConfig, + FileListInputConfig, + ] + paragraph, select, _file, file_list = node_data.inputs + assert isinstance(paragraph, ParagraphInputConfig) + assert isinstance(select, SelectInputConfig) + assert isinstance(file_list, FileListInputConfig) + assert paragraph.output_variable_name == "reason" + assert paragraph.default is not None + assert paragraph.default.value == "n/a" + assert select.option_source.value == ["t1", "t2"] + assert file_list.number_limits == 3 + assert node_data.timeout == 36 + assert node_data.timeout_unit == TimeoutUnit.HOUR + assert node_data.title == "Budget review" + + +def test_node_data_form_content_embeds_title_question_and_field_markers() -> None: + args = _args( + title="Decision needed", + markdown="Some **context** here.", + fields=[{"type": "paragraph", "name": "reason", "label": "Reason", "required": True}], + ) + + content = ask_human_args_to_node_data(args, node_title="t").form_content + + assert "## Decision needed" in content + assert "Approve the budget?" in content + assert "Some **context** here." in content + # The label carries a required marker and positions the input via $output. + assert "**Reason ***" in content + assert "{{#$output.reason#}}" in content + + +def test_node_data_maps_action_styles_and_titles() -> None: + args = _args( + actions=[ + {"id": "approve", "label": "Approve", "style": "primary"}, + {"id": "reject", "label": "Reject", "style": "destructive"}, + {"id": "later", "label": "Decide later"}, + ], + ) + + actions = ask_human_args_to_node_data(args, node_title="t").user_actions + + assert [(a.id, a.title, a.button_style) for a in actions] == [ + ("approve", "Approve", ButtonStyle.PRIMARY), + ("reject", "Reject", ButtonStyle.ACCENT), # destructive -> accent + ("later", "Decide later", ButtonStyle.DEFAULT), + ] + + +def test_node_data_synthesizes_submit_action_when_none_given() -> None: + actions = ask_human_args_to_node_data(_args(), node_title="t").user_actions + assert len(actions) == 1 + assert actions[0].id == "submit" + assert actions[0].button_style == ButtonStyle.PRIMARY + + +def test_node_data_clamps_overlong_action_id_deterministically() -> None: + long_id = "approve_the_quarterly_budget_request" # > 20 chars, valid identifier + args = _args(actions=[{"id": long_id, "label": "Approve"}]) + + first = ask_human_args_to_node_data(args, node_title="t").user_actions[0] + second = ask_human_args_to_node_data(args, node_title="t").user_actions[0] + + assert len(first.id) <= 20 + assert first.id.isidentifier() + assert first.id == second.id # stable across builds + assert first.title == "Approve" # label preserved verbatim + + +# ───────────────────────────── build_delivery_methods ────────────────────── + + +def test_delivery_always_includes_interactive_surface() -> None: + methods = build_delivery_methods([], args=_args()) + assert len(methods) == 1 + assert isinstance(methods[0], InteractiveSurfaceDeliveryMethod) + + +def test_delivery_adds_email_for_contacts_and_dedupes() -> None: + contacts = [ + AgentHumanContactConfig(email="a@x.com"), + AgentHumanContactConfig(email="a@x.com"), # duplicate + AgentHumanContactConfig(email=None), # no email + AgentHumanContactConfig(email="b@x.com"), + ] + + methods = build_delivery_methods(contacts, args=_args()) + + email_methods = [m for m in methods if isinstance(m, EmailDeliveryMethod)] + assert len(email_methods) == 1 + recipients = email_methods[0].config.recipients.items + assert [r.email for r in recipients if isinstance(r, ExternalRecipient)] == ["a@x.com", "b@x.com"] + + +def test_delivery_high_urgency_prefixes_subject() -> None: + methods = build_delivery_methods( + [AgentHumanContactConfig(email="a@x.com")], + args=_args(title="Sign off", urgency="high"), + ) + email_method = next(m for m in methods if isinstance(m, EmailDeliveryMethod)) + assert email_method.config.subject.startswith("[Action needed] ") + + +# ─────────────────────────── build_ask_human_pause_reason ─────────────────── + + +def test_pause_reason_none_for_non_ask_human_tool() -> None: + result = build_ask_human_pause_reason( + deferred_tool_call=_deferred_call({"question": "q"}, tool_name="final_output"), + node_id="node-1", + default_node_title="Agent", + workflow_run_id="wf-1", + contacts=[], + repository=_fake_repository(), + ) + assert result is None + + +def test_pause_reason_requires_workflow_run_id() -> None: + with pytest.raises(AskHumanFormBuildError): + build_ask_human_pause_reason( + deferred_tool_call=_deferred_call({"question": "q"}), + node_id="node-1", + default_node_title="Agent", + workflow_run_id="", + contacts=[], + repository=_fake_repository(), + ) + + +def test_pause_reason_builds_form_and_returns_human_input_required() -> None: + repo = _fake_repository(form_id="form-xyz") + contacts = [AgentHumanContactConfig(email="a@x.com")] + + result = build_ask_human_pause_reason( + deferred_tool_call=_deferred_call( + { + "title": "Approve?", + "question": "Please approve", + "fields": [{"type": "paragraph", "name": "note", "label": "Note"}], + "actions": [{"id": "ok", "label": "OK"}], + } + ), + node_id="node-1", + default_node_title="Agent fallback", + workflow_run_id="wf-1", + contacts=contacts, + repository=repo, + ) + + assert result is not None + assert result.form_id == "form-xyz" + assert result.node_id == "node-1" + assert result.node_title == "Approve?" # args.title wins over default + assert [i.output_variable_name for i in result.inputs] == ["note"] + assert [a.id for a in result.actions] == ["ok"] + + params: FormCreateParams = repo.create_form.call_args.args[0] + assert params.workflow_execution_id == "wf-1" + assert params.node_id == "node-1" + # No conversation_id passed -> pure workflow run owns the form by workflow_run_id only. + assert params.conversation_id is None + assert any(isinstance(m, EmailDeliveryMethod) for m in params.delivery_methods) + + +def test_pause_reason_forwards_conversation_id_for_chatflow() -> None: + # ENG-635 (review): an agent node running in a chatflow tags its ask_human form + # with the conversation in addition to the workflow run. + repo = _fake_repository(form_id="form-xyz") + + build_ask_human_pause_reason( + deferred_tool_call=_deferred_call({"question": "Please approve"}), + node_id="node-1", + default_node_title="Agent", + workflow_run_id="wf-1", + conversation_id="conv-1", + contacts=[], + repository=repo, + ) + + params: FormCreateParams = repo.create_form.call_args.args[0] + assert params.workflow_execution_id == "wf-1" + assert params.conversation_id == "conv-1" + + +def test_pause_reason_falls_back_to_default_node_title() -> None: + result = build_ask_human_pause_reason( + deferred_tool_call=_deferred_call({"question": "q with no title"}), + node_id="node-1", + default_node_title="Agent fallback", + workflow_run_id="wf-1", + contacts=[], + repository=_fake_repository(), + ) + assert result is not None + assert result.node_title == "Agent fallback" + + +def test_pause_reason_select_default_flows_into_resolved_defaults() -> None: + repo = _fake_repository() + result = build_ask_human_pause_reason( + deferred_tool_call=_deferred_call( + { + "question": "pick", + "fields": [ + { + "type": "select", + "name": "tier", + "label": "Tier", + "options": [{"value": "t1", "label": "Tier 1"}], + "default": "t1", + } + ], + } + ), + node_id="node-1", + default_node_title="Agent", + workflow_run_id="wf-1", + contacts=[], + repository=repo, + ) + assert result is not None + assert result.resolved_default_values == {"tier": "t1"} + + +def test_pause_reason_wraps_repository_value_error() -> None: + repo = MagicMock(spec=HumanInputFormRepository) + repo.create_form.side_effect = ValueError("db boom") + with pytest.raises(AskHumanFormBuildError): + build_ask_human_pause_reason( + deferred_tool_call=_deferred_call({"question": "q"}), + node_id="node-1", + default_node_title="Agent", + workflow_run_id="wf-1", + contacts=[], + repository=repo, + ) diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_ask_human_resume.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_ask_human_resume.py new file mode 100644 index 00000000000..8f7454313df --- /dev/null +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_ask_human_resume.py @@ -0,0 +1,121 @@ +"""Unit tests for mapping a submitted/timed-out HITL form back to ask_human (ENG-638).""" + +from __future__ import annotations + +from datetime import datetime + +from dify_agent.layers.ask_human import AskHumanToolResult + +from core.workflow.nodes.agent_v2.ask_human_resume import ( + build_deferred_tool_results, + map_form_to_outcome, +) +from graphon.entities.pause_reason import HumanInputRequired +from graphon.nodes.human_input.entities import FormDefinition, ParagraphInputConfig, UserActionConfig +from graphon.nodes.human_input.enums import HumanInputFormStatus + + +def _form_definition_json() -> str: + return FormDefinition( + form_content="Approve? {{#$output.note#}}", + inputs=[ParagraphInputConfig(output_variable_name="note")], + user_actions=[UserActionConfig(id="approve", title="Approve"), UserActionConfig(id="reject", title="Reject")], + rendered_content="Approve? ", + expiration_time=datetime(2026, 1, 1), + default_values={"note": "default"}, + node_title="Budget review", + ).model_dump_json() + + +def test_map_submitted_form_to_result() -> None: + outcome = map_form_to_outcome( + status=HumanInputFormStatus.SUBMITTED, + selected_action_id="approve", + submitted_data='{"note": "looks good"}', + rendered_content="Approve? ", + form_definition=_form_definition_json(), + form_id="form-1", + node_id="node-1", + ) + + assert outcome.repause is None + result = outcome.deferred_result + assert result is not None + assert result.status == "submitted" + assert result.action is not None + assert result.action.id == "approve" + assert result.action.label == "Approve" # verbatim label recovered from the form + assert result.values == {"note": "looks good"} + assert result.rendered_content == "Approve? " + + +def test_map_timeout_form_to_timeout_result() -> None: + outcome = map_form_to_outcome( + status=HumanInputFormStatus.TIMEOUT, + selected_action_id=None, + submitted_data=None, + rendered_content="x", + form_definition=_form_definition_json(), + form_id="form-1", + node_id="node-1", + ) + assert outcome.deferred_result is not None + assert outcome.deferred_result.status == "timeout" + assert outcome.deferred_result.action is None + + +def test_map_expired_form_to_timeout_result() -> None: + outcome = map_form_to_outcome( + status=HumanInputFormStatus.EXPIRED, + selected_action_id=None, + submitted_data=None, + rendered_content="x", + form_definition=_form_definition_json(), + form_id="form-1", + node_id="node-1", + ) + assert outcome.deferred_result is not None + assert outcome.deferred_result.status == "timeout" + + +def test_map_waiting_form_rebuilds_pause() -> None: + outcome = map_form_to_outcome( + status=HumanInputFormStatus.WAITING, + selected_action_id=None, + submitted_data=None, + rendered_content="x", + form_definition=_form_definition_json(), + form_id="form-1", + node_id="node-1", + ) + + assert outcome.deferred_result is None + pause = outcome.repause + assert isinstance(pause, HumanInputRequired) + assert pause.form_id == "form-1" + assert pause.node_id == "node-1" + assert pause.node_title == "Budget review" + assert [a.id for a in pause.actions] == ["approve", "reject"] + assert [i.output_variable_name for i in pause.inputs] == ["note"] + + +def test_map_submitted_without_action_id() -> None: + outcome = map_form_to_outcome( + status=HumanInputFormStatus.SUBMITTED, + selected_action_id=None, + submitted_data="{}", + rendered_content="x", + form_definition=_form_definition_json(), + form_id="form-1", + node_id="node-1", + ) + assert outcome.deferred_result is not None + assert outcome.deferred_result.action is None + assert outcome.deferred_result.values == {} + + +def test_build_deferred_tool_results_keys_by_tool_call_id() -> None: + result = AskHumanToolResult(status="timeout") + payload = build_deferred_tool_results(tool_call_id="call-42", result=result) + assert set(payload.calls) == {"call-42"} + assert payload.calls["call-42"] == result.model_dump(mode="json") diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_binding_resolver.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_binding_resolver.py index 1f75e4c19d9..a628c76b38d 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_binding_resolver.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_binding_resolver.py @@ -4,7 +4,7 @@ from core.workflow.nodes.agent_v2.binding_resolver import ( WorkflowAgentBindingError, WorkflowAgentBindingResolver, ) -from models.agent import Agent, AgentConfigSnapshot, AgentStatus, WorkflowAgentNodeBinding +from models.agent import Agent, AgentConfigSnapshot, AgentStatus, WorkflowAgentBindingType, WorkflowAgentNodeBinding from models.agent_config_entities import AgentSoulConfig, AgentSoulModelConfig, WorkflowNodeJobConfig @@ -85,6 +85,25 @@ def test_binding_resolver_returns_detached_binding_bundle(monkeypatch: pytest.Mo assert fake_session.expunge_calls == [bundle.binding, bundle.agent, bundle.snapshot] +def test_binding_resolver_uses_active_snapshot_for_roster_agent(monkeypatch: pytest.MonkeyPatch): + binding = _binding() + binding.binding_type = WorkflowAgentBindingType.ROSTER_AGENT + binding.current_snapshot_id = "old-snapshot" + agent = _agent() + agent.active_config_snapshot_id = "active-snapshot" + snapshot = _snapshot() + snapshot.id = "active-snapshot" + fake_session = FakeSession([binding, agent, snapshot]) + monkeypatch.setattr( + "core.workflow.nodes.agent_v2.binding_resolver.session_factory.create_session", + lambda: fake_session, + ) + + bundle = WorkflowAgentBindingResolver().resolve(**_resolve()) + + assert bundle.snapshot.id == "active-snapshot" + + def test_binding_resolver_raises_when_binding_missing(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( "core.workflow.nodes.agent_v2.binding_resolver.session_factory.create_session", diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_output_adapter.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_output_adapter.py index 49e24cc6770..8d50021f8fe 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_output_adapter.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_output_adapter.py @@ -6,7 +6,6 @@ from agenton.compositor import CompositorSessionSnapshot from clients.agent_backend import ( AgentBackendRunCancelledInternalEvent, AgentBackendRunFailedInternalEvent, - AgentBackendRunPausedInternalEvent, AgentBackendRunSucceededInternalEvent, ) from core.workflow.file_reference import build_file_reference @@ -144,24 +143,6 @@ def test_success_output_adapter_preserves_dict_output(): } -def test_failure_output_adapter_maps_paused_to_unsupported_failure(): - result = WorkflowAgentOutputAdapter().build_failure_result( - event=AgentBackendRunPausedInternalEvent( - run_id="run-1", - source_event_id="2-0", - reason="human", - message=None, - session_snapshot=None, - ), - inputs={}, - process_data={}, - metadata={}, - ) - - assert result.status == WorkflowNodeExecutionStatus.FAILED - assert result.error_type == "agent_backend_paused_unsupported" - - def test_failure_output_adapter_preserves_backend_failed_reason(): result = WorkflowAgentOutputAdapter().build_failure_result( event=AgentBackendRunFailedInternalEvent( diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_plugin_tools_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_plugin_tools_builder.py index c27b560e457..1ff87614b08 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_plugin_tools_builder.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_plugin_tools_builder.py @@ -17,6 +17,7 @@ from core.tools.entities.tool_entities import ( ToolInvokeMessage, ToolParameter, ) +from core.tools.tool_manager import ToolManager from core.workflow.nodes.agent_v2.plugin_tools_builder import ( WorkflowAgentPluginToolsBuilder, WorkflowAgentPluginToolsBuildError, @@ -32,6 +33,8 @@ class FakeRuntimeProvider: self.tool = tool self.last_agent_tool: AgentToolEntity | None = None self.last_invoke_from: InvokeFrom | None = None + self.last_allow_file_parameters: bool | None = None + self.last_use_default_for_missing_form_parameters: bool | None = None def get_agent_tool_runtime( self, @@ -41,11 +44,25 @@ class FakeRuntimeProvider: user_id: str | None = None, invoke_from: InvokeFrom = InvokeFrom.DEBUGGER, variable_pool: Any | None = None, + allow_file_parameters: bool = False, + use_default_for_missing_form_parameters: bool = False, ) -> Tool: self.last_agent_tool = agent_tool self.last_invoke_from = invoke_from + self.last_allow_file_parameters = allow_file_parameters + self.last_use_default_for_missing_form_parameters = use_default_for_missing_form_parameters if isinstance(self.tool, Exception): raise self.tool + if self.tool.runtime is not None: + runtime_parameters = ToolManager._convert_tool_parameters_type( + self.tool.get_merged_runtime_parameters(), + variable_pool, + agent_tool.tool_parameters, + typ="agent", + allow_file_parameters=allow_file_parameters, + use_default_for_missing_form_parameters=use_default_for_missing_form_parameters, + ) + self.tool.runtime.runtime_parameters.update(runtime_parameters) return self.tool @@ -103,6 +120,67 @@ def _tool(*, runtime_parameters: dict[str, Any] | None = None) -> FakeTool: return FakeTool(entity=entity, runtime=runtime) +def _file_tool() -> FakeTool: + parameters = [ + ToolParameter( + name="audio_file", + label=I18nObject(en_US="Audio File"), + type=ToolParameter.ToolParameterType.FILE, + form=ToolParameter.ToolParameterForm.LLM, + required=True, + llm_description="The audio file to be converted.", + ) + ] + entity = ToolEntity( + identity=ToolIdentity( + author="langgenius", + name="asr", + label=I18nObject(en_US="Speech To Text"), + provider="audio", + ), + description=ToolDescription(human=I18nObject(en_US="Speech To Text"), llm="Convert audio file to text."), + parameters=parameters, + ) + runtime = ToolRuntime(tenant_id="tenant-1", user_id="user-1", credentials={}, runtime_parameters={}) + return FakeTool(entity=entity, runtime=runtime) + + +def _tts_tool() -> FakeTool: + parameters = [ + ToolParameter( + name="text", + label=I18nObject(en_US="Text"), + type=ToolParameter.ToolParameterType.STRING, + form=ToolParameter.ToolParameterForm.LLM, + required=True, + llm_description="The text to be converted.", + ), + ToolParameter( + name="model", + label=I18nObject(en_US="Model"), + type=ToolParameter.ToolParameterType.SELECT, + form=ToolParameter.ToolParameterForm.FORM, + required=True, + options=[ + {"value": "provider-a#model-a", "label": {"en_US": "model-a(provider-a)"}}, + {"value": "provider-b#model-b", "label": {"en_US": "model-b(provider-b)"}}, + ], + ), + ] + entity = ToolEntity( + identity=ToolIdentity( + author="langgenius", + name="tts", + label=I18nObject(en_US="Text To Speech"), + provider="audio", + ), + description=ToolDescription(human=I18nObject(en_US="Text To Speech"), llm="Convert text to audio file."), + parameters=parameters, + ) + runtime = ToolRuntime(tenant_id="tenant-1", user_id="user-1", credentials={}, runtime_parameters={}) + return FakeTool(entity=entity, runtime=runtime) + + def _build( builder: WorkflowAgentPluginToolsBuilder, tools: AgentSoulToolsConfig, @@ -157,6 +235,62 @@ def test_builds_dify_plugin_tools_layer_from_existing_tool_runtime(): assert runtime_provider.last_agent_tool.provider_type.value == "plugin" +def test_builds_dify_plugin_tool_with_file_llm_parameter(): + runtime_provider = FakeRuntimeProvider(_file_tool()) + builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=runtime_provider) + tools = AgentSoulToolsConfig.model_validate( + { + "dify_tools": [ + { + "provider_id": "audio", + "provider_type": "builtin", + "tool_name": "asr", + "credential_type": "unauthorized", + } + ] + } + ) + + result = _build(builder, tools) + + assert result is not None + prepared = result.tools[0] + assert prepared.tool_name == "asr" + assert prepared.runtime_parameters == {} + assert prepared.parameters[0].name == "audio_file" + assert prepared.parameters[0].type == "file" + # The public Agent backend DTO carries non-scalar tool inputs in + # ``parameters``; legacy JSON schema generation omits file fields. + assert prepared.parameters_json_schema == {"type": "object", "properties": {}, "required": []} + assert runtime_provider.last_allow_file_parameters is True + assert runtime_provider.last_use_default_for_missing_form_parameters is True + + +def test_builds_dify_plugin_tool_with_missing_required_select_default(): + runtime_provider = FakeRuntimeProvider(_tts_tool()) + builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=runtime_provider) + tools = AgentSoulToolsConfig.model_validate( + { + "dify_tools": [ + { + "provider_id": "audio", + "provider_type": "builtin", + "tool_name": "tts", + "credential_type": "unauthorized", + } + ] + } + ) + + result = _build(builder, tools) + + assert result is not None + prepared = result.tools[0] + assert prepared.tool_name == "tts" + assert prepared.runtime_parameters == {"model": "provider-a#model-a"} + assert runtime_provider.last_use_default_for_missing_form_parameters is True + + def test_rejects_duplicate_exposed_tool_names(): builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=FakeRuntimeProvider(_tool())) tools = AgentSoulToolsConfig.model_validate( @@ -437,3 +571,112 @@ def test_legacy_provider_name_and_tool_parameters_normalized(): assert tool.runtime_parameters == {"region": "us"} assert tool.credential_ref is not None assert tool.credential_ref.id == "credential-1" + + +# ── provider-level entries (tool_name omitted = all tools of the provider) ─── + + +def test_provider_level_entry_expands_to_all_tools(): + runtime_provider = FakeRuntimeProvider(_tool()) + listed: list[tuple[str, str]] = [] + + def lister(*, tenant_id: str, provider_id: str) -> list[str]: + listed.append((tenant_id, provider_id)) + return ["search", "image_search"] + + builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=runtime_provider, provider_tools_lister=lister) + tools = AgentSoulToolsConfig.model_validate( + {"dify_tools": [{"provider_id": "langgenius/search/search", "credential_type": "unauthorized"}]} + ) + + result = _build(builder, tools) + + assert result is not None + assert [tool.tool_name for tool in result.tools] == ["search", "image_search"] + assert listed == [("tenant-1", "langgenius/search/search")] + + +def test_explicit_tool_entry_wins_over_provider_expansion(): + builder = WorkflowAgentPluginToolsBuilder( + tool_runtime_provider=FakeRuntimeProvider(_tool()), + provider_tools_lister=lambda *, tenant_id, provider_id: ["search", "image_search"], + ) + tools = AgentSoulToolsConfig.model_validate( + { + "dify_tools": [ + {"provider_id": "langgenius/search/search", "credential_type": "unauthorized"}, + { + "provider_id": "langgenius/search/search", + "tool_name": "search", + "credential_type": "unauthorized", + "runtime_parameters": {"region": "eu"}, + }, + ] + } + ) + + result = _build(builder, tools) + + # the expansion skips "search" (explicit entry wins); no duplicate error + assert result is not None + assert sorted(tool.tool_name for tool in result.tools) == ["image_search", "search"] + + +def test_provider_level_entry_with_no_tools_maps_to_declaration_not_found(): + builder = WorkflowAgentPluginToolsBuilder( + tool_runtime_provider=FakeRuntimeProvider(_tool()), + provider_tools_lister=lambda *, tenant_id, provider_id: [], + ) + tools = AgentSoulToolsConfig.model_validate( + {"dify_tools": [{"provider_id": "langgenius/search/search", "credential_type": "unauthorized"}]} + ) + + with pytest.raises(WorkflowAgentPluginToolsBuildError) as exc_info: + _build(builder, tools) + assert exc_info.value.error_code == "agent_tool_declaration_not_found" + + +def test_provider_level_entry_unknown_provider_maps_to_declaration_not_found(): + from core.tools.errors import ToolProviderNotFoundError + + def lister(*, tenant_id: str, provider_id: str) -> list[str]: + raise ToolProviderNotFoundError("provider gone") + + builder = WorkflowAgentPluginToolsBuilder( + tool_runtime_provider=FakeRuntimeProvider(_tool()), provider_tools_lister=lister + ) + tools = AgentSoulToolsConfig.model_validate( + {"dify_tools": [{"provider_id": "langgenius/search/search", "credential_type": "unauthorized"}]} + ) + + with pytest.raises(WorkflowAgentPluginToolsBuildError) as exc_info: + _build(builder, tools) + assert exc_info.value.error_code == "agent_tool_declaration_not_found" + + +def test_list_provider_tool_names_reads_builtin_provider(monkeypatch): + """The default provider-tools lister maps ToolManager's provider controller + to the plain name list the expansion step consumes.""" + from types import SimpleNamespace + + from core.workflow.nodes.agent_v2 import plugin_tools_builder as module + + provider = SimpleNamespace( + get_tools=lambda: [ + SimpleNamespace(entity=SimpleNamespace(identity=SimpleNamespace(name="ddg_search"))), + SimpleNamespace(entity=SimpleNamespace(identity=SimpleNamespace(name="ddg_news"))), + ] + ) + captured: dict[str, str] = {} + + def fake_get_builtin_provider(provider_id, tenant_id): + captured["provider_id"] = provider_id + captured["tenant_id"] = tenant_id + return provider + + monkeypatch.setattr(module.ToolManager, "get_builtin_provider", staticmethod(fake_get_builtin_provider)) + + names = module._list_provider_tool_names(tenant_id="tenant-1", provider_id="langgenius/duckduckgo/duckduckgo") + + assert names == ["ddg_search", "ddg_news"] + assert captured == {"provider_id": "langgenius/duckduckgo/duckduckgo", "tenant_id": "tenant-1"} diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py index 80ef5cadfad..f402f851b8b 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py @@ -21,6 +21,9 @@ from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding from models.agent_config_entities import ( AgentSoulConfig, AgentSoulModelConfig, + DeclaredArrayItem, + DeclaredOutputChildConfig, + DeclaredOutputConfig, DeclaredOutputType, WorkflowNodeJobConfig, ) @@ -321,6 +324,7 @@ def test_build_shell_layer_config_accepts_legacy_fallback_keys(): "secret_refs": [ {"variable": "TOKEN", "credential_id": "credential-1"}, {"name": "API_KEY", "provider_credential_id": "credential-2"}, + {"name": "EDITABLE_TOKEN", "value": "credential-3"}, {"ref": "missing-name"}, ], }, @@ -330,9 +334,9 @@ def test_build_shell_layer_config_accepts_legacy_fallback_keys(): config = build_shell_layer_config(agent_soul).model_dump(mode="json") assert config["cli_tools"] == [ - {"name": "node", "install_commands": ["apt-get install -y nodejs"]}, - {"name": "python", "install_commands": ["pip install pytest"]}, - {"name": None, "install_commands": ["apk add git"]}, + {"name": "node", "install_commands": ["apt-get install -y nodejs"], "env": [], "secret_refs": []}, + {"name": "python", "install_commands": ["pip install pytest"], "env": [], "secret_refs": []}, + {"name": None, "install_commands": ["apk add git"], "env": [], "secret_refs": []}, ] assert config["env"] == [ {"name": "PROJECT_NAME", "value": "demo"}, @@ -341,6 +345,7 @@ def test_build_shell_layer_config_accepts_legacy_fallback_keys(): assert config["secret_refs"] == [ {"name": "TOKEN", "ref": "credential-1"}, {"name": "API_KEY", "ref": "credential-2"}, + {"name": "EDITABLE_TOKEN", "ref": "credential-3"}, ] assert config["sandbox"] is None @@ -353,7 +358,9 @@ def test_build_shell_layer_config_maps_typed_command_field(): config = build_shell_layer_config(agent_soul).model_dump(mode="json") - assert config["cli_tools"] == [{"name": "jq", "install_commands": ["apt-get install -y jq"]}] + assert config["cli_tools"] == [ + {"name": "jq", "install_commands": ["apt-get install -y jq"], "env": [], "secret_refs": []} + ] def test_build_shell_layer_config_skips_disabled_cli_tools(): @@ -371,7 +378,9 @@ def test_build_shell_layer_config_skips_disabled_cli_tools(): config = build_shell_layer_config(agent_soul).model_dump(mode="json") - assert config["cli_tools"] == [{"name": "jq", "install_commands": ["apt-get install -y jq"]}] + assert config["cli_tools"] == [ + {"name": "jq", "install_commands": ["apt-get install -y jq"], "env": [], "secret_refs": []} + ] def test_build_shell_layer_config_skips_unauthorized_or_unacknowledged_cli_tools(): @@ -397,8 +406,43 @@ def test_build_shell_layer_config_skips_unauthorized_or_unacknowledged_cli_tools config = build_shell_layer_config(agent_soul).model_dump(mode="json") assert config["cli_tools"] == [ - {"name": "jq", "install_commands": ["apt-get install -y jq"]}, - {"name": "accepted-risk", "install_commands": ["curl https://example.test/install.sh | sh"]}, + {"name": "jq", "install_commands": ["apt-get install -y jq"], "env": [], "secret_refs": []}, + { + "name": "accepted-risk", + "install_commands": ["curl https://example.test/install.sh | sh"], + "env": [], + "secret_refs": [], + }, + ] + + +def test_build_shell_layer_config_maps_cli_tool_scoped_env(): + agent_soul = AgentSoulConfig.model_validate( + { + "tools": { + "cli_tools": [ + { + "name": "github", + "command": "apt-get install -y gh", + "env": { + "variables": [{"name": "GH_HOST", "value": "github.com"}], + "secret_refs": [{"name": "GITHUB_TOKEN", "credential_id": "credential-1"}], + }, + } + ] + } + } + ) + + config = build_shell_layer_config(agent_soul).model_dump(mode="json") + + assert config["cli_tools"] == [ + { + "name": "github", + "install_commands": ["apt-get install -y gh"], + "env": [{"name": "GH_HOST", "value": "github.com"}], + "secret_refs": [{"name": "GITHUB_TOKEN", "ref": "credential-1"}], + } ] @@ -591,6 +635,40 @@ def test_array_output_emits_typed_items_per_array_item(): assert output_schema["required"] == ["tags"] +def test_nested_declared_output_emits_object_and_array_child_schema(): + profile_output = DeclaredOutputConfig( + name="profile", + type=DeclaredOutputType.OBJECT, + children=[ + DeclaredOutputChildConfig(name="email", type=DeclaredOutputType.STRING), + DeclaredOutputChildConfig( + name="nickname", + type=DeclaredOutputType.STRING, + required=False, + description="Optional display name", + ), + DeclaredOutputChildConfig( + name="addresses", + type=DeclaredOutputType.ARRAY, + array_item=DeclaredArrayItem( + type=DeclaredOutputType.OBJECT, + description="Address item", + children=[DeclaredOutputChildConfig(name="city", type=DeclaredOutputType.STRING)], + ), + ), + ], + ) + + schema = WorkflowAgentRuntimeRequestBuilder._schema_for_declared_output(profile_output) + + assert schema["properties"]["email"] == {"type": "string"} + assert schema["properties"]["nickname"] == {"type": "string", "description": "Optional display name"} + assert schema["properties"]["addresses"]["items"]["properties"]["city"] == {"type": "string"} + assert schema["properties"]["addresses"]["items"]["description"] == "Address item" + assert schema["properties"]["addresses"]["items"]["required"] == ["city"] + assert schema["required"] == ["email", "addresses"] + + def test_effective_declared_outputs_passthrough_when_user_declared(): """effective_declared_outputs() must return user-provided outputs verbatim when non-empty; only empty input gets PRD defaults injected.""" @@ -636,3 +714,157 @@ def test_mentions_expand_in_soul_and_job_prompts_without_token_leak(): # the value still rides the Workflow context block, not the job prompt assert "Previous result" in dumped["composition"]["layers"][2]["config"]["user"] assert "[§" not in json.dumps(dumped["composition"]["layers"][:3]) + + +# ── ENG-623: dify.drive declaration layer ───────────────────────────────────── + + +def _soul_with_drive_skill() -> AgentSoulConfig: + return AgentSoulConfig( + prompt={"system_prompt": "You are careful."}, + model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"), + skills_files={ + "skills": [ + { + "id": "abc123", + "name": "Tender Analyzer", + "description": "Parses RFPs.", + "skill_md_key": "tender-analyzer/SKILL.md", + "full_archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip", + }, + {"id": "legacy", "name": "Legacy Skill"}, # pre-standardization: no drive key + ], + "files": [ + {"name": "sample.pdf", "drive_key": "files/sample.pdf", "type": "application/pdf"}, + {"name": "plain-upload.pdf", "file_id": "upload-1"}, # not drive-backed + ], + }, + ) + + +def test_build_drive_layer_config_catalogs_only_drive_backed_refs(): + from core.workflow.nodes.agent_v2.runtime_request_builder import build_drive_layer_config + + config, warnings = build_drive_layer_config(_soul_with_drive_skill(), agent_id="agent-1") + + assert config is not None + assert config.drive_ref == "agent-agent-1" + assert [skill.skill_md_key for skill in config.skills] == ["tender-analyzer/SKILL.md"] + assert config.skills[0].archive_key == "tender-analyzer/.DIFY-SKILL-FULL.zip" + assert [file.key for file in config.files] == ["files/sample.pdf"] + assert [w["code"] for w in warnings] == ["skill_ref_dangling"] + assert "Legacy Skill" in warnings[0]["message"] + + +def test_build_drive_layer_config_skips_when_nothing_configured(): + from core.workflow.nodes.agent_v2.runtime_request_builder import build_drive_layer_config + + soul = AgentSoulConfig( + model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test") + ) + assert build_drive_layer_config(soul, agent_id="agent-1") == (None, []) + + +def test_build_drive_layer_config_requires_agent_identity(): + from core.workflow.nodes.agent_v2.runtime_request_builder import build_drive_layer_config + + config, warnings = build_drive_layer_config(_soul_with_drive_skill(), agent_id=None) + + assert config is None + assert [w["code"] for w in warnings] == ["skill_ref_dangling"] + + +def test_workflow_run_request_contains_drive_layer_when_flag_enabled(monkeypatch: pytest.MonkeyPatch): + """Contract test: locks the dify.drive composition shape against cross-package drift.""" + monkeypatch.setattr( + "core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True + ) + context = _context() + context.snapshot.config_snapshot = _soul_with_drive_skill() + + result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context) + + dumped = result.request.model_dump(mode="json") + layer_names = [layer["name"] for layer in dumped["composition"]["layers"]] + assert "drive" in layer_names + # injected right after execution_context, before history/llm + assert layer_names.index("drive") == layer_names.index("execution_context") + 1 + drive = next(layer for layer in dumped["composition"]["layers"] if layer["name"] == "drive") + assert drive["type"] == "dify.drive" + assert drive["config"]["drive_ref"] == "agent-agent-1" + assert drive["config"]["skills"] == [ + { + "name": "Tender Analyzer", + "description": "Parses RFPs.", + "skill_md_key": "tender-analyzer/SKILL.md", + "archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip", + } + ] + assert drive["config"]["files"] == [ + {"name": "sample.pdf", "key": "files/sample.pdf", "size": None, "mime_type": "application/pdf"} + ] + # the dangling legacy ref degraded to a warning instead of failing the run + warnings = result.metadata["runtime_support"]["unsupported_runtime_warnings"] + assert any(w["code"] == "skill_ref_dangling" for w in warnings) + # the drive layer is non-sensitive and must survive into persistable specs + from dify_agent.protocol import extract_runtime_layer_specs + + specs = extract_runtime_layer_specs(result.request.composition) + assert any(spec.name == "drive" and spec.type == "dify.drive" for spec in specs) + + +def test_workflow_run_request_has_no_drive_layer_when_flag_disabled(): + context = _context() + context.snapshot.config_snapshot = _soul_with_drive_skill() + + result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context) + + dumped = result.request.model_dump(mode="json") + assert all(layer["name"] != "drive" for layer in dumped["composition"]["layers"]) + warnings = result.metadata["runtime_support"]["unsupported_runtime_warnings"] + assert any(w["code"] == "drive_manifest_disabled" for w in warnings) + + +def test_build_drive_layer_config_all_refs_dangling_yields_no_config(): + from core.workflow.nodes.agent_v2.runtime_request_builder import build_drive_layer_config + + soul = AgentSoulConfig( + model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"), + skills_files={"skills": [{"id": "legacy", "name": "Legacy"}], "files": [{"name": "u.pdf", "file_id": "u1"}]}, + ) + config, warnings = build_drive_layer_config(soul, agent_id="agent-1") + assert config is None + assert [w["code"] for w in warnings] == ["skill_ref_dangling"] + + +# ── ENG-635: ask_human layer gating + feature manifest ─────────────────────── + + +def test_build_ask_human_layer_config_gated_on_human_contacts(): + from dify_agent.layers.ask_human import DifyAskHumanLayerConfig + + from core.workflow.nodes.agent_v2.runtime_request_builder import build_ask_human_layer_config + + # no human involvement configured -> tool stays off + assert build_ask_human_layer_config(AgentSoulConfig()) is None + + soul = AgentSoulConfig.model_validate( + {"human": {"contacts": [{"id": "c-1", "name": "David", "email": "d@acme.com", "channel": "email"}]}} + ) + config = build_ask_human_layer_config(soul) + assert isinstance(config, DifyAskHumanLayerConfig) + assert config.enabled is True + + +def test_feature_manifest_marks_human_supported_when_configured(): + from core.workflow.nodes.agent_v2.runtime_feature_manifest import build_runtime_feature_manifest + + soul = AgentSoulConfig.model_validate( + {"human": {"contacts": [{"id": "c-1", "name": "David", "email": "d@acme.com", "channel": "email"}]}} + ) + manifest = build_runtime_feature_manifest(soul) + assert "human" in manifest["supported"] + assert "human" not in manifest["reserved"] + assert manifest["reserved_status"]["human"] == "supported_by_ask_human_hitl" + # configured human no longer produces a "not executed" warning + assert all("human" not in w["section"] for w in manifest["unsupported_runtime_warnings"]) diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_session_cleanup_layer.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_session_cleanup_layer.py index 11e77f43caa..bb7947ce656 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_session_cleanup_layer.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_session_cleanup_layer.py @@ -7,7 +7,7 @@ from agenton.compositor.schemas import LayerSessionSnapshot from agenton.layers.base import LifecycleState from dify_agent.protocol import CancelRunRequest, RunEvent, RunStatusResponse -from clients.agent_backend import AgentBackendRunRequestBuilder, CleanupLayerSpec, FakeAgentBackendRunClient +from clients.agent_backend import AgentBackendRunRequestBuilder, FakeAgentBackendRunClient, RuntimeLayerSpec from clients.agent_backend.errors import AgentBackendHTTPError from core.workflow.nodes.agent_v2.session_cleanup_layer import WorkflowAgentSessionCleanupLayer from core.workflow.nodes.agent_v2.session_store import ( @@ -40,7 +40,7 @@ def _layer_snapshot(name: str) -> LayerSessionSnapshot: def _stored_session(scope: WorkflowAgentSessionScope, *, index: int = 1) -> StoredWorkflowAgentSession: """A typical stored session with prompt + execution_context + history + llm specs. - The LLM layer is *not* in ``composition_layer_specs`` because the cleanup + The LLM layer is *not* in ``runtime_layer_specs`` because the cleanup contract excludes credential-bearing plugin layers, but it *is* present in the saved snapshot so the layer's filter logic gets exercised. """ @@ -55,10 +55,10 @@ def _stored_session(scope: WorkflowAgentSessionScope, *, index: int = 1) -> Stor ] ), backend_run_id=f"agent-run-{index}", - composition_layer_specs=[ - CleanupLayerSpec(name="workflow_node_job_prompt", type="plain.prompt", config={"prefix": "ok"}), - CleanupLayerSpec(name="execution_context", type="dify.execution_context", config={"tenant_id": "t"}), - CleanupLayerSpec(name="history", type="pydantic_ai.history"), + runtime_layer_specs=[ + RuntimeLayerSpec(name="workflow_node_job_prompt", type="plain.prompt", config={"prefix": "ok"}), + RuntimeLayerSpec(name="execution_context", type="dify.execution_context", config={"tenant_id": "t"}), + RuntimeLayerSpec(name="history", type="pydantic_ai.history"), ], ) @@ -268,7 +268,7 @@ def test_cleanup_layer_marks_cleaned_locally_when_http_cleanup_disabled(): def test_cleanup_layer_skips_sessions_without_persisted_specs(): """Backwards-compatible safety net: a row written before A.1 landed has - no composition_layer_specs, so cleanup would unavoidably hit the snapshot- + no runtime_layer_specs, so cleanup would unavoidably hit the snapshot- validation trap. The layer must skip such rows instead of issuing a doomed request.""" scope = _default_scope() @@ -276,7 +276,7 @@ def test_cleanup_layer_skips_sessions_without_persisted_specs(): scope=scope, session_snapshot=CompositorSessionSnapshot(layers=[_layer_snapshot("history")]), backend_run_id="legacy-run", - composition_layer_specs=[], + runtime_layer_specs=[], ) session_store = FakeSessionStore(stored=[legacy_session]) agent_backend_client = _WaitableFakeAgentBackendRunClient() diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_session_store.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_session_store.py index 1c2d0d13019..a57b2adc168 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_session_store.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_session_store.py @@ -14,9 +14,9 @@ import pytest from agenton.compositor import CompositorSessionSnapshot from agenton.compositor.schemas import LayerSessionSnapshot from agenton.layers.base import LifecycleState +from dify_agent.protocol import RuntimeLayerSpec from sqlalchemy import delete -from clients.agent_backend.request_builder import CleanupLayerSpec from core.db.session_factory import session_factory from core.workflow.nodes.agent_v2.session_store import ( StoredWorkflowAgentSession, @@ -52,10 +52,10 @@ def _snapshot(messages: int = 1) -> CompositorSessionSnapshot: ) -def _specs() -> list[CleanupLayerSpec]: +def _specs() -> list[RuntimeLayerSpec]: return [ - CleanupLayerSpec(name="workflow_node_job_prompt", type="plain.prompt", config={"prefix": "ok"}), - CleanupLayerSpec(name="history", type="pydantic_ai.history"), + RuntimeLayerSpec(name="workflow_node_job_prompt", type="plain.prompt", config={"prefix": "ok"}), + RuntimeLayerSpec(name="history", type="pydantic_ai.history"), ] @@ -85,15 +85,17 @@ def test_load_active_snapshot_returns_none_when_no_row_matches(): def test_save_active_snapshot_creates_row_and_load_round_trips(): store = WorkflowAgentRuntimeSessionStore() snapshot = _snapshot(messages=2) - store.save_active_snapshot( - scope=_scope(), backend_run_id="run-1", snapshot=snapshot, composition_layer_specs=_specs() - ) + store.save_active_snapshot(scope=_scope(), backend_run_id="run-1", snapshot=snapshot, runtime_layer_specs=_specs()) loaded = store.load_active_snapshot(_scope()) assert loaded is not None assert len(loaded.layers) == 1 assert loaded.layers[0].name == "history" assert loaded.layers[0].runtime_state["messages"] == snapshot.layers[0].runtime_state["messages"] + with session_factory.create_session() as session: + row = session.query(WorkflowAgentRuntimeSession).one() + assert "workflow_node_job_prompt" in row.composition_layer_specs + assert "history" in row.composition_layer_specs def test_save_active_snapshot_skips_when_workflow_run_id_missing(): @@ -103,7 +105,7 @@ def test_save_active_snapshot_skips_when_workflow_run_id_missing(): scope=_scope(workflow_run_id=None), backend_run_id="run-skipped", snapshot=_snapshot(), - composition_layer_specs=_specs(), + runtime_layer_specs=_specs(), ) with session_factory.create_session() as session: assert session.query(WorkflowAgentRuntimeSession).count() == 0 @@ -116,7 +118,7 @@ def test_save_active_snapshot_skips_when_snapshot_missing(): scope=_scope(), backend_run_id="run-empty", snapshot=None, - composition_layer_specs=_specs(), + runtime_layer_specs=_specs(), ) with session_factory.create_session() as session: assert session.query(WorkflowAgentRuntimeSession).count() == 0 @@ -129,14 +131,14 @@ def test_save_active_snapshot_updates_existing_row_on_re_entry(): scope=_scope(), backend_run_id="run-1", snapshot=_snapshot(messages=1), - composition_layer_specs=_specs(), + runtime_layer_specs=_specs(), ) # Second call with new snapshot + backend_run_id. store.save_active_snapshot( scope=_scope(), backend_run_id="run-2", snapshot=_snapshot(messages=2), - composition_layer_specs=_specs(), + runtime_layer_specs=_specs(), ) with session_factory.create_session() as session: @@ -154,7 +156,7 @@ def test_save_active_snapshot_resurrects_cleaned_row(): scope=_scope(), backend_run_id="run-1", snapshot=_snapshot(), - composition_layer_specs=_specs(), + runtime_layer_specs=_specs(), ) store.mark_cleaned(scope=_scope(), backend_run_id="cleanup-1") # Save again — the existing row was CLEANED; should be revived. @@ -162,7 +164,7 @@ def test_save_active_snapshot_resurrects_cleaned_row(): scope=_scope(), backend_run_id="run-2", snapshot=_snapshot(messages=3), - composition_layer_specs=_specs(), + runtime_layer_specs=_specs(), ) with session_factory.create_session() as session: @@ -179,13 +181,13 @@ def test_list_active_sessions_returns_specs_and_snapshot(): scope=_scope(binding_id="binding-A"), backend_run_id="run-A", snapshot=_snapshot(), - composition_layer_specs=_specs(), + runtime_layer_specs=_specs(), ) store.save_active_snapshot( scope=_scope(binding_id="binding-B"), backend_run_id="run-B", snapshot=_snapshot(messages=2), - composition_layer_specs=_specs(), + runtime_layer_specs=_specs(), ) listed = store.list_active_sessions(workflow_run_id="wfr-1") @@ -193,8 +195,8 @@ def test_list_active_sessions_returns_specs_and_snapshot(): by_run = {s.backend_run_id: s for s in listed} assert isinstance(by_run["run-A"], StoredWorkflowAgentSession) # Specs round-trip through pydantic TypeAdapter — ensure deserialize works. - assert by_run["run-A"].composition_layer_specs[0].name == "workflow_node_job_prompt" - assert by_run["run-A"].composition_layer_specs[1].type == "pydantic_ai.history" + assert by_run["run-A"].runtime_layer_specs[0].name == "workflow_node_job_prompt" + assert by_run["run-A"].runtime_layer_specs[1].type == "pydantic_ai.history" # node_execution_id default-replaces NULL with "" when the DB column is None. assert by_run["run-A"].scope.node_execution_id == "node-exec-1" @@ -205,13 +207,13 @@ def test_list_active_sessions_skips_cleaned_rows(): scope=_scope(binding_id="binding-A"), backend_run_id="run-A", snapshot=_snapshot(), - composition_layer_specs=_specs(), + runtime_layer_specs=_specs(), ) store.save_active_snapshot( scope=_scope(binding_id="binding-B"), backend_run_id="run-B", snapshot=_snapshot(), - composition_layer_specs=_specs(), + runtime_layer_specs=_specs(), ) store.mark_cleaned(scope=_scope(binding_id="binding-A"), backend_run_id="cleanup-A") @@ -220,7 +222,7 @@ def test_list_active_sessions_skips_cleaned_rows(): def test_list_active_sessions_handles_legacy_rows_without_specs(): - """Rows persisted before composition_layer_specs landed have an empty string.""" + """Rows persisted before runtime_layer_specs landed have an empty string.""" # Insert a legacy-shape row directly: empty specs payload simulates a row # written before the spec persistence feature landed in A.1. store = WorkflowAgentRuntimeSessionStore() @@ -228,11 +230,11 @@ def test_list_active_sessions_handles_legacy_rows_without_specs(): scope=_scope(), backend_run_id="run-legacy", snapshot=_snapshot(), - composition_layer_specs=[], + runtime_layer_specs=[], ) listed = store.list_active_sessions(workflow_run_id="wfr-1") assert len(listed) == 1 - assert listed[0].composition_layer_specs == [] + assert listed[0].runtime_layer_specs == [] def test_mark_cleaned_sets_status_and_cleaned_at_with_backend_run_id(): @@ -241,7 +243,7 @@ def test_mark_cleaned_sets_status_and_cleaned_at_with_backend_run_id(): scope=_scope(), backend_run_id="run-1", snapshot=_snapshot(), - composition_layer_specs=_specs(), + runtime_layer_specs=_specs(), ) store.mark_cleaned(scope=_scope(), backend_run_id="cleanup-1") @@ -259,7 +261,7 @@ def test_mark_cleaned_preserves_existing_backend_run_id_when_none_given(): scope=_scope(), backend_run_id="run-1", snapshot=_snapshot(), - composition_layer_specs=_specs(), + runtime_layer_specs=_specs(), ) store.mark_cleaned(scope=_scope(), backend_run_id=None) diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py index 6280d6d2895..440bd49e5c0 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py @@ -8,7 +8,7 @@ from core.workflow.nodes.agent_v2.validators import ( WorkflowAgentNodeValidationError, WorkflowAgentNodeValidator, ) -from models.agent import Agent, AgentConfigSnapshot, AgentStatus, WorkflowAgentNodeBinding +from models.agent import Agent, AgentConfigSnapshot, AgentStatus, WorkflowAgentBindingType, WorkflowAgentNodeBinding from models.agent_config_entities import AgentSoulConfig, AgentSoulModelConfig, WorkflowNodeJobConfig from models.workflow import Workflow @@ -111,6 +111,24 @@ def test_publish_validation_accepts_upstream_previous_output_ref(): ) +def test_publish_validation_uses_active_snapshot_for_roster_agent(): + node_job = WorkflowNodeJobConfig() + binding = _binding(node_job) + binding.binding_type = WorkflowAgentBindingType.ROSTER_AGENT + binding.current_snapshot_id = "old-snapshot" + agent = _agent() + agent.active_config_snapshot_id = "active-snapshot" + snapshot = _snapshot() + snapshot.id = "active-snapshot" + session = Mock() + session.scalar.side_effect = [binding, agent, snapshot] + + WorkflowAgentNodeValidator.validate_published_workflow( + session=session, + workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + ) + + def test_publish_validation_rejects_non_upstream_previous_output_ref(): node_job = WorkflowNodeJobConfig.model_validate( {"previous_node_output_refs": [{"node_id": "later-node", "output": "text"}]} @@ -191,6 +209,64 @@ def test_publish_validation_rejects_missing_agent_soul_model(): ) +def test_publish_validation_dedupes_provider_level_tool_entries(): + """Provider-level entries (tool_name omitted = all tools of the provider) + dedupe per provider; one provider-level + one explicit tool entry for the + same provider is fine (the runtime builder reconciles those).""" + node_job = WorkflowNodeJobConfig.model_validate({}) + snapshot = _snapshot() + snapshot.config_snapshot = AgentSoulConfig( + model=AgentSoulModelConfig( + plugin_id="langgenius/openai", + model_provider="openai", + model="gpt-test", + ), + tools={ + "dify_tools": [ + {"provider_id": "langgenius/duckduckgo/duckduckgo", "credential_type": "unauthorized"}, + {"provider_id": "langgenius/duckduckgo/duckduckgo", "credential_type": "unauthorized"}, + ] + }, + ) + session = Mock() + session.scalar.side_effect = [_binding(node_job), _agent(), snapshot] + + with pytest.raises(WorkflowAgentNodeValidationError, match="duplicate Dify Plugin Tool"): + WorkflowAgentNodeValidator.validate_published_workflow( + session=session, + workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + ) + + +def test_publish_validation_accepts_provider_level_plus_explicit_tool_entry(): + node_job = WorkflowNodeJobConfig.model_validate({}) + snapshot = _snapshot() + snapshot.config_snapshot = AgentSoulConfig( + model=AgentSoulModelConfig( + plugin_id="langgenius/openai", + model_provider="openai", + model="gpt-test", + ), + tools={ + "dify_tools": [ + {"provider_id": "langgenius/duckduckgo/duckduckgo", "credential_type": "unauthorized"}, + { + "provider_id": "langgenius/duckduckgo/duckduckgo", + "tool_name": "ddg_search", + "credential_type": "unauthorized", + }, + ] + }, + ) + session = Mock() + session.scalar.side_effect = [_binding(node_job), _agent(), snapshot] + + WorkflowAgentNodeValidator.validate_published_workflow( + session=session, + workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + ) + + def test_publish_validation_rejects_duplicate_cli_tool_names(): node_job = WorkflowNodeJobConfig.model_validate({}) snapshot = _snapshot() @@ -277,6 +353,60 @@ def test_publish_validation_rejects_unauthorized_secret_ref(): ) +def test_publish_validation_rejects_cli_tool_scoped_env_conflicts_and_unauthorized_secret_refs(): + node_job = WorkflowNodeJobConfig.model_validate({}) + snapshot = _snapshot() + snapshot.config_snapshot = AgentSoulConfig( + model=AgentSoulModelConfig( + plugin_id="langgenius/openai", + model_provider="openai", + model="gpt-test", + ), + env={"variables": [{"name": "TOKEN", "value": "agent"}]}, + tools={ + "cli_tools": [ + { + "name": "github", + "env": {"secret_refs": [{"name": "TOKEN", "id": "credential-1"}]}, + } + ] + }, + ) + session = Mock() + session.scalar.side_effect = [_binding(node_job), _agent(), snapshot] + + with pytest.raises(WorkflowAgentNodeValidationError, match="duplicate env/secret name TOKEN"): + WorkflowAgentNodeValidator.validate_published_workflow( + session=session, + workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + ) + + snapshot.config_snapshot = AgentSoulConfig( + model=AgentSoulModelConfig( + plugin_id="langgenius/openai", + model_provider="openai", + model="gpt-test", + ), + tools={ + "cli_tools": [ + { + "name": "github", + "env": { + "secret_refs": [{"name": "GITHUB_TOKEN", "id": "credential-1", "permission_status": "denied"}] + }, + } + ] + }, + ) + session.scalar.side_effect = [_binding(node_job), _agent(), snapshot] + + with pytest.raises(WorkflowAgentNodeValidationError, match="unauthorized secret reference GITHUB_TOKEN"): + WorkflowAgentNodeValidator.validate_published_workflow( + session=session, + workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + ) + + def test_publish_validation_rejects_missing_previous_node(): node_job = WorkflowNodeJobConfig.model_validate( {"previous_node_output_refs": [{"node_id": "missing-node", "output": "text"}]} diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py b/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py index 1d68610e7f4..231000817a8 100644 --- a/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py +++ b/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py @@ -30,7 +30,7 @@ from core.workflow.human_input_adapter import ( WebAppDeliveryMethod, _WebAppDeliveryConfig, ) -from core.workflow.node_runtime import DifyFileReferenceFactory, DifyHumanInputNodeRuntime +from core.workflow.node_runtime import DifyHumanInputNodeRuntime from core.workflow.system_variables import build_system_variables from graphon.entities import GraphInitParams from graphon.file import File, FileTransferMethod, FileType @@ -171,12 +171,13 @@ def _build_human_input_node( typed_node_data = ( node_data if isinstance(node_data, HumanInputNodeData) else HumanInputNodeData.model_validate(node_data) ) + runtime._file_reference_factory = _TestFileReferenceFactory() # type: ignore[attr-defined] return HumanInputNode( node_id=node_id, data=typed_node_data, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, - file_reference_factory=DifyFileReferenceFactory(graph_init_params.run_context), + file_reference_factory=_TestFileReferenceFactory(), runtime=runtime, ) diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py b/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py index 763e1eecfd7..9ee7b79cc42 100644 --- a/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py +++ b/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py @@ -4,7 +4,7 @@ from types import SimpleNamespace from typing import Any from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, InvokeFrom, UserFrom -from core.workflow.node_runtime import DifyFileReferenceFactory, DifyHumanInputNodeRuntime +from core.workflow.node_runtime import DifyHumanInputNodeRuntime from core.workflow.system_variables import default_system_variables from graphon.entities import GraphInitParams from graphon.enums import BuiltinNodeTypes @@ -67,14 +67,16 @@ def _create_human_input_node( if isinstance(config["data"], HumanInputNodeData) else HumanInputNodeData.model_validate(config["data"]) ) + runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context) + runtime._file_reference_factory = _TestFileReferenceFactory() # type: ignore[attr-defined] return HumanInputNode( node_id=config["id"], data=node_data, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, form_repository=repo, - file_reference_factory=DifyFileReferenceFactory(graph_init_params.run_context), - runtime=DifyHumanInputNodeRuntime(graph_init_params.run_context), + file_reference_factory=_TestFileReferenceFactory(), + runtime=runtime, ) diff --git a/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py b/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py index fb50723402d..909de623d82 100644 --- a/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py @@ -76,6 +76,7 @@ from graphon.nodes.llm.node import ( _render_jinja2_message, ) from graphon.nodes.llm.protocols import CredentialsProvider, ModelFactory +from graphon.nodes.llm.reasoning import split_reasoning from graphon.nodes.llm.runtime_protocols import PromptMessageSerializerProtocol from graphon.runtime import GraphRuntimeState, VariablePool from graphon.template_rendering import TemplateRenderError @@ -345,6 +346,62 @@ def test_fetch_model_config_hydrates_model_instance_runtime_settings(model_confi provider_model.raise_for_status.assert_called_once() +def test_fetch_model_config_reuses_validated_provider_model_from_dify_credentials_provider( + model_config: ModelConfigWithCredentialsEntity, +): + mock_provider_manager = mock.MagicMock() + mock_configurations = mock.MagicMock() + mock_provider_configuration = mock.MagicMock() + mock_provider_model = mock.MagicMock() + mock_model_factory = mock.MagicMock(spec=DifyModelFactory) + + mock_configurations.get.return_value = mock_provider_configuration + mock_provider_configuration.get_provider_model.return_value = mock_provider_model + mock_provider_configuration.get_current_credentials.return_value = {"api_key": "test"} + mock_provider_manager.get_configurations.return_value = mock_configurations + + run_context = DifyRunContext( + tenant_id="tenant", + app_id="app", + user_id="user", + user_from=UserFrom.ACCOUNT, + invoke_from=InvokeFrom.DEBUGGER, + ) + credentials_provider = DifyCredentialsProvider( + run_context=run_context, + provider_manager=mock_provider_manager, + ) + + model_instance = mock.MagicMock( + model_type_instance=model_config.provider_model_bundle.model_type_instance, + provider_model_bundle=model_config.provider_model_bundle, + ) + mock_model_factory.init_model_instance.return_value = model_instance + + with mock.patch.object( + model_instance.model_type_instance.__class__, + "get_model_schema", + return_value=model_config.model_schema, + autospec=True, + ): + fetch_model_config( + node_data_model=ModelConfig( + provider="openai", + name="gpt-3.5-turbo", + mode="chat", + completion_params={}, + ), + credentials_provider=credentials_provider, + model_factory=mock_model_factory, + ) + + mock_provider_configuration.get_provider_model.assert_called_once_with( + model_type=ModelType.LLM, + model="gpt-3.5-turbo", + ) + mock_provider_model.raise_for_status.assert_called_once() + + def test_dify_model_access_adapters_call_managers(): mock_provider_manager = mock.MagicMock() mock_model_manager = mock.MagicMock() @@ -1215,7 +1272,10 @@ class TestLLMNodeSaveMultiModalImageOutput: assert llm_node._file_outputs == [mock_file] assert file == mock_file mock_file_saver.save_binary_string.assert_called_once_with( - data=b"test-data", mime_type="image/png", file_type=FileType.IMAGE + data=b"test-data", + mime_type="image/png", + file_type=FileType.IMAGE, + extension_override=".png", ) def test_llm_node_save_url_output(self, llm_node_for_multimodal: tuple[LLMNode, LLMFileSaver]): @@ -1249,8 +1309,9 @@ class TestLLMNodeSaveMultiModalImageOutput: def test_llm_node_image_file_to_markdown(llm_node: LLMNode): mock_file = mock.MagicMock(spec=File) + mock_file.type = FileType.IMAGE mock_file.generate_url.return_value = "https://example.com/image.png" - markdown = llm_node._image_file_to_markdown(mock_file) + markdown = llm_node._saved_file_to_markdown(mock_file) assert markdown == "![](https://example.com/image.png)" @@ -1322,6 +1383,7 @@ class TestSaveMultimodalOutputAndConvertResultToMarkdown: data=image_raw_data, mime_type="image/png", file_type=FileType.IMAGE, + extension_override=".png", ) assert mock_saved_file in llm_node._file_outputs @@ -1369,7 +1431,7 @@ class TestReasoningFormat: Dify is an open source AI platform. """ - clean_text, reasoning_content = LLMNode._split_reasoning(text_with_think, "separated") + clean_text, reasoning_content = split_reasoning(text_with_think, "separated") assert clean_text == "Dify is an open source AI platform." assert reasoning_content == "I need to explain what Dify is. It's an open source AI platform." @@ -1382,7 +1444,7 @@ class TestReasoningFormat: Dify is an open source AI platform. """ - clean_text, reasoning_content = LLMNode._split_reasoning(text_with_think, "tagged") + clean_text, reasoning_content = split_reasoning(text_with_think, "tagged") # Original text unchanged assert clean_text == text_with_think @@ -1394,7 +1456,7 @@ class TestReasoningFormat: text_without_think = "This is a simple answer without any thinking blocks." - clean_text, reasoning_content = LLMNode._split_reasoning(text_without_think, "separated") + clean_text, reasoning_content = split_reasoning(text_without_think, "separated") assert clean_text == text_without_think assert reasoning_content == "" @@ -1415,7 +1477,7 @@ class TestReasoningFormat: I need to explain what Dify is. It's an open source AI platform. Dify is an open source AI platform. """ - clean_text, reasoning_content = LLMNode._split_reasoning(text_with_think, node_data.reasoning_format) + clean_text, reasoning_content = split_reasoning(text_with_think, node_data.reasoning_format) assert clean_text == text_with_think assert reasoning_content == "" @@ -1513,10 +1575,10 @@ def test_handle_invoke_result_streaming_collects_text_metrics_and_structured_out ) assert events[0] == first_chunk - assert events[1] == StreamChunkEvent(selector=["node-1", "text"], chunk="plan", is_final=False) - assert events[2] == StreamChunkEvent(selector=["node-1", "text"], chunk="answer", is_final=False) - completed = events[3] + assert events[1] == StreamChunkEvent(selector=["node-1", "text"], chunk="answer", is_final=False) + + completed = events[2] assert isinstance(completed, ModelInvokeCompletedEvent) assert completed.text == "answer" assert completed.reasoning_content == "plan" diff --git a/api/tests/unit_tests/core/workflow/test_node_runtime.py b/api/tests/unit_tests/core/workflow/test_node_runtime.py index 216ce513f8e..bdccea478d9 100644 --- a/api/tests/unit_tests/core/workflow/test_node_runtime.py +++ b/api/tests/unit_tests/core/workflow/test_node_runtime.py @@ -625,12 +625,43 @@ def test_dify_human_input_runtime_create_form_filters_debugger_delivery_methods( params = repository.create_form.call_args.args[0] assert params.node_id == "human-input-node" assert params.workflow_execution_id == "workflow-execution-id" + # No conversation_id_getter wired -> a pure workflow run leaves it None. + assert params.conversation_id is None assert params.display_in_ui is True assert len(params.delivery_methods) == 1 assert params.delivery_methods[0].type == DeliveryMethodType.EMAIL assert params.delivery_methods[0].config.recipients.items[0].reference_id == "user-id" +def test_dify_human_input_runtime_create_form_tags_conversation_id_for_chatflow() -> None: + # ENG-635 (review): a chatflow (advanced-chat) run carries a conversation, so its + # Human Input form is tagged with BOTH its workflow run and its conversation — + # making the form queryable per conversation without changing resume routing. + repository = MagicMock() + repository.create_form.return_value = sentinel.form + node_data = HumanInputNodeData( + title="Human Input", + delivery_methods=[WebAppDeliveryMethod(enabled=True, config=_WebAppDeliveryConfig())], + ) + runtime = DifyHumanInputNodeRuntime( + _build_run_context(), + workflow_execution_id_getter=lambda: "workflow-execution-id", + conversation_id_getter=lambda: "conversation-id", + form_repository=repository, + ) + + runtime.create_form( + node_id="human-input-node", + node_data=node_data, + rendered_content="

Rendered

", + resolved_default_values={}, + ) + + params = repository.create_form.call_args.args[0] + assert params.workflow_execution_id == "workflow-execution-id" + assert params.conversation_id == "conversation-id" + + def test_dify_human_input_runtime_preserves_webapp_delivery_for_web_invocations() -> None: repository = MagicMock() repository.create_form.return_value = sentinel.form diff --git a/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py b/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py index a57cdd13379..3ccfdf76f5a 100644 --- a/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py +++ b/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py @@ -338,6 +338,52 @@ class TestWorkflowEntryRun: assert list(entry.run()) == [] + def test_iter_dify_graph_engine_events_applies_response_stream_filter(self): + graph_engine = MagicMock() + graph_engine.run.return_value = iter([sentinel.raw_event]) + + with ( + patch.object( + workflow_entry.GraphEventFilterContext, + "from_engine", + return_value=sentinel.filter_context, + ) as from_engine, + patch.object( + workflow_entry, + "ResponseStreamFilter", + return_value=sentinel.response_stream_filter, + ) as response_stream_filter_cls, + patch.object( + workflow_entry, + "filter_graph_events", + return_value=iter([sentinel.filtered_event]), + ) as filter_graph_events, + ): + events = list(workflow_entry.iter_dify_graph_engine_events(graph_engine)) + + assert events == [sentinel.filtered_event] + from_engine.assert_called_once_with(graph_engine) + response_stream_filter_cls.assert_called_once_with() + filter_graph_events.assert_called_once_with( + graph_engine.run.return_value, + context=sentinel.filter_context, + filters=[sentinel.response_stream_filter], + ) + + def test_run_delegates_to_dify_event_iterator(self): + entry = object.__new__(workflow_entry.WorkflowEntry) + entry.graph_engine = sentinel.graph_engine + + with patch.object( + workflow_entry, + "iter_dify_graph_engine_events", + return_value=iter([sentinel.filtered_event]), + ) as iter_dify_graph_engine_events: + events = list(entry.run()) + + assert events == [sentinel.filtered_event] + iter_dify_graph_engine_events.assert_called_once_with(sentinel.graph_engine) + def test_run_emits_failed_event_for_unexpected_errors(self): entry = object.__new__(workflow_entry.WorkflowEntry) entry.graph_engine = MagicMock() diff --git a/api/tests/unit_tests/enterprise/telemetry/test_exporter.py b/api/tests/unit_tests/enterprise/telemetry/test_exporter.py index 6bdae139237..674a2026131 100644 --- a/api/tests/unit_tests/enterprise/telemetry/test_exporter.py +++ b/api/tests/unit_tests/enterprise/telemetry/test_exporter.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from datetime import UTC, datetime from types import SimpleNamespace from unittest.mock import MagicMock, patch @@ -88,11 +89,10 @@ def test_api_key_and_custom_headers_merge(mock_metric_exporter: MagicMock, mock_ assert ("x-custom", "foo") in headers -@patch("enterprise.telemetry.exporter.logger") @patch("enterprise.telemetry.exporter.GRPCSpanExporter") @patch("enterprise.telemetry.exporter.GRPCMetricExporter") def test_api_key_overrides_conflicting_header( - mock_metric_exporter: MagicMock, mock_span_exporter: MagicMock, mock_logger: MagicMock + mock_metric_exporter: MagicMock, mock_span_exporter: MagicMock, caplog ) -> None: """Test that API key overrides conflicting authorization header and logs warning.""" mock_config = SimpleNamespace( @@ -105,7 +105,8 @@ def test_api_key_overrides_conflicting_header( ENTERPRISE_OTLP_API_KEY="test-key", ) - EnterpriseExporter(mock_config) + with caplog.at_level(logging.WARNING, logger="enterprise.telemetry.exporter"): + EnterpriseExporter(mock_config) # Verify Bearer header takes precedence assert mock_span_exporter.call_args is not None @@ -116,11 +117,8 @@ def test_api_key_overrides_conflicting_header( assert ("authorization", "Basic old") not in headers # Verify warning was logged - mock_logger.warning.assert_called_once() - assert mock_logger.warning.call_args is not None - warning_message = mock_logger.warning.call_args[0][0] - assert "ENTERPRISE_OTLP_API_KEY is set" in warning_message - assert "authorization" in warning_message + assert "ENTERPRISE_OTLP_API_KEY is set" in caplog.text + assert "authorization" in caplog.text @patch("enterprise.telemetry.exporter.GRPCSpanExporter") @@ -535,33 +533,33 @@ def test_export_span_cross_workflow_parent_context() -> None: assert kwargs["context"] is not None -@patch("enterprise.telemetry.exporter.logger") -def test_export_span_logs_exception_on_error(mock_logger: MagicMock) -> None: +def test_export_span_logs_exception_on_error(caplog) -> None: """If the span block raises, the exception is logged and context is still cleared.""" exporter, mock_tracer, mock_span = _make_exporter_with_mock_tracer() mock_tracer.start_as_current_span.side_effect = RuntimeError("boom") - exporter.export_span(name="bad.span", attributes={}) # must not raise + with caplog.at_level(logging.ERROR, logger="enterprise.telemetry.exporter"): + exporter.export_span(name="bad.span", attributes={}) # must not raise - mock_logger.exception.assert_called_once() - assert "bad.span" in mock_logger.exception.call_args[0][1] + assert "Failed to export span" in caplog.text + assert "bad.span" in caplog.text -@patch("enterprise.telemetry.exporter.logger") -def test_export_span_invalid_trace_correlation_logs_warning(mock_logger: MagicMock) -> None: +def test_export_span_invalid_trace_correlation_logs_warning(caplog) -> None: """Invalid UUID for trace_correlation_override triggers a warning log.""" exporter, mock_tracer, mock_span = _make_exporter_with_mock_tracer() parent_uid = "987fbc97-4bed-5078-9f07-9141ba07c9f3" - exporter.export_span( - name="link.span", - attributes={}, - correlation_id="not-a-valid-uuid", - parent_span_id_source=parent_uid, - ) + with caplog.at_level(logging.WARNING, logger="enterprise.telemetry.exporter"): + exporter.export_span( + name="link.span", + attributes={}, + correlation_id="not-a-valid-uuid", + parent_span_id_source=parent_uid, + ) - mock_logger.warning.assert_called() + assert "Invalid trace correlation UUID for cross-workflow link" in caplog.text # --------------------------------------------------------------------------- diff --git a/api/tests/unit_tests/enterprise/telemetry/test_telemetry_log.py b/api/tests/unit_tests/enterprise/telemetry/test_telemetry_log.py index 0edd0ace27e..791c665d93c 100644 --- a/api/tests/unit_tests/enterprise/telemetry/test_telemetry_log.py +++ b/api/tests/unit_tests/enterprise/telemetry/test_telemetry_log.py @@ -2,8 +2,10 @@ from __future__ import annotations +import logging import uuid -from unittest.mock import MagicMock, patch + +import pytest # --------------------------------------------------------------------------- # compute_trace_id_hex @@ -135,134 +137,128 @@ class TestEmitTelemetryLog: compute_trace_id_hex.cache_clear() compute_span_id_hex.cache_clear() - @patch("enterprise.telemetry.telemetry_log.logger") - def test_logs_info_with_event_name_and_signal(self, mock_logger: MagicMock) -> None: + def test_logs_info_with_event_name_and_signal(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_telemetry_log - mock_logger.isEnabledFor.return_value = True + with caplog.at_level(logging.INFO, logger="dify.telemetry"): + emit_telemetry_log( + event_name="dify.workflow.run", + attributes={"tenant_id": "t1"}, + signal="metric_only", + ) - emit_telemetry_log( - event_name="dify.workflow.run", - attributes={"tenant_id": "t1"}, - signal="metric_only", - ) + assert len(caplog.records) == 1 + record = caplog.records[0] + assert record.levelno == logging.INFO + assert record.getMessage() == "telemetry.metric_only" + assert hasattr(record, "attributes") + assert record.attributes["dify.event.name"] == "dify.workflow.run" + assert record.attributes["dify.event.signal"] == "metric_only" + assert record.attributes["tenant_id"] == "t1" - mock_logger.info.assert_called_once() - args, kwargs = mock_logger.info.call_args - assert args[0] == "telemetry.%s" - assert args[1] == "metric_only" - extra = kwargs["extra"] - assert extra["attributes"]["dify.event.name"] == "dify.workflow.run" - assert extra["attributes"]["dify.event.signal"] == "metric_only" - - @patch("enterprise.telemetry.telemetry_log.logger") - def test_no_log_when_info_disabled(self, mock_logger: MagicMock) -> None: + def test_no_log_when_info_disabled(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_telemetry_log - mock_logger.isEnabledFor.return_value = False + with caplog.at_level(logging.WARNING, logger="dify.telemetry"): + emit_telemetry_log(event_name="dify.workflow.run", attributes={}) - emit_telemetry_log(event_name="dify.workflow.run", attributes={}) + assert len(caplog.records) == 0 - mock_logger.info.assert_not_called() - - @patch("enterprise.telemetry.telemetry_log.logger") - def test_trace_id_added_to_extra_when_valid_uuid(self, mock_logger: MagicMock) -> None: + def test_trace_id_added_to_extra_when_valid_uuid(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_telemetry_log - mock_logger.isEnabledFor.return_value = True uid = "123e4567-e89b-12d3-a456-426614174000" - emit_telemetry_log(event_name="test.event", attributes={}, trace_id_source=uid) + with caplog.at_level(logging.INFO, logger="dify.telemetry"): + emit_telemetry_log(event_name="test.event", attributes={}, trace_id_source=uid) - extra = mock_logger.info.call_args.kwargs["extra"] - assert "trace_id" in extra - assert len(extra["trace_id"]) == 32 + assert len(caplog.records) == 1 + record = caplog.records[0] + assert hasattr(record, "trace_id") + assert len(record.trace_id) == 32 - @patch("enterprise.telemetry.telemetry_log.logger") - def test_trace_id_absent_when_invalid_source(self, mock_logger: MagicMock) -> None: + def test_trace_id_absent_when_invalid_source(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_telemetry_log - mock_logger.isEnabledFor.return_value = True + with caplog.at_level(logging.INFO, logger="dify.telemetry"): + emit_telemetry_log(event_name="test.event", attributes={}, trace_id_source="bad-id") - emit_telemetry_log(event_name="test.event", attributes={}, trace_id_source="bad-id") + assert len(caplog.records) == 1 + record = caplog.records[0] + assert not hasattr(record, "trace_id") - extra = mock_logger.info.call_args.kwargs["extra"] - assert "trace_id" not in extra - - @patch("enterprise.telemetry.telemetry_log.logger") - def test_span_id_added_to_extra_when_valid_uuid(self, mock_logger: MagicMock) -> None: + def test_span_id_added_to_extra_when_valid_uuid(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_telemetry_log - mock_logger.isEnabledFor.return_value = True uid = "123e4567-e89b-12d3-a456-426614174000" - emit_telemetry_log(event_name="test.event", attributes={}, span_id_source=uid) + with caplog.at_level(logging.INFO, logger="dify.telemetry"): + emit_telemetry_log(event_name="test.event", attributes={}, span_id_source=uid) - extra = mock_logger.info.call_args.kwargs["extra"] - assert "span_id" in extra - assert len(extra["span_id"]) == 16 + assert len(caplog.records) == 1 + record = caplog.records[0] + assert hasattr(record, "span_id") + assert len(record.span_id) == 16 - @patch("enterprise.telemetry.telemetry_log.logger") - def test_tenant_id_added_when_provided(self, mock_logger: MagicMock) -> None: + def test_tenant_id_added_when_provided(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_telemetry_log - mock_logger.isEnabledFor.return_value = True + with caplog.at_level(logging.INFO, logger="dify.telemetry"): + emit_telemetry_log(event_name="test.event", attributes={}, tenant_id="tenant-99") - emit_telemetry_log(event_name="test.event", attributes={}, tenant_id="tenant-99") + assert len(caplog.records) == 1 + record = caplog.records[0] + assert hasattr(record, "tenant_id") + assert record.tenant_id == "tenant-99" - extra = mock_logger.info.call_args.kwargs["extra"] - assert extra["tenant_id"] == "tenant-99" - - @patch("enterprise.telemetry.telemetry_log.logger") - def test_user_id_added_when_provided(self, mock_logger: MagicMock) -> None: + def test_user_id_added_when_provided(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_telemetry_log - mock_logger.isEnabledFor.return_value = True + with caplog.at_level(logging.INFO, logger="dify.telemetry"): + emit_telemetry_log(event_name="test.event", attributes={}, user_id="user-42") - emit_telemetry_log(event_name="test.event", attributes={}, user_id="user-42") + assert len(caplog.records) == 1 + record = caplog.records[0] + assert hasattr(record, "user_id") + assert record.user_id == "user-42" - extra = mock_logger.info.call_args.kwargs["extra"] - assert extra["user_id"] == "user-42" - - @patch("enterprise.telemetry.telemetry_log.logger") - def test_tenant_and_user_id_absent_when_not_provided(self, mock_logger: MagicMock) -> None: + def test_tenant_and_user_id_absent_when_not_provided(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_telemetry_log - mock_logger.isEnabledFor.return_value = True + with caplog.at_level(logging.INFO, logger="dify.telemetry"): + emit_telemetry_log(event_name="test.event", attributes={}) - emit_telemetry_log(event_name="test.event", attributes={}) + assert len(caplog.records) == 1 + record = caplog.records[0] + assert not hasattr(record, "tenant_id") + assert not hasattr(record, "user_id") - extra = mock_logger.info.call_args.kwargs["extra"] - assert "tenant_id" not in extra - assert "user_id" not in extra - - @patch("enterprise.telemetry.telemetry_log.logger") - def test_caller_attributes_merged_into_attrs(self, mock_logger: MagicMock) -> None: + def test_caller_attributes_merged_into_attrs(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_telemetry_log - mock_logger.isEnabledFor.return_value = True + with caplog.at_level(logging.INFO, logger="dify.telemetry"): + emit_telemetry_log( + event_name="dify.node.run", + attributes={"node_type": "code", "elapsed": 0.5}, + ) - emit_telemetry_log( - event_name="dify.node.run", - attributes={"node_type": "code", "elapsed": 0.5}, - ) + assert len(caplog.records) == 1 + record = caplog.records[0] + assert hasattr(record, "attributes") + assert record.attributes["node_type"] == "code" + assert record.attributes["elapsed"] == 0.5 - extra = mock_logger.info.call_args.kwargs["extra"] - assert extra["attributes"]["node_type"] == "code" - assert extra["attributes"]["elapsed"] == 0.5 - - @patch("enterprise.telemetry.telemetry_log.logger") - def test_signal_span_detail_forwarded(self, mock_logger: MagicMock) -> None: + def test_signal_span_detail_forwarded(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_telemetry_log - mock_logger.isEnabledFor.return_value = True + with caplog.at_level(logging.INFO, logger="dify.telemetry"): + emit_telemetry_log(event_name="test.event", attributes={}, signal="span_detail") - emit_telemetry_log(event_name="test.event", attributes={}, signal="span_detail") - - args = mock_logger.info.call_args[0] - assert args[1] == "span_detail" - extra = mock_logger.info.call_args.kwargs["extra"] - assert extra["attributes"]["dify.event.signal"] == "span_detail" + assert len(caplog.records) == 1 + record = caplog.records[0] + assert record.getMessage() == "telemetry.span_detail" + assert hasattr(record, "attributes") + assert record.attributes["dify.event.signal"] == "span_detail" # --------------------------------------------------------------------------- @@ -277,51 +273,50 @@ class TestEmitMetricOnlyEvent: compute_trace_id_hex.cache_clear() compute_span_id_hex.cache_clear() - @patch("enterprise.telemetry.telemetry_log.logger") - def test_delegates_to_emit_telemetry_log_with_metric_only_signal(self, mock_logger: MagicMock) -> None: + def test_delegates_to_emit_telemetry_log_with_metric_only_signal(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_metric_only_event - mock_logger.isEnabledFor.return_value = True + with caplog.at_level(logging.INFO, logger="dify.telemetry"): + emit_metric_only_event( + event_name="dify.app.created", + attributes={"app_id": "app-1"}, + tenant_id="t1", + user_id="u1", + ) - emit_metric_only_event( - event_name="dify.app.created", - attributes={"app_id": "app-1"}, - tenant_id="t1", - user_id="u1", - ) + assert len(caplog.records) == 1 + record = caplog.records[0] + assert hasattr(record, "attributes") + assert record.attributes["dify.event.signal"] == "metric_only" + assert record.attributes["dify.event.name"] == "dify.app.created" + assert record.attributes["app_id"] == "app-1" + assert hasattr(record, "tenant_id") + assert record.tenant_id == "t1" + assert hasattr(record, "user_id") + assert record.user_id == "u1" - mock_logger.info.assert_called_once() - extra = mock_logger.info.call_args.kwargs["extra"] - assert extra["attributes"]["dify.event.signal"] == "metric_only" - assert extra["attributes"]["dify.event.name"] == "dify.app.created" - assert extra["attributes"]["app_id"] == "app-1" - assert extra["tenant_id"] == "t1" - assert extra["user_id"] == "u1" - - @patch("enterprise.telemetry.telemetry_log.logger") - def test_trace_and_span_ids_passed_through(self, mock_logger: MagicMock) -> None: + def test_trace_and_span_ids_passed_through(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_metric_only_event - mock_logger.isEnabledFor.return_value = True uid = "123e4567-e89b-12d3-a456-426614174000" - emit_metric_only_event( - event_name="dify.workflow.run", - attributes={}, - trace_id_source=uid, - span_id_source=uid, - ) + with caplog.at_level(logging.INFO, logger="dify.telemetry"): + emit_metric_only_event( + event_name="dify.workflow.run", + attributes={}, + trace_id_source=uid, + span_id_source=uid, + ) - extra = mock_logger.info.call_args.kwargs["extra"] - assert "trace_id" in extra - assert "span_id" in extra + assert len(caplog.records) == 1 + record = caplog.records[0] + assert hasattr(record, "trace_id") + assert hasattr(record, "span_id") - @patch("enterprise.telemetry.telemetry_log.logger") - def test_no_log_emitted_when_logger_disabled(self, mock_logger: MagicMock) -> None: + def test_no_log_emitted_when_logger_disabled(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_metric_only_event - mock_logger.isEnabledFor.return_value = False + with caplog.at_level(logging.WARNING, logger="dify.telemetry"): + emit_metric_only_event(event_name="dify.workflow.run", attributes={}) - emit_metric_only_event(event_name="dify.workflow.run", attributes={}) - - mock_logger.info.assert_not_called() + assert len(caplog.records) == 0 diff --git a/api/tests/unit_tests/libs/test_external_api.py b/api/tests/unit_tests/libs/test_external_api.py index 5135970bcc5..7ebdf5f60eb 100644 --- a/api/tests/unit_tests/libs/test_external_api.py +++ b/api/tests/unit_tests/libs/test_external_api.py @@ -6,6 +6,7 @@ from constants import COOKIE_NAME_ACCESS_TOKEN, COOKIE_NAME_CSRF_TOKEN, COOKIE_N from core.errors.error import AppInvokeQuotaExceededError from libs.exception import BaseHTTPException from libs.external_api import ExternalApi +from libs.rate_limit import _BearerRateLimited def _create_api_app(): @@ -58,6 +59,13 @@ def _create_api_app(): e.description = {"field": "is required"} raise e + # The per-token rate limit raises this; ExternalApi must carry its Retry-After header to the + # wire (it reads getattr(e, "headers"), not werkzeug's get_headers()). + @api.route("/rate-limited") + class RateLimited(Resource): + def get(self): + raise _BearerRateLimited(23) + app.register_blueprint(bp, url_prefix="/api") return app @@ -115,6 +123,16 @@ def test_external_api_param_mapping_and_quota(): assert res.status_code in (400, 429) +def test_external_api_carries_exception_headers_to_429_response(): + # Locks the coupling enforce_bearer_rate_limit relies on: handle_error reads getattr(e, + # "headers") and puts it on the response, so Retry-After reaches the wire. + app = _create_api_app() + res = app.test_client().get("/api/rate-limited") + assert res.status_code == 429 + assert res.headers["Retry-After"] == "23" + assert (res.get_json() or {})["status"] == 429 + + def test_unauthorized_and_force_logout_clears_cookies(): """Test that UnauthorizedAndForceLogout error clears auth cookies""" diff --git a/api/tests/unit_tests/libs/test_login.py b/api/tests/unit_tests/libs/test_login.py index 2bf22128448..8b32e448d64 100644 --- a/api/tests/unit_tests/libs/test_login.py +++ b/api/tests/unit_tests/libs/test_login.py @@ -1,15 +1,15 @@ -from types import SimpleNamespace +from typing import cast from unittest.mock import MagicMock import pytest from flask import Flask, Response, g -from flask_login import UserMixin from pytest_mock import MockerFixture +from werkzeug.exceptions import Unauthorized import libs.login as login_module from extensions.ext_login import DifyLoginManager from libs.login import current_user -from models.account import Account +from models.account import Account, Tenant @pytest.fixture @@ -23,7 +23,7 @@ def protected_view(): return _protected_view -class MockUser(UserMixin): +class MockUser: """Mock user class for testing.""" def __init__(self, id: str, is_authenticated: bool = True): @@ -35,6 +35,22 @@ class MockUser(UserMixin): return self._is_authenticated +class LoginManagerStub: + def __init__(self, unauthorized_response: Response) -> None: + self._unauthorized_response = unauthorized_response + + def unauthorized(self) -> Response: + return self._unauthorized_response + + +def _login_manager(app: Flask) -> DifyLoginManager: + return cast(DifyLoginManager, app.__dict__["login_manager"]) + + +def _unauthorized_mock(app: Flask) -> MagicMock: + return cast(MagicMock, _login_manager(app).unauthorized) + + @pytest.fixture def login_app(mocker: MockerFixture) -> Flask: app = Flask(__name__) @@ -95,7 +111,7 @@ class TestLoginRequired: assert result == "Protected content" resolve_user.assert_called_once_with() - login_app.login_manager.unauthorized.assert_not_called() + _unauthorized_mock(login_app).assert_not_called() @pytest.mark.parametrize( ("resolved_user", "description"), @@ -120,11 +136,11 @@ class TestLoginRequired: with login_app.test_request_context(): result = protected_view() - assert result is login_app.login_manager.unauthorized.return_value, description + assert result is _unauthorized_mock(login_app).return_value, description assert isinstance(result, Response) assert result.status_code == 401 resolve_user.assert_called_once_with() - login_app.login_manager.unauthorized.assert_called_once_with() + _unauthorized_mock(login_app).assert_called_once_with() csrf_check.assert_not_called() def test_unauthorized_access_propagates_response_object( @@ -138,9 +154,7 @@ class TestLoginRequired: """Test that unauthorized responses are propagated as Flask Response objects.""" resolve_user = resolve_current_user(None) response = Response("Unauthorized", status=401, content_type="application/json") - mocker.patch.object( - login_module, "_get_login_manager", return_value=SimpleNamespace(unauthorized=lambda: response) - ) + mocker.patch.object(login_module, "_get_login_manager", return_value=LoginManagerStub(response)) with login_app.test_request_context(): result = protected_view() @@ -177,7 +191,7 @@ class TestLoginRequired: assert result == "Protected content" resolve_user.assert_not_called() csrf_check.assert_not_called() - login_app.login_manager.unauthorized.assert_not_called() + _unauthorized_mock(login_app).assert_not_called() class TestGetUser: @@ -191,6 +205,7 @@ class TestGetUser: g._login_user = mock_user user = login_module._get_user() assert user == mock_user + assert user is not None assert user.id == "test_user" def test_get_user_loads_user_if_not_in_g(self, login_app: Flask, mocker: MockerFixture): @@ -201,7 +216,7 @@ class TestGetUser: g._login_user = mock_user load_user = mocker.patch.object( - login_app.login_manager, + _login_manager(login_app), "load_user_from_request_context", side_effect=load_user_from_request_context, ) @@ -244,7 +259,9 @@ class TestCurrentAccountWithTenant: def test_returns_account_and_tenant_id(self, mocker: MockerFixture): account = Account(name="Test User", email="test@example.com") - account._current_tenant = SimpleNamespace(id="tenant-123") + tenant = Tenant(name="Test Tenant") + tenant.id = "tenant-123" + account._current_tenant = tenant current_user_proxy = mocker.Mock() current_user_proxy._get_current_object.return_value = account mocker.patch.object(login_module, "current_user", new=current_user_proxy) @@ -267,3 +284,58 @@ class TestCurrentAccountWithTenant: with pytest.raises(AssertionError, match="tenant information should be loaded"): login_module.current_account_with_tenant() + + +class TestCurrentAccountWithTenantOptional: + """Test cases for optional current account resolution.""" + + def test_returns_account_and_tenant_id_for_authenticated_account(self, mocker: MockerFixture) -> None: + account = Account(name="Test User", email="test@example.com") + tenant = Tenant(name="Test Tenant") + tenant.id = "tenant-123" + account._current_tenant = tenant + mocker.patch.object(login_module, "_resolve_current_user", return_value=account) + + user, tenant_id = login_module.current_account_with_tenant_optional() + + assert user is account + assert tenant_id == "tenant-123" + + def test_returns_none_pair_when_request_loader_raises_unauthorized(self, mocker: MockerFixture) -> None: + mocker.patch.object(login_module, "_resolve_current_user", side_effect=Unauthorized()) + + user, tenant_id = login_module.current_account_with_tenant_optional() + + assert user is None + assert tenant_id is None + + def test_returns_none_pair_when_resolved_user_is_not_account(self, mocker: MockerFixture) -> None: + mocker.patch.object(login_module, "_resolve_current_user", return_value=MockUser("end-user")) + + user, tenant_id = login_module.current_account_with_tenant_optional() + + assert user is None + assert tenant_id is None + + +class TestResolveTenantIdFallback: + """Test cases for tenant-only fallback helper.""" + + def test_returns_provided_tenant_id_without_current_user_lookup(self, mocker: MockerFixture) -> None: + current_account_with_tenant = mocker.patch.object(login_module, "current_account_with_tenant") + + tenant_id = login_module.resolve_tenant_id_fallback("tenant-123") + + assert tenant_id == "tenant-123" + current_account_with_tenant.assert_not_called() + + def test_falls_back_to_current_account_tenant(self, mocker: MockerFixture) -> None: + account = Account(name="Test User", email="test@example.com") + tenant = Tenant(name="Test Tenant") + tenant.id = "tenant-123" + account._current_tenant = tenant + mocker.patch.object(login_module, "current_account_with_tenant", return_value=(account, tenant.id)) + + tenant_id = login_module.resolve_tenant_id_fallback() + + assert tenant_id == "tenant-123" diff --git a/api/tests/unit_tests/libs/test_rate_limit_bearer.py b/api/tests/unit_tests/libs/test_rate_limit_bearer.py index b204575ccb8..62363f5f600 100644 --- a/api/tests/unit_tests/libs/test_rate_limit_bearer.py +++ b/api/tests/unit_tests/libs/test_rate_limit_bearer.py @@ -11,6 +11,8 @@ from werkzeug.exceptions import TooManyRequests from libs.helper import RateLimiter from libs.rate_limit import ( LIMIT_BEARER_PER_TOKEN, + RateLimit, + RateLimitScope, enforce_bearer_rate_limit, ) @@ -67,8 +69,17 @@ def test_enforce_bearer_rate_limit_raises_429_with_retry_after(mock_build): mock_build.return_value = limiter with pytest.raises(TooManyRequests) as exc: enforce_bearer_rate_limit("hash-1") - headers = dict(exc.value.get_response().headers) - assert headers.get("Retry-After") == "23" - body = exc.value.get_response().get_json() or {} - assert body.get("error") == "rate_limited" - assert body.get("retry_after_ms") == 23000 + # Header-only TooManyRequests: the canonical ErrorBody (code "too_many_requests") is built + # later by the openapi formatter; here we only assert the advisory header rides along. + assert dict(exc.value.headers).get("Retry-After") == "23" + + +@patch("libs.rate_limit._build_limiter") +def test_enforce_bearer_rate_limit_disabled_when_limit_is_zero(mock_build, monkeypatch): + # 0 disables the limit — short-circuit before building/consulting a limiter. + monkeypatch.setattr( + "libs.rate_limit.LIMIT_BEARER_PER_TOKEN", + RateLimit(limit=0, window=timedelta(minutes=1), scopes=(RateLimitScope.TOKEN_ID,)), + ) + enforce_bearer_rate_limit("hash-1") + mock_build.assert_not_called() diff --git a/api/tests/unit_tests/libs/test_workspace_permission.py b/api/tests/unit_tests/libs/test_workspace_permission.py index 48d9b351a4d..93e69e11c4b 100644 --- a/api/tests/unit_tests/libs/test_workspace_permission.py +++ b/api/tests/unit_tests/libs/test_workspace_permission.py @@ -1,3 +1,4 @@ +import logging from unittest.mock import Mock, patch import pytest @@ -124,10 +125,9 @@ class TestWorkspacePermissionHelper: mock_enterprise_service.WorkspacePermissionService.get_permission.assert_called_once_with("test-workspace-id") - @patch("libs.workspace_permission.logger") @patch("libs.workspace_permission.EnterpriseService") @patch("libs.workspace_permission.dify_config") - def test_enterprise_service_error_fails_open(self, mock_config, mock_enterprise_service, mock_logger): + def test_enterprise_service_error_fails_open(self, mock_config, mock_enterprise_service, caplog): """On enterprise service error, should fail-open (allow) and log error.""" mock_config.ENTERPRISE_ENABLED = True @@ -135,8 +135,7 @@ class TestWorkspacePermissionHelper: mock_enterprise_service.WorkspacePermissionService.get_permission.side_effect = Exception("Service unavailable") # Should not raise (fail-open) - check_workspace_member_invite_permission("test-workspace-id") + with caplog.at_level(logging.ERROR, logger="libs.workspace_permission"): + check_workspace_member_invite_permission("test-workspace-id") - # Should log the error - mock_logger.exception.assert_called_once() - assert "Failed to check workspace invite permission" in str(mock_logger.exception.call_args) + assert "Failed to check workspace invite permission for test-workspace-id" in caplog.text diff --git a/api/tests/unit_tests/models/test_agent.py b/api/tests/unit_tests/models/test_agent.py index 2efc64ac866..aabbd4df300 100644 --- a/api/tests/unit_tests/models/test_agent.py +++ b/api/tests/unit_tests/models/test_agent.py @@ -27,6 +27,7 @@ def test_agent_enums_match_prd_boundaries(): assert AgentIconType.EMOJI.value == "emoji" assert AgentScope.ROSTER.value == "roster" assert AgentScope.WORKFLOW_ONLY.value == "workflow_only" + assert AgentSource.ROSTER.value == "roster" assert AgentSource.AGENT_APP.value == "agent_app" assert AgentSource.WORKFLOW.value == "workflow" assert AgentStatus.ACTIVE.value == "active" diff --git a/api/tests/unit_tests/models/test_agent_config_entities.py b/api/tests/unit_tests/models/test_agent_config_entities.py index 51e51fb6d46..5538a1981de 100644 --- a/api/tests/unit_tests/models/test_agent_config_entities.py +++ b/api/tests/unit_tests/models/test_agent_config_entities.py @@ -1,7 +1,12 @@ import pytest from core.workflow.file_reference import build_file_reference -from models.agent_config_entities import DeclaredOutputConfig, DeclaredOutputType +from models.agent_config_entities import ( + DeclaredArrayItem, + DeclaredOutputChildConfig, + DeclaredOutputConfig, + DeclaredOutputType, +) def test_file_default_value_accepts_canonical_reference_mapping() -> None: @@ -92,3 +97,90 @@ def test_array_file_default_value_rejects_legacy_item_shape() -> None: }, } ) + + +def test_declared_array_item_rejects_nested_arrays_and_non_object_children() -> None: + with pytest.raises(ValueError, match="nested arrays"): + DeclaredArrayItem(type=DeclaredOutputType.ARRAY) + + with pytest.raises(ValueError, match="array_item.children"): + DeclaredArrayItem( + type=DeclaredOutputType.STRING, + children=[DeclaredOutputChildConfig(name="label", type=DeclaredOutputType.STRING)], + ) + + +def test_declared_output_child_validates_shape_and_defaults() -> None: + file_child = DeclaredOutputChildConfig(name="report", type=DeclaredOutputType.FILE) + assert file_child.file is not None + + array_child = DeclaredOutputChildConfig(name="items", type=DeclaredOutputType.ARRAY) + assert array_child.array_item is not None + assert array_child.array_item.type == DeclaredOutputType.OBJECT + + with pytest.raises(ValueError, match="output child name"): + DeclaredOutputChildConfig(name="bad-name", type=DeclaredOutputType.STRING) + + with pytest.raises(ValueError, match="file metadata"): + DeclaredOutputChildConfig(name="title", type=DeclaredOutputType.STRING, file={}) + + with pytest.raises(ValueError, match="array_item is only allowed"): + DeclaredOutputChildConfig( + name="title", + type=DeclaredOutputType.STRING, + array_item={"type": DeclaredOutputType.STRING}, + ) + + with pytest.raises(ValueError, match="children is only allowed"): + DeclaredOutputChildConfig( + name="title", + type=DeclaredOutputType.STRING, + children=[DeclaredOutputChildConfig(name="label", type=DeclaredOutputType.STRING)], + ) + + +def test_declared_output_validates_shape_and_defaults() -> None: + file_output = DeclaredOutputConfig(name="report", type=DeclaredOutputType.FILE) + assert file_output.file is not None + + array_output = DeclaredOutputConfig(name="items", type=DeclaredOutputType.ARRAY) + assert array_output.array_item is not None + assert array_output.array_item.type == DeclaredOutputType.OBJECT + + default_failure_strategy = DeclaredOutputConfig.model_validate( + {"name": "summary", "type": "string", "failure_strategy": None} + ) + assert default_failure_strategy.failure_strategy.on_failure == "stop" + + with pytest.raises(ValueError, match="output name"): + DeclaredOutputConfig(name="bad-name", type=DeclaredOutputType.STRING) + + with pytest.raises(ValueError, match="file metadata"): + DeclaredOutputConfig(name="summary", type=DeclaredOutputType.STRING, file={}) + + with pytest.raises(ValueError, match="array_item is only allowed"): + DeclaredOutputConfig( + name="summary", + type=DeclaredOutputType.STRING, + array_item={"type": DeclaredOutputType.STRING}, + ) + + with pytest.raises(ValueError, match="children is only allowed"): + DeclaredOutputConfig( + name="summary", + type=DeclaredOutputType.STRING, + children=[DeclaredOutputChildConfig(name="title", type=DeclaredOutputType.STRING)], + ) + + with pytest.raises(ValueError, match="output check is only allowed"): + DeclaredOutputConfig.model_validate( + { + "name": "summary", + "type": "string", + "check": { + "enabled": True, + "prompt": "Compare output", + "benchmark_file_ref": {"name": "expected.pdf"}, + }, + } + ) diff --git a/api/tests/unit_tests/services/agent/test_agent_composer_entities.py b/api/tests/unit_tests/services/agent/test_agent_composer_entities.py index dbdf37a9053..089a5c74f3a 100644 --- a/api/tests/unit_tests/services/agent/test_agent_composer_entities.py +++ b/api/tests/unit_tests/services/agent/test_agent_composer_entities.py @@ -51,6 +51,19 @@ def test_locked_workflow_soul_rejects_soul_changes(): ComposerConfigValidator.validate_save_payload(payload) +def test_locked_workflow_node_job_only_allows_inline_soul_payload(): + payload = ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.WORKFLOW, + "save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY, + "soul_lock": {"locked": True}, + "agent_soul": {"prompt": {"system_prompt": "changed"}}, + } + ) + + ComposerConfigValidator.validate_save_payload(payload) + + def test_agent_app_soul_allows_app_features_and_variables(): payload = ComposerSavePayload.model_validate( { diff --git a/api/tests/unit_tests/services/agent/test_agent_observability_service.py b/api/tests/unit_tests/services/agent/test_agent_observability_service.py new file mode 100644 index 00000000000..1ce8edad788 --- /dev/null +++ b/api/tests/unit_tests/services/agent/test_agent_observability_service.py @@ -0,0 +1,123 @@ +from datetime import UTC, datetime +from decimal import Decimal +from types import SimpleNamespace + +import pytest + +from core.app.entities.app_invoke_entities import InvokeFrom +from models.enums import ConversationFromSource, MessageStatus +from services.agent.observability_service import AgentObservabilityService + + +def test_resolve_source_accepts_frontend_aliases() -> None: + assert AgentObservabilityService.resolve_source(None) is None + assert AgentObservabilityService.resolve_source("all") is None + assert AgentObservabilityService.resolve_source("console") == InvokeFrom.EXPLORE + assert AgentObservabilityService.resolve_source("api") == InvokeFrom.SERVICE_API + assert AgentObservabilityService.resolve_source("web_app") == InvokeFrom.WEB_APP + + with pytest.raises(ValueError, match="Unsupported source"): + AgentObservabilityService.resolve_source("unknown") + + +def test_serialize_log_message_returns_frontend_log_shape() -> None: + created_at = datetime(2026, 6, 17, 1, 2, 3, tzinfo=UTC) + updated_at = datetime(2026, 6, 17, 1, 3, 3, tzinfo=UTC) + message = SimpleNamespace( + id="message-1", + conversation_id="conversation-1", + query="hello", + answer="hi", + error=None, + status=MessageStatus.NORMAL, + invoke_from=InvokeFrom.EXPLORE, + from_source=ConversationFromSource.CONSOLE, + from_end_user_id=None, + from_account_id="account-1", + message_tokens=3, + answer_tokens=4, + total_price=Decimal("0.0001"), + currency="USD", + provider_response_latency=1.25, + created_at=created_at, + updated_at=updated_at, + ) + conversation = SimpleNamespace(name="Debug conversation") + + payload = AgentObservabilityService.serialize_log_message(message, conversation) # type: ignore[arg-type] + + assert payload == { + "id": "message-1", + "message_id": "message-1", + "conversation_id": "conversation-1", + "conversation_name": "Debug conversation", + "query": "hello", + "answer": "hi", + "status": "success", + "error": None, + "source": "explore", + "from_source": "console", + "from_end_user_id": None, + "from_account_id": "account-1", + "message_tokens": 3, + "answer_tokens": 4, + "total_tokens": 7, + "total_price": "0.0001", + "currency": "USD", + "latency": 1.25, + "created_at": int(created_at.timestamp()), + "updated_at": int(updated_at.timestamp()), + } + + +def test_build_charts_and_summary_match_monitoring_metrics() -> None: + rows = [ + { + "date": "2026-06-16", + "message_count": 2, + "conversation_count": 1, + "end_user_count": 1, + "token_count": 30, + "total_price": Decimal("0.003"), + "avg_latency": 1.5, + "latency_sum": 3, + "answer_tokens": 12, + "like_count": 1, + }, + { + "date": "2026-06-17", + "message_count": 1, + "conversation_count": 1, + "end_user_count": 1, + "token_count": 20, + "total_price": Decimal("0.002"), + "avg_latency": 2, + "latency_sum": 2, + "answer_tokens": 8, + "like_count": 1, + }, + ] + + charts = AgentObservabilityService._build_charts(rows) + summary = AgentObservabilityService._build_summary(rows) + + assert charts["token_usage"] == [ + {"date": "2026-06-16", "token_count": 30, "total_price": "0.003", "currency": "USD"}, + {"date": "2026-06-17", "token_count": 20, "total_price": "0.002", "currency": "USD"}, + ] + assert charts["average_response_time"] == [ + {"date": "2026-06-16", "latency": 1500.0}, + {"date": "2026-06-17", "latency": 2000.0}, + ] + assert summary == { + "total_messages": 3, + "total_conversations": 2, + "total_end_users": 2, + "total_tokens": 50, + "total_price": "0.005", + "currency": "USD", + "average_session_interactions": 1.5, + "average_response_time": 1666.6667, + "tokens_per_second": 4.0, + "user_satisfaction_rate": 66.67, + } diff --git a/api/tests/unit_tests/services/agent/test_agent_services.py b/api/tests/unit_tests/services/agent/test_agent_services.py index ed7240128d4..fc85719883e 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -1,3 +1,4 @@ +import json from datetime import UTC, datetime from types import SimpleNamespace @@ -14,11 +15,23 @@ from models.agent import ( WorkflowAgentBindingType, WorkflowAgentNodeBinding, ) +from models.agent_config_entities import ( + AgentFileRefConfig, + DeclaredArrayItem, + DeclaredOutputChildConfig, + DeclaredOutputConfig, + DeclaredOutputType, + WorkflowNodeJobConfig, +) +from models.workflow import Workflow from services.agent import composer_service, roster_service +from services.agent.agent_soul_state import agent_soul_has_model from services.agent.composer_service import AgentComposerService from services.agent.composer_validator import ComposerConfigValidator from services.agent.errors import InvalidComposerConfigError from services.agent.roster_service import AgentRosterService +from services.agent.workflow_publish_service import WorkflowAgentPublishService +from services.app_service import AppListParams, AppService from services.entities.agent_entities import AgentSoulConfig, ComposerSavePayload, ComposerSaveStrategy, ComposerVariant @@ -35,6 +48,7 @@ class FakeSession: self._scalars = list(scalars or []) self._scalar = list(scalar or []) self.added = [] + self.deleted = [] self.commits = 0 self.flushes = 0 self.rollbacks = 0 @@ -52,6 +66,9 @@ class FakeSession: def add(self, value): self.added.append(value) + def delete(self, value): + self.deleted.append(value) + def flush(self): self.flushes += 1 for index, value in enumerate(self.added, start=1): @@ -65,6 +82,23 @@ class FakeSession: self.rollbacks += 1 +def _agent_soul_with_model() -> AgentSoulConfig: + return AgentSoulConfig.model_validate( + { + "model": { + "plugin_id": "langgenius/openai/openai", + "model_provider": "openai", + "model": "gpt-4o", + } + } + ) + + +def test_agent_soul_has_model(): + assert agent_soul_has_model(_agent_soul_with_model()) is True + assert agent_soul_has_model(AgentSoulConfig()) is False + + def test_load_workflow_composer_returns_empty_state(monkeypatch): monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1")) monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: None) @@ -80,14 +114,22 @@ def test_load_workflow_composer_returns_empty_state(monkeypatch): effective = result["effective_declared_outputs"] assert [o["name"] for o in effective] == ["text", "files", "json"] files_output = next(o for o in effective if o["name"] == "files") - assert files_output["array_item"] == {"type": "file", "description": None} + assert files_output["array_item"] == {"type": "file", "description": None, "children": []} def test_load_workflow_composer_serializes_existing_binding(monkeypatch): - binding = SimpleNamespace(agent_id="agent-1", current_snapshot_id="version-1") + binding = SimpleNamespace( + agent_id="agent-1", + binding_type=WorkflowAgentBindingType.ROSTER_AGENT, + current_snapshot_id="version-1", + ) monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1")) monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: binding) - monkeypatch.setattr(AgentComposerService, "_get_agent_if_present", lambda **kwargs: SimpleNamespace(id="agent-1")) + monkeypatch.setattr( + AgentComposerService, + "_get_agent_if_present", + lambda **kwargs: SimpleNamespace(id="agent-1", active_config_snapshot_id="version-1"), + ) monkeypatch.setattr( AgentComposerService, "_get_version_if_present", @@ -116,14 +158,22 @@ def test_load_workflow_composer_serializes_existing_binding(monkeypatch): ) def test_save_workflow_composer_dispatches_save_strategy(monkeypatch, strategy, helper_name): fake_session = FakeSession() - binding = SimpleNamespace(agent_id="agent-1", current_snapshot_id="version-1") + binding = SimpleNamespace( + agent_id="agent-1", + binding_type=WorkflowAgentBindingType.ROSTER_AGENT, + current_snapshot_id="version-1", + ) calls = [] monkeypatch.setattr(composer_service.db, "session", fake_session) monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_save_payload", lambda payload: None) monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1")) monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: None) - monkeypatch.setattr(AgentComposerService, "_get_agent_if_present", lambda **kwargs: SimpleNamespace(id="agent-1")) + monkeypatch.setattr( + AgentComposerService, + "_get_agent_if_present", + lambda **kwargs: SimpleNamespace(id="agent-1", active_config_snapshot_id="version-1"), + ) monkeypatch.setattr( AgentComposerService, "_get_version_if_present", @@ -194,13 +244,13 @@ def test_save_agent_app_composer_creates_agent_when_missing(monkeypatch): assert result == {"loaded": True} assert fake_session.added[0].name == "Analyst" assert fake_session.added[0].active_config_snapshot_id == "version-1" + assert fake_session.added[0].active_config_has_model is False assert fake_session.commits == 1 def test_save_agent_app_composer_updates_current_version(monkeypatch): - fake_session = FakeSession( - scalar=[SimpleNamespace(id="agent-1", active_config_snapshot_id="version-1", updated_by=None)] - ) + agent = SimpleNamespace(id="agent-1", active_config_snapshot_id="version-1", updated_by=None) + fake_session = FakeSession(scalar=[agent]) updated = {} monkeypatch.setattr(composer_service.db, "session", fake_session) @@ -216,7 +266,7 @@ def test_save_agent_app_composer_updates_current_version(monkeypatch): { "variant": ComposerVariant.AGENT_APP.value, "save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value, - "agent_soul": {"prompt": {"system_prompt": "updated"}}, + "agent_soul": _agent_soul_with_model().model_dump(mode="json"), } ) @@ -227,6 +277,7 @@ def test_save_agent_app_composer_updates_current_version(monkeypatch): assert result.pop("validation") == {"warnings": [], "knowledge_retrieval_placeholder": []} assert result == {"loaded": True} assert updated["operation"].value == "save_current_version" + assert agent.active_config_has_model is True assert fake_session._scalar == [] assert fake_session.commits == 1 @@ -408,6 +459,157 @@ def test_composer_save_helpers_create_and_rebind_agents(monkeypatch): assert new_version_binding.current_snapshot_id == "new-version-1" +def test_node_job_only_updates_inline_agent_soul(monkeypatch): + fake_session = FakeSession() + monkeypatch.setattr(composer_service.db, "session", fake_session) + inline_agent = SimpleNamespace( + id="inline-agent-1", + scope=AgentScope.WORKFLOW_ONLY, + active_config_snapshot_id="inline-version-1", + active_config_has_model=False, + updated_by=None, + ) + current_snapshot = AgentConfigSnapshot( + id="inline-version-1", + tenant_id="tenant-1", + agent_id="inline-agent-1", + version=1, + config_snapshot='{"prompt":{"system_prompt":"old"}}', + ) + next_snapshot = AgentConfigSnapshot( + id="inline-version-2", + tenant_id="tenant-1", + agent_id="inline-agent-1", + version=2, + ) + + monkeypatch.setattr(AgentComposerService, "_require_version", lambda **kwargs: current_snapshot) + monkeypatch.setattr(AgentComposerService, "_update_current_version", lambda **kwargs: next_snapshot) + monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: inline_agent) + + binding = WorkflowAgentNodeBinding( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version="draft", + node_id="node-1", + binding_type=WorkflowAgentBindingType.INLINE_AGENT, + agent_id="inline-agent-1", + current_snapshot_id="inline-version-1", + ) + payload = ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.WORKFLOW.value, + "save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY.value, + "agent_soul": { + "model": { + "plugin_id": "langgenius/openai/openai", + "model_provider": "openai", + "model": "gpt-4o", + }, + "prompt": {"system_prompt": "new"}, + }, + "node_job": {"workflow_prompt": "use prior output"}, + } + ) + + updated_binding = AgentComposerService._save_node_job_only( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + node_id="node-1", + account_id="account-1", + binding=binding, + payload=payload, + ) + + assert updated_binding.current_snapshot_id == "inline-version-2" + assert updated_binding.node_job_config_dict["workflow_prompt"] == "use prior output" + assert updated_binding.updated_by == "account-1" + assert inline_agent.active_config_snapshot_id == "inline-version-2" + assert inline_agent.active_config_has_model is True + assert inline_agent.updated_by == "account-1" + + +def test_node_job_only_rejects_inline_binding_pointing_to_roster_agent(monkeypatch): + fake_session = FakeSession() + monkeypatch.setattr(composer_service.db, "session", fake_session) + current_snapshot = AgentConfigSnapshot( + id="inline-version-1", + tenant_id="tenant-1", + agent_id="agent-1", + version=1, + config_snapshot='{"prompt":{"system_prompt":"old"}}', + ) + next_snapshot = AgentConfigSnapshot(id="inline-version-2", tenant_id="tenant-1", agent_id="agent-1", version=2) + roster_agent = SimpleNamespace(id="agent-1", scope=AgentScope.ROSTER) + + monkeypatch.setattr(AgentComposerService, "_require_version", lambda **kwargs: current_snapshot) + monkeypatch.setattr(AgentComposerService, "_update_current_version", lambda **kwargs: next_snapshot) + monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: roster_agent) + + binding = WorkflowAgentNodeBinding( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version="draft", + node_id="node-1", + binding_type=WorkflowAgentBindingType.INLINE_AGENT, + agent_id="agent-1", + current_snapshot_id="inline-version-1", + ) + payload = ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.WORKFLOW.value, + "save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY.value, + "agent_soul": {"prompt": {"system_prompt": "new"}}, + } + ) + + with pytest.raises(ValueError, match="workflow-only agent"): + AgentComposerService._save_node_job_only( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + node_id="node-1", + account_id="account-1", + binding=binding, + payload=payload, + ) + + +def test_composer_create_agents_syncs_active_config_has_model(monkeypatch): + fake_session = FakeSession() + monkeypatch.setattr(composer_service.db, "session", fake_session) + monkeypatch.setattr( + AgentComposerService, + "_create_config_version", + lambda **kwargs: SimpleNamespace(id="version-with-model"), + ) + + workflow_agent = AgentComposerService._create_workflow_only_agent( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + node_id="node-1", + account_id="account-1", + agent_soul=_agent_soul_with_model(), + ) + roster_agent = AgentComposerService._create_roster_agent_for_composer( + tenant_id="tenant-1", + account_id="account-1", + name="Ready Agent", + agent_soul=_agent_soul_with_model(), + operation=AgentConfigRevisionOperation.CREATE_VERSION, + version_note=None, + ) + + assert workflow_agent.active_config_snapshot_id == "version-with-model" + assert workflow_agent.active_config_has_model is True + assert roster_agent.active_config_snapshot_id == "version-with-model" + assert roster_agent.active_config_has_model is True + + def test_composer_version_helpers_and_lookup_errors(monkeypatch): fake_session = FakeSession( scalar=[ @@ -523,6 +725,7 @@ def test_roster_list_and_invite_options(monkeypatch): tenant_id="tenant-1", name="Analyst", description="", + role="researcher", agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.ROSTER, source=AgentSource.AGENT_APP, @@ -530,27 +733,187 @@ def test_roster_list_and_invite_options(monkeypatch): ) agent.created_at = created_at agent.updated_at = updated_at - version = AgentConfigSnapshot(id="version-1", agent_id="agent-1", version=1) + version = AgentConfigSnapshot( + id="version-1", agent_id="agent-1", version=1, config_snapshot=_agent_soul_with_model() + ) version.created_at = version_created_at agent.active_config_snapshot_id = "version-1" + agent.active_config_has_model = True + unconfigured_agent = Agent( + id="agent-2", + tenant_id="tenant-1", + name="Draft Agent", + description="", + role="draft", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + ) + unconfigured_agent.active_config_snapshot_id = "version-2" + unconfigured_agent.active_config_has_model = False + unconfigured_version = AgentConfigSnapshot( + id="version-2", agent_id="agent-2", version=1, config_snapshot=AgentSoulConfig() + ) fake_session = FakeSession( - scalar=[1, 1, SimpleNamespace(id="workflow-1")], - scalars=[[agent], [agent], [SimpleNamespace(agent_id="agent-1", node_id="node-1")]], + scalar=[2, 1, SimpleNamespace(id="workflow-1")], + scalars=[ + [agent, unconfigured_agent], + [agent], + [SimpleNamespace(agent_id="agent-1", node_id="node-1")], + ], ) service = AgentRosterService(fake_session) - monkeypatch.setattr(service, "_load_versions_by_id", lambda version_ids: {"version-1": version}) + monkeypatch.setattr( + service, + "_load_versions_by_id", + lambda version_ids: {"version-1": version, "version-2": unconfigured_version}, + ) + monkeypatch.setattr(service, "_load_published_references_by_agent_id", lambda **kwargs: {}) + monkeypatch.setattr(service, "_load_published_active_snapshot_agent_ids", lambda **kwargs: {"agent-1"}) listed = service.list_roster_agents(tenant_id="tenant-1", page=1, limit=20) invited = service.list_invite_options(tenant_id="tenant-1", page=1, limit=20, app_id="app-1") + assert [item["id"] for item in listed["data"]] == ["agent-1", "agent-2"] + assert [item["id"] for item in invited["data"]] == ["agent-1"] + assert invited["total"] == 1 assert listed["data"][0]["active_config_snapshot"]["id"] == "version-1" + assert listed["data"][0]["role"] == "researcher" assert listed["data"][0]["created_at"] == int(created_at.timestamp()) assert listed["data"][0]["updated_at"] == int(updated_at.timestamp()) assert listed["data"][0]["active_config_snapshot"]["created_at"] == int(version_created_at.timestamp()) + assert listed["data"][0]["active_config_is_published"] is True + assert listed["data"][1]["active_config_is_published"] is False assert invited["data"][0]["is_in_current_workflow"] is True assert invited["data"][0]["existing_node_ids"] == ["node-1"] +def test_invite_options_uses_db_filtered_pagination(monkeypatch): + configured_agent = Agent( + id="agent-2", + tenant_id="tenant-1", + name="Ready Agent", + description="", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + active_config_snapshot_id="version-2", + active_config_has_model=True, + ) + fake_session = FakeSession(scalar=[1], scalars=[[configured_agent]]) + service = AgentRosterService(fake_session) + monkeypatch.setattr( + service, + "_load_versions_by_id", + lambda version_ids: { + "version-2": AgentConfigSnapshot( + id="version-2", agent_id="agent-2", version=1, config_snapshot=_agent_soul_with_model() + ) + }, + ) + monkeypatch.setattr(service, "_load_published_references_by_agent_id", lambda **kwargs: {}) + monkeypatch.setattr(service, "_load_published_active_snapshot_agent_ids", lambda **kwargs: set()) + + result = service.list_invite_options(tenant_id="tenant-1", page=1, limit=1) + + assert result["total"] == 1 + assert result["has_more"] is False + assert [item["id"] for item in result["data"]] == ["agent-2"] + + +def test_active_config_is_published_flags_handle_matching_and_empty_snapshots(): + agent = Agent( + id="agent-1", + tenant_id="tenant-1", + name="Published", + description="", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + active_config_snapshot_id="version-1", + ) + draft_agent = Agent( + id="agent-2", + tenant_id="tenant-1", + name="Draft", + description="", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + active_config_snapshot_id=None, + ) + service = AgentRosterService(FakeSession(scalars=[["agent-1"], ["agent-1"]])) + + flags = service.load_active_config_is_published_by_agent_id(tenant_id="tenant-1", agents=[agent, draft_agent]) + + assert flags == {"agent-1": True, "agent-2": False} + assert service.active_config_is_published(tenant_id="tenant-1", agent=agent) is True + assert AgentRosterService(FakeSession()).load_active_config_is_published_by_agent_id( + tenant_id="tenant-1", + agents=[draft_agent], + ) == {"agent-2": False} + + +def test_published_references_include_app_display_fields_and_sort_by_updated_at(): + recent_updated_at = datetime(2026, 1, 7, 3, 4, 5, tzinfo=UTC) + stale_updated_at = datetime(2026, 1, 6, 3, 4, 5, tzinfo=UTC) + bindings = [ + SimpleNamespace( + tenant_id="tenant-1", + agent_id="agent-1", + app_id="app-stale", + workflow_id="workflow-stale", + workflow_version="published-stale", + node_id="node-b", + ), + SimpleNamespace( + tenant_id="tenant-1", + agent_id="agent-1", + app_id="app-recent", + workflow_id="workflow-recent", + workflow_version="published-recent", + node_id="node-a", + ), + ] + apps = [ + SimpleNamespace( + id="app-stale", + name="Stale Workflow", + mode="advanced-chat", + workflow_id="workflow-stale", + icon_type=SimpleNamespace(value="emoji"), + icon="old", + icon_background="#F3F4F6", + updated_at=stale_updated_at, + ), + SimpleNamespace( + id="app-recent", + name="Recent Workflow", + mode="advanced-chat", + workflow_id="workflow-recent", + icon_type=SimpleNamespace(value="image"), + icon="upload-file-id", + icon_background="#E0F2FE", + updated_at=recent_updated_at, + ), + ] + service = AgentRosterService(FakeSession(scalars=[bindings, apps])) + + result = service._load_published_references_by_agent_id(tenant_id="tenant-1", agent_ids=["agent-1"]) + + references = result["agent-1"] + assert [item["app_id"] for item in references] == ["app-recent", "app-stale"] + assert references[0]["app_icon_type"] == "image" + assert references[0]["app_icon"] == "upload-file-id" + assert references[0]["app_icon_background"] == "#E0F2FE" + assert references[0]["app_updated_at"] == int(recent_updated_at.timestamp()) + assert references[0]["workflow_version"] == "published-recent" + + def test_roster_update_archive_versions_and_detail(monkeypatch): listed_version = AgentConfigSnapshot(id="version-2", agent_id="agent-1", version=2) listed_version_created_at = datetime(2026, 1, 5, 3, 4, 5, tzinfo=UTC) @@ -567,7 +930,7 @@ def test_roster_update_archive_versions_and_detail(monkeypatch): created_by="account-1", created_at=revision_created_at, ) - fake_session = FakeSession(scalars=[[listed_version], [revision]]) + fake_session = FakeSession(scalar=["visible-revision"], scalars=[[listed_version], [revision]]) agent = Agent( id="agent-1", tenant_id="tenant-1", @@ -623,6 +986,7 @@ def test_roster_create_detail_and_lookup_helpers(monkeypatch): payload = roster_service.RosterAgentCreatePayload( name="Analyst", description="desc", + role="Research assistant", icon_type="emoji", icon="A", icon_background="#fff", @@ -631,6 +995,13 @@ def test_roster_create_detail_and_lookup_helpers(monkeypatch): ) created = service.create_roster_agent(tenant_id="tenant-1", account_id="account-1", payload=payload) + backing_agent = service.create_backing_agent_for_app( + tenant_id="tenant-1", + account_id="account-1", + app_id="app-1", + name="Backing Agent", + role="Support agent", + ) found_agent = service._get_agent(tenant_id="tenant-1", agent_id="agent-1") with pytest.raises(roster_service.AgentNotFoundError): service._get_agent(tenant_id="tenant-1", agent_id="missing") @@ -641,12 +1012,42 @@ def test_roster_create_detail_and_lookup_helpers(monkeypatch): assert service._load_versions_by_id([]) == {} assert created.name == "Analyst" + assert created.role == "Research assistant" + assert created.source == AgentSource.ROSTER assert created.active_config_snapshot_id is not None + assert created.active_config_has_model is False + assert backing_agent.role == "Support agent" + assert backing_agent.active_config_snapshot_id is not None + assert backing_agent.active_config_has_model is False assert found_agent.id == "agent-1" assert found_version.id == "version-1" assert loaded_versions["version-1"].agent_id == "agent-1" +def test_agent_app_visible_versions_exclude_draft_saves(): + agent_app = Agent(source=AgentSource.AGENT_APP) + roster_agent = Agent(source=AgentSource.ROSTER) + + agent_app_operations = AgentRosterService._visible_version_operations(agent_app) + roster_operations = AgentRosterService._visible_version_operations(roster_agent) + + assert agent_app_operations == {AgentConfigRevisionOperation.SAVE_NEW_VERSION} + assert AgentConfigRevisionOperation.SAVE_CURRENT_VERSION not in agent_app_operations + assert AgentConfigRevisionOperation.CREATE_VERSION in roster_operations + assert AgentConfigRevisionOperation.SAVE_CURRENT_VERSION not in roster_operations + + +def test_app_list_all_excludes_agent_apps_by_default(): + filters = AppService._build_app_list_filters( + "account-1", + "tenant-1", + AppListParams(mode="all"), + ) + sql = " ".join(str(filter_) for filter_ in filters) + + assert "apps.mode != :mode_1" in sql + + def test_validator_dict_helpers_wrap_validation_errors(): valid_soul = ComposerConfigValidator.validate_agent_soul_dict({"prompt": {"system_prompt": "x"}}) valid_node_job = ComposerConfigValidator.validate_node_job_dict({"workflow_prompt": "x"}) @@ -734,6 +1135,28 @@ def test_composer_validator_rejects_invalid_shell_env_and_cli(): } ) + # CLI tool scoped env shares the same shell namespace as agent-level env. + with pytest.raises(InvalidComposerConfigError): + ComposerConfigValidator.validate_agent_soul_dict( + { + "env": {"variables": [{"name": "TOKEN", "value": "v"}]}, + "tools": { + "cli_tools": [ + { + "name": "github", + "env": {"secret_refs": [{"name": "TOKEN", "credential_id": "credential-1"}]}, + } + ] + }, + } + ) + + # CLI tool scoped env names are validated before runtime. + with pytest.raises(InvalidComposerConfigError): + ComposerConfigValidator.validate_agent_soul_dict( + {"tools": {"cli_tools": [{"name": "github", "env": {"variables": [{"name": "BAD-NAME"}]}}]}} + ) + # an enabled CLI tool with neither a name nor a command is meaningless with pytest.raises(InvalidComposerConfigError): ComposerConfigValidator.validate_agent_soul_dict({"tools": {"cli_tools": [{"enabled": True}]}}) @@ -779,11 +1202,18 @@ def test_composer_validator_accepts_valid_shell_env_and_cli(): { "env": { "variables": [{"name": "MY_VAR", "value": "v"}], - "secret_refs": [{"name": "API_TOKEN", "id": "credential-1"}], + "secret_refs": [{"name": "API_TOKEN", "value": "credential-1"}], }, "tools": { "cli_tools": [ - {"name": "jq", "command": "apt-get install -y jq"}, + { + "name": "jq", + "command": "apt-get install -y jq", + "env": { + "variables": [{"name": "JQ_COLOR", "value": "1"}], + "secret_refs": [{"name": "JQ_TOKEN", "value": "credential-2"}], + }, + }, { "name": "accepted-risk", "command": "curl https://example.test/install.sh | sh", @@ -797,6 +1227,10 @@ def test_composer_validator_accepts_valid_shell_env_and_cli(): ) assert {variable.name for variable in config.env.variables} == {"MY_VAR"} assert {secret.name for secret in config.env.secret_refs} == {"API_TOKEN"} + assert config.env.secret_refs[0].value == "credential-1" + assert config.tools.cli_tools[0].env.variables[0].name == "JQ_COLOR" + assert config.tools.cli_tools[0].env.secret_refs[0].name == "JQ_TOKEN" + assert config.tools.cli_tools[0].env.secret_refs[0].value == "credential-2" class TestAgentAppBackingAgent: @@ -850,18 +1284,49 @@ class TestAgentAppBackingAgent: assert service.get_app_backing_agent(tenant_id="tenant-1", app_id="app-x") is None + def test_get_agent_app_model_resolves_app_backing_agent(self): + agent = Agent( + id="agent-1", + tenant_id="tenant-1", + name="Iris", + description="", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + app_id="app-1", + ) + app = SimpleNamespace(id="app-1", mode="agent", status="normal") + session = FakeSession(scalar=[agent, app]) + service = AgentRosterService(session) + + assert service.get_agent_app_model(tenant_id="tenant-1", agent_id="agent-1") is app + + def test_get_agent_app_model_rejects_unbound_agent(self): + session = FakeSession() + service = AgentRosterService(session) + + with pytest.raises(roster_service.AgentNotFoundError): + service.get_agent_app_model(tenant_id="tenant-1", agent_id="agent-x") + class TestListWorkflowsReferencingAppAgent: def test_groups_bindings_by_workflow_app_and_sorts_by_name(self): agent = SimpleNamespace(id="agent-1") bindings = [ - SimpleNamespace(app_id="wf-app-1", workflow_id="wf-1", node_id="node-b"), - SimpleNamespace(app_id="wf-app-1", workflow_id="wf-1", node_id="node-a"), - SimpleNamespace(app_id="wf-app-2", workflow_id="wf-2", node_id="node-a"), + SimpleNamespace( + agent_id="agent-1", app_id="wf-app-1", workflow_id="wf-1", workflow_version="v1", node_id="node-b" + ), + SimpleNamespace( + agent_id="agent-1", app_id="wf-app-1", workflow_id="wf-1", workflow_version="v1", node_id="node-a" + ), + SimpleNamespace( + agent_id="agent-1", app_id="wf-app-2", workflow_id="wf-2", workflow_version="v2", node_id="node-a" + ), ] apps = [ - SimpleNamespace(id="wf-app-1", name="Beta Flow", mode="workflow"), - SimpleNamespace(id="wf-app-2", name="Alpha Flow", mode="advanced-chat"), + SimpleNamespace(id="wf-app-1", name="Beta Flow", mode="workflow", workflow_id="wf-1"), + SimpleNamespace(id="wf-app-2", name="Alpha Flow", mode="advanced-chat", workflow_id="wf-2"), ] # scalar -> backing agent; scalars -> bindings, then resolved apps. session = FakeSession(scalar=[agent], scalars=[bindings, apps]) @@ -873,6 +1338,7 @@ class TestListWorkflowsReferencingAppAgent: beta = next(r for r in result if r["app_id"] == "wf-app-1") assert beta["node_ids"] == ["node-a", "node-b"] # deduped + sorted assert beta["workflow_id"] == "wf-1" + assert beta["workflow_version"] == "v1" def test_returns_empty_when_no_backing_agent(self): session = FakeSession() # scalar() -> None @@ -889,12 +1355,561 @@ class TestListWorkflowsReferencingAppAgent: def test_skips_orphaned_binding_whose_app_is_gone(self): agent = SimpleNamespace(id="agent-1") - bindings = [SimpleNamespace(app_id="wf-app-gone", workflow_id="wf-9", node_id="node-a")] + bindings = [ + SimpleNamespace( + agent_id="agent-1", app_id="wf-app-gone", workflow_id="wf-9", workflow_version="v9", node_id="node-a" + ) + ] session = FakeSession(scalar=[agent], scalars=[bindings, []]) # no apps resolved service = AgentRosterService(session) assert service.list_workflows_referencing_app_agent(tenant_id="tenant-1", app_id="app-1") == [] + def test_skips_historical_published_workflow_versions(self): + agent = SimpleNamespace(id="agent-1") + bindings = [ + SimpleNamespace( + agent_id="agent-1", app_id="wf-app-1", workflow_id="old-wf", workflow_version="old", node_id="old" + ), + SimpleNamespace( + agent_id="agent-1", app_id="wf-app-1", workflow_id="current-wf", workflow_version="v2", node_id="new" + ), + ] + apps = [SimpleNamespace(id="wf-app-1", name="Flow", mode="workflow", workflow_id="current-wf")] + session = FakeSession(scalar=[agent], scalars=[bindings, apps]) + service = AgentRosterService(session) + + result = service.list_workflows_referencing_app_agent(tenant_id="tenant-1", app_id="app-1") + + assert len(result) == 1 + assert result[0]["workflow_id"] == "current-wf" + assert result[0]["node_ids"] == ["new"] + + +class TestWorkflowAgentDraftBindingSync: + def test_projects_binding_declared_outputs_to_draft_graph_response(self): + workflow = Workflow( + id="workflow-1", + tenant_id="tenant-1", + app_id="app-1", + version=Workflow.VERSION_DRAFT, + graph=json.dumps( + { + "nodes": [ + { + "id": "agent-node", + "data": { + "type": "agent", + "version": "2", + "agent_binding": { + "binding_type": "roster_agent", + "agent_id": "agent-1", + }, + }, + } + ], + "edges": [], + } + ), + ) + binding = WorkflowAgentNodeBinding( + id="binding-1", + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version=Workflow.VERSION_DRAFT, + node_id="agent-node", + binding_type=WorkflowAgentBindingType.ROSTER_AGENT, + agent_id="agent-1", + current_snapshot_id="snapshot-1", + node_job_config=WorkflowNodeJobConfig( + workflow_prompt="Summarize the upstream result.", + declared_outputs=[ + DeclaredOutputConfig(name="summary", type=DeclaredOutputType.STRING, description="Short summary"), + DeclaredOutputConfig( + name="profile", + type=DeclaredOutputType.OBJECT, + children=[ + DeclaredOutputChildConfig(name="email", type=DeclaredOutputType.STRING), + DeclaredOutputChildConfig( + name="addresses", + type=DeclaredOutputType.ARRAY, + array_item=DeclaredArrayItem( + type=DeclaredOutputType.OBJECT, + children=[DeclaredOutputChildConfig(name="city", type=DeclaredOutputType.STRING)], + ), + ), + ], + ), + ], + ), + ) + session = FakeSession(scalars=[[binding]]) + + graph = WorkflowAgentPublishService.project_draft_bindings_to_graph( + session=session, + draft_workflow=workflow, + ) + + node_data = graph["nodes"][0]["data"] + assert node_data["agent_task"] == "Summarize the upstream result." + assert node_data["agent_declared_outputs"][0]["name"] == "summary" + assert node_data["agent_declared_outputs"][0]["type"] == "string" + assert node_data["agent_declared_outputs"][0]["description"] == "Short summary" + profile_output = node_data["agent_declared_outputs"][1] + assert profile_output["children"][0]["name"] == "email" + assert profile_output["children"][1]["array_item"]["children"][0]["name"] == "city" + assert "agent_declared_outputs" not in workflow.graph_dict["nodes"][0]["data"] + + def test_creates_roster_binding_from_agent_node_graph(self): + workflow = Workflow( + id="workflow-1", + tenant_id="tenant-1", + app_id="app-1", + version=Workflow.VERSION_DRAFT, + graph=json.dumps( + { + "nodes": [ + { + "id": "agent-node", + "data": { + "type": "agent", + "version": "2", + "agent_task": "Summarize the upstream result.", + "agent_declared_outputs": [ + { + "name": "summary", + "type": "string", + "description": "Short summary", + } + ], + "agent_binding": { + "binding_type": "roster_agent", + "agent_id": "agent-1", + }, + }, + } + ] + } + ), + ) + agent = Agent( + id="agent-1", + tenant_id="tenant-1", + name="Agent", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + active_config_snapshot_id="snapshot-2", + ) + session = FakeSession(scalar=[agent], scalars=[[]]) + + WorkflowAgentPublishService.sync_roster_agent_bindings_for_draft( + session=session, + draft_workflow=workflow, + account_id="account-1", + ) + + binding = next(item for item in session.added if isinstance(item, WorkflowAgentNodeBinding)) + assert binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT + assert binding.agent_id == "agent-1" + assert binding.current_snapshot_id == "snapshot-2" + assert binding.node_job_config_dict == WorkflowNodeJobConfig( + workflow_prompt="Summarize the upstream result.", + declared_outputs=[ + DeclaredOutputConfig( + name="summary", + type=DeclaredOutputType.STRING, + description="Short summary", + ) + ], + ).model_dump(mode="json") + + def test_creates_inline_binding_from_agent_node_graph(self): + workflow = Workflow( + id="workflow-1", + tenant_id="tenant-1", + app_id="app-1", + version=Workflow.VERSION_DRAFT, + graph=json.dumps( + { + "nodes": [ + { + "id": "agent-node", + "data": { + "type": "agent", + "version": "2", + "agent_task": "Use the current node context.", + "agent_binding": { + "binding_type": "inline_agent", + "agent_id": "inline-agent-1", + "current_snapshot_id": "inline-snapshot-1", + }, + }, + } + ] + } + ), + ) + agent = Agent( + id="inline-agent-1", + tenant_id="tenant-1", + name="Workflow Agent agent-node", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.WORKFLOW_ONLY, + source=AgentSource.WORKFLOW, + app_id="app-1", + workflow_id="workflow-1", + workflow_node_id="agent-node", + status=AgentStatus.ACTIVE, + active_config_snapshot_id="inline-snapshot-1", + ) + snapshot = AgentConfigSnapshot( + id="inline-snapshot-1", + tenant_id="tenant-1", + agent_id="inline-agent-1", + version=1, + config_snapshot=AgentSoulConfig(), + ) + session = FakeSession(scalar=[agent, snapshot], scalars=[[]]) + + WorkflowAgentPublishService.sync_agent_bindings_for_draft( + session=session, + draft_workflow=workflow, + account_id="account-1", + ) + + binding = next(item for item in session.added if isinstance(item, WorkflowAgentNodeBinding)) + assert binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT + assert binding.agent_id == "inline-agent-1" + assert binding.current_snapshot_id == "inline-snapshot-1" + assert binding.node_job_config_dict == WorkflowNodeJobConfig( + workflow_prompt="Use the current node context.", + ).model_dump(mode="json") + + def test_rejects_inline_binding_for_agent_owned_by_another_node(self): + workflow = Workflow( + id="workflow-1", + tenant_id="tenant-1", + app_id="app-1", + version=Workflow.VERSION_DRAFT, + graph=json.dumps( + { + "nodes": [ + { + "id": "agent-node", + "data": { + "type": "agent", + "version": "2", + "agent_binding": { + "binding_type": "inline_agent", + "agent_id": "inline-agent-1", + "current_snapshot_id": "inline-snapshot-1", + }, + }, + } + ] + } + ), + ) + agent = Agent( + id="inline-agent-1", + tenant_id="tenant-1", + name="Workflow Agent other-node", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.WORKFLOW_ONLY, + source=AgentSource.WORKFLOW, + app_id="app-1", + workflow_id="workflow-1", + workflow_node_id="other-node", + status=AgentStatus.ACTIVE, + active_config_snapshot_id="inline-snapshot-1", + ) + session = FakeSession(scalar=[agent], scalars=[[]]) + + with pytest.raises(ValueError, match="inline_agent binding does not belong to this node"): + WorkflowAgentPublishService.sync_agent_bindings_for_draft( + session=session, + draft_workflow=workflow, + account_id="account-1", + ) + + def test_rejects_agent_node_graph_binding_with_unsupported_type(self): + workflow = Workflow( + id="workflow-1", + tenant_id="tenant-1", + app_id="app-1", + version=Workflow.VERSION_DRAFT, + graph=json.dumps( + { + "nodes": [ + { + "id": "agent-node", + "data": { + "type": "agent", + "version": "2", + "agent_binding": { + "binding_type": "unknown", + "agent_id": "agent-1", + }, + }, + } + ] + } + ), + ) + + with pytest.raises(ValueError, match="unsupported agent_binding type"): + WorkflowAgentPublishService.sync_agent_bindings_for_draft( + session=FakeSession(scalars=[[]]), + draft_workflow=workflow, + account_id="account-1", + ) + + def test_rejects_inline_binding_without_current_snapshot_id(self): + workflow = Workflow( + id="workflow-1", + tenant_id="tenant-1", + app_id="app-1", + version=Workflow.VERSION_DRAFT, + graph=json.dumps( + { + "nodes": [ + { + "id": "agent-node", + "data": { + "type": "agent", + "version": "2", + "agent_binding": { + "binding_type": "inline_agent", + "agent_id": "inline-agent-1", + }, + }, + } + ] + } + ), + ) + + with pytest.raises(ValueError, match="inline_agent binding requires current_snapshot_id"): + WorkflowAgentPublishService.sync_agent_bindings_for_draft( + session=FakeSession(scalars=[[]]), + draft_workflow=workflow, + account_id="account-1", + ) + + def test_rejects_inline_binding_with_missing_snapshot(self): + workflow = Workflow( + id="workflow-1", + tenant_id="tenant-1", + app_id="app-1", + version=Workflow.VERSION_DRAFT, + graph=json.dumps( + { + "nodes": [ + { + "id": "agent-node", + "data": { + "type": "agent", + "version": "2", + "agent_binding": { + "binding_type": "inline_agent", + "agent_id": "inline-agent-1", + "current_snapshot_id": "missing-snapshot", + }, + }, + } + ] + } + ), + ) + agent = Agent( + id="inline-agent-1", + tenant_id="tenant-1", + name="Workflow Agent agent-node", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.WORKFLOW_ONLY, + source=AgentSource.WORKFLOW, + app_id="app-1", + workflow_id="workflow-1", + workflow_node_id="agent-node", + status=AgentStatus.ACTIVE, + active_config_snapshot_id="inline-snapshot-1", + ) + + with pytest.raises(ValueError, match="missing inline agent config snapshot"): + WorkflowAgentPublishService.sync_agent_bindings_for_draft( + session=FakeSession(scalar=[agent, None], scalars=[[]]), + draft_workflow=workflow, + account_id="account-1", + ) + + def test_updates_existing_roster_binding_prompt_from_agent_node_graph(self): + workflow = Workflow( + id="workflow-1", + tenant_id="tenant-1", + app_id="app-1", + version=Workflow.VERSION_DRAFT, + graph=json.dumps( + { + "nodes": [ + { + "id": "agent-node", + "data": { + "type": "agent", + "version": "2", + "agent_task": "Use the latest tender context.", + "agent_binding": { + "binding_type": "roster_agent", + "agent_id": "agent-1", + }, + }, + } + ] + } + ), + ) + agent = Agent( + id="agent-1", + tenant_id="tenant-1", + name="Agent", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + active_config_snapshot_id="snapshot-2", + ) + existing_binding = WorkflowAgentNodeBinding( + id="binding-1", + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version=Workflow.VERSION_DRAFT, + node_id="agent-node", + binding_type=WorkflowAgentBindingType.ROSTER_AGENT, + agent_id="agent-1", + current_snapshot_id="snapshot-1", + node_job_config=WorkflowNodeJobConfig( + workflow_prompt="Old prompt", + declared_outputs=[ + DeclaredOutputConfig(name="summary", type=DeclaredOutputType.STRING, description="Short summary") + ], + ), + ) + session = FakeSession(scalar=[agent], scalars=[[existing_binding]]) + + WorkflowAgentPublishService.sync_roster_agent_bindings_for_draft( + session=session, + draft_workflow=workflow, + account_id="account-1", + ) + + node_job = WorkflowNodeJobConfig.model_validate(existing_binding.node_job_config_dict) + assert node_job.workflow_prompt == "Use the latest tender context." + assert [output.name for output in node_job.declared_outputs] == ["summary"] + assert existing_binding.current_snapshot_id == "snapshot-2" + + def test_updates_existing_roster_binding_declared_outputs_from_agent_node_graph(self): + workflow = Workflow( + id="workflow-1", + tenant_id="tenant-1", + app_id="app-1", + version=Workflow.VERSION_DRAFT, + graph=json.dumps( + { + "nodes": [ + { + "id": "agent-node", + "data": { + "type": "agent", + "version": "2", + "agent_task": "Keep the prompt.", + "agent_declared_outputs": [], + "agent_binding": { + "binding_type": "roster_agent", + "agent_id": "agent-1", + }, + }, + } + ] + } + ), + ) + agent = Agent( + id="agent-1", + tenant_id="tenant-1", + name="Agent", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + active_config_snapshot_id="snapshot-2", + ) + existing_binding = WorkflowAgentNodeBinding( + id="binding-1", + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version=Workflow.VERSION_DRAFT, + node_id="agent-node", + binding_type=WorkflowAgentBindingType.ROSTER_AGENT, + agent_id="agent-1", + current_snapshot_id="snapshot-1", + node_job_config=WorkflowNodeJobConfig( + workflow_prompt="Old prompt", + declared_outputs=[ + DeclaredOutputConfig( + name="summary", + type=DeclaredOutputType.STRING, + description="Short summary", + ) + ], + ), + ) + session = FakeSession(scalar=[agent], scalars=[[existing_binding]]) + + WorkflowAgentPublishService.sync_roster_agent_bindings_for_draft( + session=session, + draft_workflow=workflow, + account_id="account-1", + ) + + node_job = WorkflowNodeJobConfig.model_validate(existing_binding.node_job_config_dict) + assert node_job.workflow_prompt == "Keep the prompt." + assert node_job.declared_outputs == [] + assert existing_binding.current_snapshot_id == "snapshot-2" + + def test_deletes_draft_binding_when_agent_node_removed(self): + workflow = Workflow( + id="workflow-1", + tenant_id="tenant-1", + app_id="app-1", + version=Workflow.VERSION_DRAFT, + graph='{"nodes":[]}', + ) + stale_binding = WorkflowAgentNodeBinding( + id="binding-1", + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version=Workflow.VERSION_DRAFT, + node_id="removed-node", + binding_type=WorkflowAgentBindingType.ROSTER_AGENT, + agent_id="agent-1", + current_snapshot_id="snapshot-1", + node_job_config=WorkflowNodeJobConfig(), + ) + session = FakeSession(scalars=[[stale_binding]]) + + WorkflowAgentPublishService.sync_roster_agent_bindings_for_draft( + session=session, + draft_workflow=workflow, + account_id="account-1", + ) + + assert session.deleted == [stale_binding] + def test_dataset_rows_filters_malformed_ids(monkeypatch): """Mention ids are user-editable text: a non-UUID id must read as missing @@ -918,3 +1933,485 @@ def test_dataset_rows_filters_malformed_ids(monkeypatch): captured.clear() assert AgentComposerService._dataset_rows(tenant_id="tenant-1", dataset_ids=["nope"]) == {} assert captured == {} + + +def test_workspace_dify_tools_returns_provider_and_tool_granularities(monkeypatch): + """The slash-menu Tools tab needs both selection granularities: a provider + hosts many tools (like an MCP server), so candidates return one + provider-level entry (id = /*, = all tools) plus one per tool.""" + from types import SimpleNamespace + + provider = SimpleNamespace( + name="duckduckgo", + plugin_id="langgenius/duckduckgo", + label=SimpleNamespace(en_US="DuckDuckGo"), + description=SimpleNamespace(en_US="Privacy-first web search"), + tools=[ + SimpleNamespace(name="ddg_search", label=SimpleNamespace(en_US="DuckDuckGo Search")), + SimpleNamespace(name="ddg_news", label=SimpleNamespace(en_US="DuckDuckGo News")), + ], + ) + + import services.tools.builtin_tools_manage_service as builtin_tools_module + + monkeypatch.setattr( + builtin_tools_module.BuiltinToolManageService, + "list_builtin_tools", + staticmethod(lambda user_id, tenant_id: [provider]), + ) + + entries = AgentComposerService._workspace_dify_tools(tenant_id="tenant-1", user_id="user-1") + + assert entries[0] == { + "id": "duckduckgo/*", + "granularity": "provider", + "name": "DuckDuckGo", + "description": "Privacy-first web search", + "provider": "duckduckgo", + "plugin_id": "langgenius/duckduckgo", + "tools_count": 2, + } + assert [entry["id"] for entry in entries[1:]] == ["duckduckgo/ddg_search", "duckduckgo/ddg_news"] + assert {entry["granularity"] for entry in entries[1:]} == {"tool"} + + +# ── ENG-623 §4.4: drive-backed ref validation ──────────────────────────────── + + +def _drive_soul(**overrides): + from services.entities.agent_entities import AgentSoulConfig + + base = { + "skills_files": { + "skills": [ + {"id": "sk-1", "name": "Tender Analyzer", "skill_md_key": "tender-analyzer/SKILL.md"}, + ], + "files": [{"name": "sample.pdf", "drive_key": "files/sample.pdf"}], + }, + } + base.update(overrides) + return AgentSoulConfig.model_validate(base) + + +def _patch_drive_keys(monkeypatch, existing_keys): + import services.agent.composer_service as composer_service_module + + captured: dict[str, object] = {} + + def fake_scalars(stmt): + captured["stmt"] = stmt + return list(existing_keys) + + monkeypatch.setattr(composer_service_module.db, "session", type("S", (), {"scalars": staticmethod(fake_scalars)})()) + return captured + + +def test_drive_ref_findings_reports_missing_keys(monkeypatch): + _patch_drive_keys(monkeypatch, existing_keys=["tender-analyzer/SKILL.md"]) + + findings = AgentComposerService._drive_ref_findings( + tenant_id="tenant-1", agent_id="agent-1", agent_soul=_drive_soul() + ) + + assert [(f["code"], f["id"]) for f in findings] == [("file_ref_dangling", "files/sample.pdf")] + assert str(findings[0]["message"]).startswith("file_ref_dangling: ") + + +def test_drive_ref_findings_clean_when_all_keys_exist(monkeypatch): + _patch_drive_keys(monkeypatch, existing_keys=["tender-analyzer/SKILL.md", "files/sample.pdf"]) + + assert ( + AgentComposerService._drive_ref_findings(tenant_id="tenant-1", agent_id="agent-1", agent_soul=_drive_soul()) + == [] + ) + + +def test_drive_ref_findings_skips_refs_without_drive_keys(monkeypatch): + # No drive-backed ref at all -> no DB roundtrip, no findings. + soul = _drive_soul( + skills_files={"skills": [{"id": "legacy", "name": "Legacy"}], "files": [{"name": "u.pdf", "file_id": "u-1"}]} + ) + findings = AgentComposerService._drive_ref_findings(tenant_id="tenant-1", agent_id="agent-1", agent_soul=soul) + assert findings == [] + + +def test_require_drive_refs_resolved_raises_with_stable_code(monkeypatch): + from services.agent.errors import InvalidComposerConfigError + + _patch_drive_keys(monkeypatch, existing_keys=[]) + + with pytest.raises(InvalidComposerConfigError, match="skill_ref_dangling"): + AgentComposerService._require_drive_refs_resolved( + tenant_id="tenant-1", agent_id="agent-1", agent_soul=_drive_soul() + ) + + +def test_collect_validation_findings_appends_drive_findings_with_agent_context(monkeypatch): + from services.entities.agent_entities import ComposerSavePayload + + _patch_drive_keys(monkeypatch, existing_keys=[]) + payload = ComposerSavePayload.model_validate( + { + "variant": "agent_app", + "save_strategy": "save_to_current_version", + "agent_soul": _drive_soul().model_dump(mode="json"), + } + ) + + findings = AgentComposerService.collect_validation_findings( + tenant_id="tenant-1", payload=payload, agent_id="agent-1" + ) + + codes = {w["code"] for w in findings["warnings"]} + assert {"skill_ref_dangling", "file_ref_dangling"} <= codes + # without agent context the drive check is skipped entirely + findings_no_agent = AgentComposerService.collect_validation_findings(tenant_id="tenant-1", payload=payload) + assert all(w["code"] not in {"skill_ref_dangling", "file_ref_dangling"} for w in findings_no_agent["warnings"]) + + +# ── ENG-625 D5: soul-first ref removal ─────────────────────────────────────── + + +def _patch_remove_drive_refs_env(monkeypatch, *, soul_dict): + """Wire the classmethod's collaborators so soul editing + versioning is observable.""" + from types import SimpleNamespace + + import services.agent.composer_service as module + + agent = SimpleNamespace(id="agent-1", active_config_snapshot_id="snap-1", updated_by=None) + snapshot = SimpleNamespace(id="snap-1", tenant_id="tenant-1", agent_id="agent-1", config_snapshot_dict=soul_dict) + committed: dict[str, object] = {} + + fake_session = SimpleNamespace(scalar=lambda stmt: agent, commit=lambda: committed.setdefault("committed", True)) + monkeypatch.setattr(module.db, "session", fake_session) + monkeypatch.setattr(AgentComposerService, "_require_version", classmethod(lambda cls, **kwargs: snapshot)) + + captured: dict[str, object] = {} + + def fake_update(cls, *, current_snapshot, account_id, agent_soul, operation, version_note): + captured["agent_soul"] = agent_soul + captured["version_note"] = version_note + return SimpleNamespace(id="snap-2") + + monkeypatch.setattr(AgentComposerService, "_update_current_version", classmethod(fake_update)) + return agent, captured, committed + + +def test_remove_drive_refs_drops_skill_by_slug_and_versions(monkeypatch): + soul_dict = { + "skills_files": { + "skills": [ + {"id": "sk-1", "name": "Tender Analyzer", "skill_md_key": "tender-analyzer/SKILL.md"}, + {"id": "sk-2", "name": "Other", "skill_md_key": "other-skill/SKILL.md"}, + ], + "files": [], + } + } + agent, captured, committed = _patch_remove_drive_refs_env(monkeypatch, soul_dict=soul_dict) + + version_id = AgentComposerService.remove_drive_refs( + tenant_id="tenant-1", agent_id="agent-1", account_id="acc-1", skill_slug="tender-analyzer" + ) + + assert version_id == "snap-2" + assert agent.active_config_snapshot_id == "snap-2" + kept = [s.skill_md_key for s in captured["agent_soul"].skills_files.skills] + assert kept == ["other-skill/SKILL.md"] + assert "Tender Analyzer" in str(captured["version_note"]) + assert committed.get("committed") is True + + +def test_remove_drive_refs_is_noop_when_ref_absent(monkeypatch): + soul_dict = {"skills_files": {"skills": [], "files": []}} + agent, captured, committed = _patch_remove_drive_refs_env(monkeypatch, soul_dict=soul_dict) + + assert ( + AgentComposerService.remove_drive_refs( + tenant_id="tenant-1", agent_id="agent-1", account_id="acc-1", file_key="files/none.pdf" + ) + is None + ) + assert "agent_soul" not in captured + assert committed == {} + + +def test_remove_drive_refs_drops_file_by_key(monkeypatch): + soul_dict = { + "skills_files": { + "skills": [], + "files": [ + {"name": "keep.pdf", "drive_key": "files/keep.pdf"}, + {"name": "drop.pdf", "drive_key": "files/drop.pdf"}, + ], + } + } + _, captured, _ = _patch_remove_drive_refs_env(monkeypatch, soul_dict=soul_dict) + + version_id = AgentComposerService.remove_drive_refs( + tenant_id="tenant-1", agent_id="agent-1", account_id="acc-1", file_key="files/drop.pdf" + ) + + assert version_id == "snap-2" + assert [f.drive_key for f in captured["agent_soul"].skills_files.files] == ["files/keep.pdf"] + + +def test_add_drive_file_ref_adds_or_replaces_file_and_versions(monkeypatch): + soul_dict = { + "skills_files": { + "skills": [], + "files": [ + {"name": "old.pdf", "drive_key": "files/old.pdf"}, + {"name": "stale.pdf", "drive_key": "files/new.pdf"}, + ], + } + } + agent, captured, committed = _patch_remove_drive_refs_env(monkeypatch, soul_dict=soul_dict) + + version_id = AgentComposerService.add_drive_file_ref( + tenant_id="tenant-1", + agent_id="agent-1", + account_id="acc-1", + file_ref=AgentFileRefConfig(name="new.pdf", file_id="uf-1", drive_key="files/new.pdf", type="application/pdf"), + ) + + assert version_id == "snap-2" + assert agent.active_config_snapshot_id == "snap-2" + assert [f.drive_key for f in captured["agent_soul"].skills_files.files] == ["files/old.pdf", "files/new.pdf"] + assert captured["agent_soul"].skills_files.files[-1].name == "new.pdf" + assert "new.pdf" in str(captured["version_note"]) + assert committed.get("committed") is True + + +def test_add_drive_file_ref_syncs_workflow_binding_snapshot(monkeypatch): + binding = SimpleNamespace(agent_id="agent-1", current_snapshot_id="snap-1", updated_by=None) + _patch_remove_drive_refs_env(monkeypatch, soul_dict={"skills_files": {"skills": [], "files": []}}) + monkeypatch.setattr( + AgentComposerService, "_get_draft_workflow", classmethod(lambda cls, **kwargs: SimpleNamespace(id="wf-1")) + ) + monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", classmethod(lambda cls, **kwargs: binding)) + + AgentComposerService.add_drive_file_ref( + tenant_id="tenant-1", + agent_id="agent-1", + account_id="acc-1", + file_ref=AgentFileRefConfig(name="new.pdf", file_id="uf-1", drive_key="files/new.pdf"), + app_id="app-1", + node_id="agent-node-1", + ) + + assert binding.current_snapshot_id == "snap-2" + assert binding.updated_by == "acc-1" + + +def test_remove_drive_refs_requires_exactly_one_scope(): + with pytest.raises(ValueError): + AgentComposerService.remove_drive_refs(tenant_id="t", agent_id="a", account_id="u") + + +# ── ENG-623/625: resolver helpers + save-path drive guard ──────────────────── + + +def test_resolve_bound_agent_id_queries_active_roster_agent(monkeypatch): + from types import SimpleNamespace + + import services.agent.composer_service as module + + monkeypatch.setattr(module.db, "session", SimpleNamespace(scalar=lambda stmt: "agent-9")) + assert AgentComposerService.resolve_bound_agent_id(tenant_id="t-1", app_id="app-1") == "agent-9" + + +def test_resolve_workflow_node_agent_id_degrades_without_workflow_or_binding(monkeypatch): + from types import SimpleNamespace + + def boom(cls, **kwargs): + raise ValueError("no draft workflow") + + monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", classmethod(boom)) + assert AgentComposerService.resolve_workflow_node_agent_id(tenant_id="t", app_id="a", node_id="n") is None + + monkeypatch.setattr( + AgentComposerService, "_get_draft_workflow", classmethod(lambda cls, **kwargs: SimpleNamespace(id="wf-1")) + ) + monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", classmethod(lambda cls, **kwargs: None)) + assert AgentComposerService.resolve_workflow_node_agent_id(tenant_id="t", app_id="a", node_id="n") is None + + monkeypatch.setattr( + AgentComposerService, + "_get_workflow_binding", + classmethod(lambda cls, **kwargs: SimpleNamespace(agent_id="agent-7")), + ) + assert AgentComposerService.resolve_workflow_node_agent_id(tenant_id="t", app_id="a", node_id="n") == "agent-7" + + +def test_remove_drive_refs_returns_none_without_agent_or_snapshot(monkeypatch): + from types import SimpleNamespace + + import services.agent.composer_service as module + + monkeypatch.setattr(module.db, "session", SimpleNamespace(scalar=lambda stmt: None)) + assert AgentComposerService.remove_drive_refs(tenant_id="t", agent_id="a", account_id="u", skill_slug="s") is None + + agent_without_snapshot = SimpleNamespace(id="a", active_config_snapshot_id=None) + monkeypatch.setattr(module.db, "session", SimpleNamespace(scalar=lambda stmt: agent_without_snapshot)) + assert AgentComposerService.remove_drive_refs(tenant_id="t", agent_id="a", account_id="u", skill_slug="s") is None + + +def test_save_workflow_composer_guards_drive_refs_for_existing_agent_strategies(monkeypatch): + from types import SimpleNamespace + + from services.entities.agent_entities import ComposerSavePayload + + payload = ComposerSavePayload.model_validate( + { + "variant": "workflow", + "save_strategy": "save_to_current_version", + "agent_soul": _drive_soul().model_dump(mode="json"), + "soul_lock": {"locked": False}, + } + ) + monkeypatch.setattr( + AgentComposerService, "_get_draft_workflow", classmethod(lambda cls, **kwargs: SimpleNamespace(id="wf-1")) + ) + monkeypatch.setattr( + AgentComposerService, + "_get_workflow_binding", + classmethod(lambda cls, **kwargs: SimpleNamespace(agent_id="agent-1")), + ) + guarded: dict[str, str] = {} + + def fake_guard(cls, *, tenant_id, agent_id, agent_soul): + guarded["agent_id"] = agent_id + raise InvalidComposerConfigError("skill_ref_dangling: boom") + + from services.agent.errors import InvalidComposerConfigError + + monkeypatch.setattr(AgentComposerService, "_require_drive_refs_resolved", classmethod(fake_guard)) + + with pytest.raises(InvalidComposerConfigError, match="skill_ref_dangling"): + AgentComposerService.save_workflow_composer( + tenant_id="t-1", app_id="app-1", node_id="n-1", account_id="acc-1", payload=payload + ) + assert guarded["agent_id"] == "agent-1" + + +def test_save_workflow_composer_guards_drive_refs_for_inline_node_job_only(monkeypatch): + payload = ComposerSavePayload.model_validate( + { + "variant": "workflow", + "save_strategy": "node_job_only", + "agent_soul": _drive_soul().model_dump(mode="json"), + "soul_lock": {"locked": False}, + } + ) + binding = WorkflowAgentNodeBinding( + tenant_id="t-1", + app_id="app-1", + workflow_id="wf-1", + workflow_version="draft", + node_id="n-1", + binding_type=WorkflowAgentBindingType.INLINE_AGENT, + agent_id="agent-1", + current_snapshot_id="version-1", + ) + monkeypatch.setattr(composer_service.db, "session", FakeSession()) + monkeypatch.setattr( + AgentComposerService, "_get_draft_workflow", classmethod(lambda cls, **kwargs: SimpleNamespace(id="wf-1")) + ) + monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", classmethod(lambda cls, **kwargs: binding)) + monkeypatch.setattr(AgentComposerService, "_save_node_job_only", classmethod(lambda cls, **kwargs: binding)) + monkeypatch.setattr( + AgentComposerService, + "_get_agent_if_present", + classmethod(lambda cls, **kwargs: SimpleNamespace(id="agent-1", active_config_snapshot_id="version-1")), + ) + monkeypatch.setattr( + AgentComposerService, + "_get_version_if_present", + classmethod(lambda cls, **kwargs: SimpleNamespace(id="version-1")), + ) + monkeypatch.setattr( + AgentComposerService, "_serialize_workflow_state", classmethod(lambda cls, **kwargs: {"state": "ok"}) + ) + monkeypatch.setattr( + AgentComposerService, "collect_validation_findings", classmethod(lambda cls, **kwargs: {"warnings": []}) + ) + guarded: dict[str, str] = {} + + def fake_guard(cls, *, tenant_id, agent_id, agent_soul): + guarded["tenant_id"] = tenant_id + guarded["agent_id"] = agent_id + + monkeypatch.setattr(AgentComposerService, "_require_drive_refs_resolved", classmethod(fake_guard)) + + result = AgentComposerService.save_workflow_composer( + tenant_id="t-1", app_id="app-1", node_id="n-1", account_id="acc-1", payload=payload + ) + + assert result == {"state": "ok", "validation": {"warnings": []}} + assert guarded == {"tenant_id": "t-1", "agent_id": "agent-1"} + + +def test_save_workflow_composer_skips_drive_refs_for_roster_node_job_only(monkeypatch): + payload = ComposerSavePayload.model_validate( + { + "variant": "workflow", + "save_strategy": "node_job_only", + "agent_soul": _drive_soul().model_dump(mode="json"), + "soul_lock": {"locked": False}, + } + ) + binding = WorkflowAgentNodeBinding( + tenant_id="t-1", + app_id="app-1", + workflow_id="wf-1", + workflow_version="draft", + node_id="n-1", + binding_type=WorkflowAgentBindingType.ROSTER_AGENT, + agent_id="agent-1", + current_snapshot_id="version-1", + ) + monkeypatch.setattr(composer_service.db, "session", FakeSession()) + monkeypatch.setattr( + AgentComposerService, "_get_draft_workflow", classmethod(lambda cls, **kwargs: SimpleNamespace(id="wf-1")) + ) + monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", classmethod(lambda cls, **kwargs: binding)) + monkeypatch.setattr(AgentComposerService, "_save_node_job_only", classmethod(lambda cls, **kwargs: binding)) + monkeypatch.setattr( + AgentComposerService, + "_get_agent_if_present", + classmethod(lambda cls, **kwargs: SimpleNamespace(id="agent-1", active_config_snapshot_id="version-1")), + ) + monkeypatch.setattr( + AgentComposerService, + "_get_version_if_present", + classmethod(lambda cls, **kwargs: SimpleNamespace(id="version-1")), + ) + monkeypatch.setattr( + AgentComposerService, "_serialize_workflow_state", classmethod(lambda cls, **kwargs: {"state": "ok"}) + ) + monkeypatch.setattr( + AgentComposerService, "collect_validation_findings", classmethod(lambda cls, **kwargs: {"warnings": []}) + ) + + def fail_guard(cls, *, tenant_id, agent_id, agent_soul): + raise AssertionError("roster node-job-only saves must not validate agent drive refs") + + monkeypatch.setattr(AgentComposerService, "_require_drive_refs_resolved", classmethod(fail_guard)) + + result = AgentComposerService.save_workflow_composer( + tenant_id="t-1", app_id="app-1", node_id="n-1", account_id="acc-1", payload=payload + ) + + assert result == {"state": "ok", "validation": {"warnings": []}} + + +def test_remove_drive_refs_noop_when_skill_slug_unmatched(monkeypatch): + soul_dict = {"skills_files": {"skills": [{"name": "Other", "skill_md_key": "other/SKILL.md"}], "files": []}} + _, captured, committed = _patch_remove_drive_refs_env(monkeypatch, soul_dict=soul_dict) + assert ( + AgentComposerService.remove_drive_refs( + tenant_id="t-1", agent_id="agent-1", account_id="acc-1", skill_slug="ghost" + ) + is None + ) + assert committed == {} diff --git a/api/tests/unit_tests/services/agent/test_composer_candidates.py b/api/tests/unit_tests/services/agent/test_composer_candidates.py index d1ac51bd776..5eb9acf8b81 100644 --- a/api/tests/unit_tests/services/agent/test_composer_candidates.py +++ b/api/tests/unit_tests/services/agent/test_composer_candidates.py @@ -123,7 +123,10 @@ def _soul() -> AgentSoulConfig: "files": [{"id": "f-1", "name": "qna_report.pdf"}], }, "tools": { - "cli_tools": [{"name": "ffmpeg"}, {"name": "disabled-one", "enabled": False}], + "cli_tools": [ + {"id": "ct-1", "name": "ffmpeg"}, + {"id": "ct-2", "name": "disabled-one", "enabled": False}, + ], }, "knowledge": {"datasets": [{"id": "ds-1", "name": "旧名"}, {"id": "ds-gone", "name": "已删"}]}, "human": {"contacts": [{"id": "c-1", "name": "David Hayes", "channel": "email"}]}, @@ -143,6 +146,8 @@ def test_soul_candidates_lists_configured_items_only(): assert truncated is False assert [item["kind"] for item in lists["skills_files"]] == ["skill", "file"] assert [item["name"] for item in lists["cli_tools"]] == ["ffmpeg"] + # the stable mention id flows through so the frontend can mint [§cli_tool:§] + assert [item["id"] for item in lists["cli_tools"]] == ["ct-1"] # enriched from DB; dangling dataset kept with missing flag (placeholder, 0522) knowledge = {item["id"]: item for item in lists["knowledge_datasets"]} assert knowledge["ds-1"]["name"] == "产品手册" diff --git a/api/tests/unit_tests/services/agent/test_composer_mention_validation.py b/api/tests/unit_tests/services/agent/test_composer_mention_validation.py index 93efbb4ebe9..e332b48ab1e 100644 --- a/api/tests/unit_tests/services/agent/test_composer_mention_validation.py +++ b/api/tests/unit_tests/services/agent/test_composer_mention_validation.py @@ -176,6 +176,81 @@ def test_unresolved_non_knowledge_mentions_warn_target_missing(): assert findings["knowledge_retrieval_placeholder"] == [] +def test_provider_all_tools_mention_resolves_against_provider_level_entry(): + # `[§tool:/*§]` = all tools of that provider; it points at a + # provider-level config entry (tool_name omitted), so with the entry present + # it must produce neither hard error nor warning… + payload = ComposerSavePayload.model_validate( + { + "variant": "agent_app", + "agent_soul": { + "prompt": {"system_prompt": "use [§tool:duckduckgo/*:DuckDuckGo 全部§] when needed"}, + "tools": { + "dify_tools": [ + { + "plugin_id": "langgenius/duckduckgo", + "provider": "duckduckgo", + "credential_type": "unauthorized", + } + ] + }, + }, + "save_strategy": "save_to_current_version", + } + ) + ComposerConfigValidator.validate_save_payload(payload) + assert _findings(payload) == {"warnings": [], "knowledge_retrieval_placeholder": []} + + # …and without any entry of that provider it warns like any dangling mention. + dangling = _findings(_soul_payload("use [§tool:duckduckgo/*:DuckDuckGo 全部§]")) + assert [(w["code"], w["kind"]) for w in dangling["warnings"]] == [("mention_target_missing", "tool")] + + +def test_duplicate_cli_tool_ids_rejected(): + payload = ComposerSavePayload.model_validate( + { + "variant": "agent_app", + "agent_soul": { + "prompt": {"system_prompt": "plain"}, + "tools": { + "cli_tools": [ + {"id": "ct-1", "name": "ffmpeg"}, + # disabled entries still occupy the id namespace + {"id": "ct-1", "name": "pandoc", "enabled": False}, + ] + }, + }, + "save_strategy": "save_to_current_version", + } + ) + with pytest.raises(InvalidComposerConfigError, match="duplicate CLI tool id 'ct-1'"): + ComposerConfigValidator.validate_save_payload(payload) + + +def test_save_backfills_missing_cli_tool_ids_and_keeps_existing(): + from services.agent.composer_service import _backfill_cli_tool_ids + + payload = ComposerSavePayload.model_validate( + { + "variant": "agent_app", + "agent_soul": { + "prompt": {"system_prompt": "plain"}, + "tools": {"cli_tools": [{"name": "ffmpeg"}, {"id": "ct-1", "name": "pandoc"}]}, + }, + "save_strategy": "save_to_current_version", + } + ) + + _backfill_cli_tool_ids(payload.agent_soul) + + assert payload.agent_soul is not None + minted, existing = payload.agent_soul.tools.cli_tools + assert existing.id == "ct-1" + assert minted.id is not None + assert len(minted.id) == 12 + assert minted.id != "ct-1" + + def test_malformed_marker_warns_but_does_not_block(): payload = _soul_payload("hello [§wat:x:y§] world") ComposerConfigValidator.validate_save_payload(payload) # no hard error diff --git a/api/tests/unit_tests/services/agent/test_prompt_mentions.py b/api/tests/unit_tests/services/agent/test_prompt_mentions.py index e615a6e7c57..b85a0a752e1 100644 --- a/api/tests/unit_tests/services/agent/test_prompt_mentions.py +++ b/api/tests/unit_tests/services/agent/test_prompt_mentions.py @@ -101,7 +101,7 @@ def soul() -> AgentSoulConfig: "credential_type": "unauthorized", }, ], - "cli_tools": [{"name": "ffmpeg"}], + "cli_tools": [{"id": "ct-1", "name": "ffmpeg"}], }, "knowledge": {"datasets": [{"id": "ds-1", "name": "产品手册"}]}, "human": {"contacts": [{"id": "c-1", "name": "David Hayes", "channel": "email"}]}, @@ -113,7 +113,7 @@ def test_soul_resolver_resolves_each_kind(soul: AgentSoulConfig): resolver = build_soul_mention_resolver(soul) prompt = ( "Use [§skill:sk-1§] with [§file:f-1§], search via " - "[§tool:tavily/tavily_search:tavily§], run [§cli_tool:ffmpeg§], " + "[§tool:tavily/tavily_search:tavily§], run [§cli_tool:ct-1:ffmpeg§], " "ground in [§knowledge:ds-1§], ask [§human:c-1§]." ) @@ -130,6 +130,46 @@ def test_soul_resolver_unknown_ids_degrade(soul: AgentSoulConfig): assert expanded == "旧产品手册" +def test_soul_resolver_cli_tool_resolves_by_id_and_keeps_name_alias(soul: AgentSoulConfig): + resolver = build_soul_mention_resolver(soul) + # id is the contract; the name alias keeps tokens minted before ids existed working + assert expand_prompt_mentions("[§cli_tool:ct-1§]", resolver) == "ffmpeg" + assert expand_prompt_mentions("[§cli_tool:ffmpeg§]", resolver) == "ffmpeg" + # a rename only changes the expansion, never breaks the id reference + soul.tools.cli_tools[0].name = "ffmpeg-v7" + assert expand_prompt_mentions("[§cli_tool:ct-1§]", build_soul_mention_resolver(soul)) == "ffmpeg-v7" + + +@pytest.fixture +def soul_with_provider_entry(soul: AgentSoulConfig) -> AgentSoulConfig: + # provider-level entry (tool_name omitted) = all tools of the provider + soul.tools.dify_tools.append( + soul.tools.dify_tools[0].model_copy( + update={"plugin_id": "langgenius/duckduckgo", "provider": "duckduckgo", "tool_name": None} + ) + ) + return soul + + +def test_soul_resolver_provider_all_tools_mention(soul_with_provider_entry: AgentSoulConfig): + resolver = build_soul_mention_resolver(soul_with_provider_entry) + # [§tool:/*§] = all tools of that provider + assert expand_prompt_mentions("Use [§tool:duckduckgo/*:DuckDuckGo 全部§].", resolver) == ( + "Use all duckduckgo tools." + ) + # plugin-prefixed alias of the same provider + assert expand_prompt_mentions("[§tool:langgenius/duckduckgo/duckduckgo/*§]", resolver) == "all duckduckgo tools" + # without a provider-level entry the mention dangles -> degrades to label + bare = build_soul_mention_resolver(AgentSoulConfig.model_validate({})) + assert expand_prompt_mentions("[§tool:duckduckgo/*:DuckDuckGo 全部§]", bare) == "DuckDuckGo 全部" + + +def test_soul_resolver_single_tool_resolves_via_provider_level_entry(soul_with_provider_entry: AgentSoulConfig): + # one tool offered through the provider-level ("all") entry still resolves + resolver = build_soul_mention_resolver(soul_with_provider_entry) + assert expand_prompt_mentions("[§tool:duckduckgo/ddg_search§]", resolver) == "ddg_search" + + # ── node-job resolver ───────────────────────────────────────────────────────── diff --git a/api/tests/unit_tests/services/agent/test_skill_standardize_service.py b/api/tests/unit_tests/services/agent/test_skill_standardize_service.py index 8a99719dd3f..128a6c42801 100644 --- a/api/tests/unit_tests/services/agent/test_skill_standardize_service.py +++ b/api/tests/unit_tests/services/agent/test_skill_standardize_service.py @@ -75,3 +75,6 @@ def test_standardize_creates_two_drive_owned_toolfiles_and_commits(): assert skill["full_archive_file_id"] == "zip-tool-file" assert skill["skill_md_file_id"] == "md-tool-file" assert skill["skill_md_key"] == "pdf-toolkit/SKILL.md" + # ENG-371: zip member listing persisted for infer-tools signals + assert "SKILL.md" in skill["manifest_files"] + assert "scripts/run.py" in skill["manifest_files"] diff --git a/api/tests/unit_tests/services/agent/test_skill_tool_inference_service.py b/api/tests/unit_tests/services/agent/test_skill_tool_inference_service.py new file mode 100644 index 00000000000..cd708127ae7 --- /dev/null +++ b/api/tests/unit_tests/services/agent/test_skill_tool_inference_service.py @@ -0,0 +1,225 @@ +"""Unit tests for skill → CLI tool inference (ENG-371).""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from services.agent.skill_tool_inference_service import ( + SkillToolInferenceError, + SkillToolInferenceService, +) +from services.agent_drive_service import AgentDriveError + +_MOD = "services.agent.skill_tool_inference_service" + +_SKILL_MD_PREVIEW = { + "key": "audio-transcribe/SKILL.md", + "size": 100, + "truncated": False, + "binary": False, + "text": "# Audio Transcribe\nStep 2 runs ffmpeg, step 3 calls the whisper API.", +} + + +def _service(preview=_SKILL_MD_PREVIEW): + drive = MagicMock() + drive.preview.return_value = preview + return SkillToolInferenceService(drive_service=drive), drive + + +def _patch_soul_files(monkeypatch, files): + monkeypatch.setattr(SkillToolInferenceService, "_manifest_files_from_soul", staticmethod(lambda **kwargs: files)) + + +def test_infer_returns_suggestions_with_inferred_from(monkeypatch): + service, drive = _service() + _patch_soul_files(monkeypatch, ["SKILL.md", "scripts/transcribe.sh"]) + raw = ( + '{"inferable": true, "reason": null, "cli_tools": [{"name": "ffmpeg",' + ' "description": "transcoding for step 2", "command": "ffmpeg",' + ' "install_commands": ["apt-get install -y ffmpeg"],' + ' "env_suggestions": [{"key": "OPENAI_API_KEY", "reason": "whisper call", "secret_likely": true}]}]}' + ) + with patch.object(SkillToolInferenceService, "_invoke", staticmethod(lambda **kwargs: raw)): + result = service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe") + + assert result["inferable"] is True + tool = result["cli_tools"][0] + assert tool["name"] == "ffmpeg" + assert tool["inferred_from"] == "audio-transcribe" + assert tool["env_suggestions"] == [{"key": "OPENAI_API_KEY", "reason": "whisper call", "secret_likely": True}] + drive.preview.assert_called_once_with(tenant_id="t-1", agent_id="a-1", key="audio-transcribe/SKILL.md") + + +def test_infer_threads_manifest_files_into_the_prompt(monkeypatch): + service, _ = _service() + _patch_soul_files(monkeypatch, ["scripts/run.sh"]) + captured: dict[str, str] = {} + + def fake_invoke(*, tenant_id, user_prompt): + captured["prompt"] = user_prompt + return '{"inferable": false, "cli_tools": [], "reason": "none"}' + + with patch.object(SkillToolInferenceService, "_invoke", staticmethod(fake_invoke)): + service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe") + + assert "scripts/run.sh" in captured["prompt"] + assert "ffmpeg" in captured["prompt"] # SKILL.md body present + + +def test_infer_not_inferable_passes_reason_through(monkeypatch): + service, _ = _service() + _patch_soul_files(monkeypatch, []) + raw = '{"inferable": false, "cli_tools": [], "reason": "SKILL.md 未描述任何外部命令依赖"}' + with patch.object(SkillToolInferenceService, "_invoke", staticmethod(lambda **kwargs: raw)): + result = service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe") + assert result == {"inferable": False, "cli_tools": [], "reason": "SKILL.md 未描述任何外部命令依赖"} + + +def test_infer_retries_once_then_422(monkeypatch): + service, _ = _service() + _patch_soul_files(monkeypatch, []) + calls: list[int] = [] + + def bad_invoke(**kwargs): + calls.append(1) + return "not json at all ][" + + with patch.object(SkillToolInferenceService, "_invoke", staticmethod(bad_invoke)): + with pytest.raises(SkillToolInferenceError) as exc_info: + service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe") + + assert len(calls) == 2 # one retry + assert exc_info.value.code == "inference_failed" + assert exc_info.value.status_code == 422 + + +def test_infer_repairs_slightly_malformed_json(monkeypatch): + service, _ = _service() + _patch_soul_files(monkeypatch, []) + raw = 'Here you go: {"inferable": true, "cli_tools": [], "reason": null,}' + with patch.object(SkillToolInferenceService, "_invoke", staticmethod(lambda **kwargs: raw)): + result = service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe") + assert result["inferable"] is True + + +def test_missing_skill_maps_to_404(): + drive = MagicMock() + drive.preview.side_effect = AgentDriveError("drive_key_not_found", "nope", status_code=404) + service = SkillToolInferenceService(drive_service=drive) + + with pytest.raises(SkillToolInferenceError) as exc_info: + service.infer(tenant_id="t-1", agent_id="a-1", slug="ghost") + assert exc_info.value.code == "skill_not_found" + assert exc_info.value.status_code == 404 + + +def test_binary_skill_md_maps_to_404(): + service, _ = _service(preview={"key": "x/SKILL.md", "size": 1, "truncated": False, "binary": True, "text": None}) + with pytest.raises(SkillToolInferenceError) as exc_info: + service.infer(tenant_id="t-1", agent_id="a-1", slug="x") + assert exc_info.value.code == "skill_not_found" + + +# ── real-path coverage: _invoke / _manifest_files_from_soul / passthrough ──── + + +def test_invoke_maps_missing_default_model_to_400(monkeypatch): + import services.agent.skill_tool_inference_service as module + from core.errors.error import ProviderTokenNotInitError + + fake_manager = MagicMock() + fake_manager.get_default_model_instance.side_effect = ProviderTokenNotInitError("no default") + monkeypatch.setattr(module.ModelManager, "for_tenant", classmethod(lambda cls, tenant_id: fake_manager)) + + with pytest.raises(SkillToolInferenceError) as exc_info: + SkillToolInferenceService._invoke(tenant_id="t-1", user_prompt="x") + assert exc_info.value.code == "default_model_not_configured" + assert exc_info.value.status_code == 400 + + +def test_invoke_maps_model_failure_to_422_and_success_returns_text(monkeypatch): + import services.agent.skill_tool_inference_service as module + + fake_manager = MagicMock() + fake_instance = MagicMock() + fake_manager.get_default_model_instance.return_value = fake_instance + monkeypatch.setattr(module.ModelManager, "for_tenant", classmethod(lambda cls, tenant_id: fake_manager)) + + fake_instance.invoke_llm.side_effect = RuntimeError("provider down") + with pytest.raises(SkillToolInferenceError) as exc_info: + SkillToolInferenceService._invoke(tenant_id="t-1", user_prompt="x") + assert exc_info.value.code == "inference_failed" + assert exc_info.value.status_code == 422 + + fake_instance.invoke_llm.side_effect = None + fake_instance.invoke_llm.return_value.message.get_text_content.return_value = '{"inferable": false}' + raw = SkillToolInferenceService._invoke(tenant_id="t-1", user_prompt="x") + assert raw == '{"inferable": false}' + call = fake_instance.invoke_llm.call_args.kwargs + assert call["model_parameters"] == {"temperature": 0.1} + assert call["stream"] is False + + +def test_load_skill_md_passes_through_non_missing_drive_errors(): + drive = MagicMock() + drive.preview.side_effect = AgentDriveError("agent_not_found", "tenant mismatch", status_code=404) + service = SkillToolInferenceService(drive_service=drive) + + with pytest.raises(SkillToolInferenceError) as exc_info: + service.infer(tenant_id="t-1", agent_id="a-1", slug="x") + assert exc_info.value.code == "agent_not_found" + + +def _patch_inference_db(monkeypatch, *, agent, snapshot): + from types import SimpleNamespace + + import services.agent.skill_tool_inference_service as module + + results = iter([agent, snapshot]) + monkeypatch.setattr(module.db, "session", SimpleNamespace(scalar=lambda stmt: next(results))) + + +def test_manifest_files_from_soul_reads_active_snapshot(monkeypatch): + from types import SimpleNamespace + + soul_dict = { + "skills_files": { + "skills": [ + {"name": "Other", "skill_md_key": "other/SKILL.md", "manifest_files": ["x.md"]}, + {"name": "Audio", "skill_md_key": "audio-transcribe/SKILL.md", "manifest_files": ["scripts/a.sh"]}, + ] + } + } + agent = SimpleNamespace(active_config_snapshot_id="snap-1") + snapshot = SimpleNamespace(config_snapshot_dict=soul_dict) + _patch_inference_db(monkeypatch, agent=agent, snapshot=snapshot) + + files = SkillToolInferenceService._manifest_files_from_soul( + tenant_id="t-1", agent_id="a-1", slug="audio-transcribe" + ) + assert files == ["scripts/a.sh"] + + +def test_manifest_files_from_soul_degrades_when_agent_or_snapshot_missing(monkeypatch): + _patch_inference_db(monkeypatch, agent=None, snapshot=None) + assert SkillToolInferenceService._manifest_files_from_soul(tenant_id="t", agent_id="a", slug="s") == [] + + from types import SimpleNamespace + + _patch_inference_db(monkeypatch, agent=SimpleNamespace(active_config_snapshot_id="snap-1"), snapshot=None) + assert SkillToolInferenceService._manifest_files_from_soul(tenant_id="t", agent_id="a", slug="s") == [] + + +def test_manifest_files_from_soul_empty_when_slug_not_in_soul(monkeypatch): + from types import SimpleNamespace + + soul_dict = {"skills_files": {"skills": [{"name": "Other", "skill_md_key": "other/SKILL.md"}]}} + _patch_inference_db( + monkeypatch, + agent=SimpleNamespace(active_config_snapshot_id="snap-1"), + snapshot=SimpleNamespace(config_snapshot_dict=soul_dict), + ) + assert SkillToolInferenceService._manifest_files_from_soul(tenant_id="t", agent_id="a", slug="ghost") == [] diff --git a/api/tests/unit_tests/services/auth/test_jina_auth.py b/api/tests/unit_tests/services/auth/test_jina_auth.py index 2c34d46f1e4..eb409c61d4e 100644 --- a/api/tests/unit_tests/services/auth/test_jina_auth.py +++ b/api/tests/unit_tests/services/auth/test_jina_auth.py @@ -68,6 +68,22 @@ class TestJinaAuth: auth.validate_credentials() assert str(exc_info.value) == "Failed to authorize. Status code: 402. Error: Payment required" + @patch("services.auth.jina.jina._http_client.post", autospec=True) + def test_should_handle_http_error_with_non_json_text_response(self, mock_post): + """Test handling of known HTTP errors with non-JSON text response.""" + mock_response = MagicMock() + mock_response.status_code = 402 + mock_response.text = "Payment required" + mock_response.json.side_effect = ValueError("Not JSON") + mock_post.return_value = mock_response + + credentials = {"auth_type": "bearer", "config": {"api_key": "test_api_key_123"}} + auth = JinaAuth(credentials) + + with pytest.raises(Exception) as exc_info: + auth.validate_credentials() + assert str(exc_info.value) == "Failed to authorize. Status code: 402. Error: Payment required" + @patch("services.auth.jina.jina._http_client.post", autospec=True) def test_should_handle_http_409_error(self, mock_post): """Test handling of 409 Conflict error""" @@ -114,6 +130,22 @@ class TestJinaAuth: auth.validate_credentials() assert str(exc_info.value) == "Failed to authorize. Status code: 403. Error: Forbidden" + @patch("services.auth.jina.jina._http_client.post", autospec=True) + def test_should_handle_unexpected_error_with_non_json_text_response(self, mock_post): + """Test handling of unexpected errors with non-JSON text response.""" + mock_response = MagicMock() + mock_response.status_code = 403 + mock_response.text = "Forbidden" + mock_response.json.side_effect = Exception("Not JSON") + mock_post.return_value = mock_response + + credentials = {"auth_type": "bearer", "config": {"api_key": "test_api_key_123"}} + auth = JinaAuth(credentials) + + with pytest.raises(Exception) as exc_info: + auth.validate_credentials() + assert str(exc_info.value) == "Failed to authorize. Status code: 403. Error: Forbidden" + @patch("services.auth.jina.jina._http_client.post", autospec=True) def test_should_handle_unexpected_error_without_text(self, mock_post): """Test handling of unexpected errors without text response""" diff --git a/api/tests/unit_tests/services/auth/test_jina_auth_standalone_module.py b/api/tests/unit_tests/services/auth/test_jina_auth_standalone_module.py index b31af996ae9..9e45cbbd944 100644 --- a/api/tests/unit_tests/services/auth/test_jina_auth_standalone_module.py +++ b/api/tests/unit_tests/services/auth/test_jina_auth_standalone_module.py @@ -118,6 +118,17 @@ def test_handle_error_statuses_default_unknown_error(jina_module: ModuleType) -> auth._handle_error(response) +def test_handle_error_statuses_fall_back_to_text_body(jina_module: ModuleType) -> None: + auth = jina_module.JinaAuth(_credentials(api_key="k")) + response = MagicMock() + response.status_code = 402 + response.text = "Payment required" + response.json.side_effect = ValueError("Not JSON") + + with pytest.raises(Exception, match="Status code: 402.*Payment required"): + auth._handle_error(response) + + def test_handle_error_with_text_json_body(jina_module: ModuleType) -> None: auth = jina_module.JinaAuth(_credentials(api_key="k")) response = MagicMock() @@ -128,6 +139,16 @@ def test_handle_error_with_text_json_body(jina_module: ModuleType) -> None: auth._handle_error(response) +def test_handle_error_with_non_json_text_body(jina_module: ModuleType) -> None: + auth = jina_module.JinaAuth(_credentials(api_key="k")) + response = MagicMock() + response.status_code = 403 + response.text = "Forbidden" + + with pytest.raises(Exception, match="Status code: 403.*Forbidden"): + auth._handle_error(response) + + def test_handle_error_with_text_json_body_missing_error(jina_module: ModuleType) -> None: auth = jina_module.JinaAuth(_credentials(api_key="k")) response = MagicMock() diff --git a/api/tests/unit_tests/services/auth/test_watercrawl_auth.py b/api/tests/unit_tests/services/auth/test_watercrawl_auth.py index 1d561731d4c..6d76d046874 100644 --- a/api/tests/unit_tests/services/auth/test_watercrawl_auth.py +++ b/api/tests/unit_tests/services/auth/test_watercrawl_auth.py @@ -99,12 +99,25 @@ class TestWatercrawlAuth: auth_instance.validate_credentials() assert str(exc_info.value) == f"Failed to authorize. Status code: {status_code}. Error: {error_message}" + @patch("services.auth.watercrawl.watercrawl.httpx.get", autospec=True) + def test_should_handle_http_error_with_non_json_text_response(self, mock_get, auth_instance): + """Test handling of known HTTP errors with non-JSON text response.""" + mock_response = MagicMock() + mock_response.status_code = 402 + mock_response.text = "Payment required" + mock_response.json.side_effect = ValueError("Not JSON") + mock_get.return_value = mock_response + + with pytest.raises(Exception) as exc_info: + auth_instance.validate_credentials() + assert str(exc_info.value) == "Failed to authorize. Status code: 402. Error: Payment required" + @pytest.mark.parametrize( ("status_code", "response_text", "has_json_error", "expected_error_contains"), [ (403, '{"error": "Forbidden"}', True, "Failed to authorize. Status code: 403. Error: Forbidden"), (404, "", True, "Unexpected error occurred while trying to authorize. Status code: 404"), - (401, "Not JSON", True, "Expecting value"), # JSON decode error + (401, "Not JSON", True, "Failed to authorize. Status code: 401. Error: Not JSON"), ], ) @patch("services.auth.watercrawl.watercrawl.httpx.get", autospec=True) diff --git a/api/tests/unit_tests/services/enterprise/test_enterprise_service.py b/api/tests/unit_tests/services/enterprise/test_enterprise_service.py index e7efe79af00..f64c7233b9d 100644 --- a/api/tests/unit_tests/services/enterprise/test_enterprise_service.py +++ b/api/tests/unit_tests/services/enterprise/test_enterprise_service.py @@ -500,9 +500,24 @@ class TestIssueMCPToken: "tenant_id": "tenant-uuid", "app_id": "app-uuid", "audience": "https://mcp.example.com/mcp/", + "user_type": "account", }, ) + def test_end_user_type_is_forwarded(self): + with patch(f"{MODULE}.EnterpriseRequest") as req: + req.send_request.return_value = {"token": "t", "expires_at": 1900000000} + EnterpriseService.issue_mcp_token( + user_id="end-user-uuid", + tenant_id="tenant-uuid", + app_id="app-uuid", + audience="https://mcp.example.com/mcp/", + user_type="end_user", + ) + body = req.send_request.call_args.kwargs["json"] + assert body["user_type"] == "end_user" + assert body["app_id"] == "app-uuid" + def test_401_maps_to_identity_refresh_error(self): from services.enterprise.base import MCPIdentityRefreshError from services.errors.enterprise import EnterpriseAPIUnauthorizedError diff --git a/api/tests/unit_tests/services/plugin/test_plugin_auto_upgrade_service.py b/api/tests/unit_tests/services/plugin/test_plugin_auto_upgrade_service.py index 021bebceff3..88d5853a78b 100644 --- a/api/tests/unit_tests/services/plugin/test_plugin_auto_upgrade_service.py +++ b/api/tests/unit_tests/services/plugin/test_plugin_auto_upgrade_service.py @@ -1,8 +1,10 @@ +from types import SimpleNamespace from unittest.mock import MagicMock, patch from models.account import TenantPluginAutoUpgradeStrategy MODULE = "services.plugin.plugin_auto_upgrade_service" +PLUGIN_CATEGORY = TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL def _patched_session(): @@ -25,7 +27,7 @@ class TestGetStrategy: with p1: from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService - result = PluginAutoUpgradeService.get_strategy("t1") + result = PluginAutoUpgradeService.get_strategy("t1", PLUGIN_CATEGORY) assert result is strategy @@ -36,7 +38,7 @@ class TestGetStrategy: with p1: from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService - result = PluginAutoUpgradeService.get_strategy("t1") + result = PluginAutoUpgradeService.get_strategy("t1", PLUGIN_CATEGORY) assert result is None @@ -57,6 +59,7 @@ class TestChangeStrategy: TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL, [], [], + category=PLUGIN_CATEGORY, ) assert result is True @@ -77,6 +80,7 @@ class TestChangeStrategy: TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL, ["p1"], ["p2"], + category=PLUGIN_CATEGORY, ) assert result is True @@ -96,17 +100,19 @@ class TestExcludePlugin: p1, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls, - patch(f"{MODULE}.PluginAutoUpgradeService.change_strategy") as cs, ): strat_cls.StrategySetting.FIX_ONLY = "fix_only" strat_cls.UpgradeMode.EXCLUDE = "exclude" - cs.return_value = True from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService - result = PluginAutoUpgradeService.exclude_plugin("t1", "plugin-1") + result = PluginAutoUpgradeService.exclude_plugin( + "t1", + "plugin-1", + PLUGIN_CATEGORY, + ) assert result is True - cs.assert_called_once() + session.add.assert_called_once() def test_appends_to_exclude_list_in_exclude_mode(self): p1, session = _patched_session() @@ -121,7 +127,7 @@ class TestExcludePlugin: strat_cls.UpgradeMode.ALL = "all" from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService - result = PluginAutoUpgradeService.exclude_plugin("t1", "p-new") + result = PluginAutoUpgradeService.exclude_plugin("t1", "p-new", PLUGIN_CATEGORY) assert result is True assert existing.exclude_plugins == ["p-existing", "p-new"] @@ -139,7 +145,7 @@ class TestExcludePlugin: strat_cls.UpgradeMode.ALL = "all" from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService - result = PluginAutoUpgradeService.exclude_plugin("t1", "p1") + result = PluginAutoUpgradeService.exclude_plugin("t1", "p1", PLUGIN_CATEGORY) assert result is True assert existing.include_plugins == ["p2"] @@ -156,7 +162,7 @@ class TestExcludePlugin: strat_cls.UpgradeMode.ALL = "all" from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService - result = PluginAutoUpgradeService.exclude_plugin("t1", "p1") + result = PluginAutoUpgradeService.exclude_plugin("t1", "p1", PLUGIN_CATEGORY) assert result is True assert existing.upgrade_mode == "exclude" @@ -175,6 +181,101 @@ class TestExcludePlugin: strat_cls.UpgradeMode.ALL = "all" from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService - PluginAutoUpgradeService.exclude_plugin("t1", "p1") + PluginAutoUpgradeService.exclude_plugin("t1", "p1", PLUGIN_CATEGORY) assert existing.exclude_plugins == ["p1"] + + +class TestBackfillStrategyCategories: + def test_creates_default_missing_categories_without_fetching_daemon(self): + p1, session = _patched_session() + tool_strategy = SimpleNamespace( + category=TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL, + strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY, + upgrade_time_of_day=0, + upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE, + exclude_plugins=[], + include_plugins=[], + ) + session.scalars.return_value.all.return_value = [tool_strategy] + installer = MagicMock() + + with p1, patch(f"{MODULE}.PluginInstaller", return_value=installer): + from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService + + result = PluginAutoUpgradeService.backfill_strategy_categories("t1") + expected_time = PluginAutoUpgradeService.default_upgrade_time_of_day("t1") + + assert result.created_count == len(TenantPluginAutoUpgradeStrategy.PluginCategory) - 1 + assert result.normalized is False + installer.list_plugins.assert_not_called() + assert tool_strategy.upgrade_time_of_day == expected_time + created_strategies = [call.args[0] for call in session.add.call_args_list] + model_strategy = next( + strategy + for strategy in created_strategies + if strategy.category == TenantPluginAutoUpgradeStrategy.PluginCategory.MODEL + ) + assert model_strategy.strategy_setting == TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST + assert model_strategy.upgrade_time_of_day == expected_time + + def test_default_upgrade_time_is_aligned_to_fifteen_minutes(self): + from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService + + default_time = PluginAutoUpgradeService.default_upgrade_time_of_day("t1") + + assert default_time % (15 * 60) == 0 + assert 0 <= default_time < 24 * 60 * 60 + + def test_creates_missing_categories_and_splits_known_plugins(self): + p1, session = _patched_session() + tool_strategy = SimpleNamespace( + category=TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL, + strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY, + upgrade_time_of_day=0, + upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE, + exclude_plugins=["tool-plugin", "model-plugin", "unknown-plugin"], + include_plugins=["model-plugin", "tool-plugin"], + ) + model_strategy = SimpleNamespace( + category=TenantPluginAutoUpgradeStrategy.PluginCategory.MODEL, + strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY, + upgrade_time_of_day=0, + upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE, + exclude_plugins=["tool-plugin", "model-plugin", "unknown-plugin"], + include_plugins=["model-plugin", "tool-plugin"], + ) + session.scalars.return_value.all.return_value = [tool_strategy, model_strategy] + + installed_plugins = [ + SimpleNamespace( + plugin_id="tool-plugin", + declaration=SimpleNamespace(category=TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL), + ), + SimpleNamespace( + plugin_id="model-plugin", + declaration=SimpleNamespace(category=TenantPluginAutoUpgradeStrategy.PluginCategory.MODEL), + ), + ] + installer = MagicMock() + installer.list_plugins.return_value = installed_plugins + + with p1, patch(f"{MODULE}.PluginInstaller", return_value=installer), patch(f"{MODULE}.logger") as logger: + from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService + + result = PluginAutoUpgradeService.backfill_strategy_categories("t1") + + assert result.created_count == len(TenantPluginAutoUpgradeStrategy.PluginCategory) - 2 + assert result.normalized is True + assert session.add.call_count == len(TenantPluginAutoUpgradeStrategy.PluginCategory) - 2 + assert tool_strategy.exclude_plugins == ["tool-plugin"] + assert tool_strategy.include_plugins == ["tool-plugin"] + assert model_strategy.exclude_plugins == ["model-plugin"] + assert model_strategy.include_plugins == ["model-plugin"] + logger.warning.assert_called_once_with( + "Skipped unknown plugin IDs while backfilling plugin auto-upgrade strategies: " + "tenant_id=%s, field=%s, plugin_ids=%s", + "t1", + "exclude_plugins", + ["unknown-plugin"], + ) diff --git a/api/tests/unit_tests/services/plugin/test_plugin_service.py b/api/tests/unit_tests/services/plugin/test_plugin_service.py index f7b89401180..fca05f94fc7 100644 --- a/api/tests/unit_tests/services/plugin/test_plugin_service.py +++ b/api/tests/unit_tests/services/plugin/test_plugin_service.py @@ -1,8 +1,9 @@ import datetime import uuid from types import SimpleNamespace -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import MagicMock, Mock, call, patch +import pytest from pydantic import TypeAdapter from redis import RedisError @@ -13,6 +14,15 @@ from graphon.model_runtime.entities.provider_entities import ConfigurateMethod, MODULE = "core.plugin.plugin_service" +@pytest.fixture(autouse=True) +def clear_plugin_model_provider_memory_cache() -> None: + from core.plugin.plugin_service import PluginService + + PluginService._plugin_model_providers_memory_cache.clear() + yield + PluginService._plugin_model_providers_memory_cache.clear() + + class _FakeSession: def __init__(self) -> None: self.execute = Mock() @@ -68,6 +78,17 @@ def _build_install_task(*, task_id: str = "task-1", status: PluginInstallTaskSta ) +def _provider_cache_key(tenant_id: str, generation: int | None = None) -> str: + if generation is None: + return f"plugin_model_providers:tenant_id:{tenant_id}" + + return f"plugin_model_providers:tenant_id:{tenant_id}:generation:{generation}" + + +def _provider_generation_key(tenant_id: str) -> str: + return f"plugin_model_providers_generation:tenant_id:{tenant_id}" + + class TestFetchLatestPluginVersion: def test_skips_marketplace_fetch_when_disabled(self) -> None: """Cache misses stay None; marketplace is never called when disabled.""" @@ -120,9 +141,13 @@ class TestPluginModelProviderCache: """A valid tenant cache entry is reused across runtime calls without plugin daemon access.""" cached_provider = _build_provider_entity() cached_payload = TypeAdapter(list[ProviderEntity]).dump_json([cached_provider]).decode("utf-8") + generation_key = _provider_generation_key("tenant-1") + cache_key = _provider_cache_key("tenant-1", 0) + legacy_cache_key = _provider_cache_key("tenant-1") with patch(f"{MODULE}.redis_client") as redis_client: - redis_client.get.return_value = cached_payload + redis_client.get.return_value = None + redis_client.mget.return_value = [cached_payload, None] from core.plugin.plugin_service import PluginService @@ -132,14 +157,20 @@ class TestPluginModelProviderCache: assert [provider.provider for provider in result] == ["langgenius/openai/openai"] client.fetch_model_providers.assert_not_called() redis_client.setex.assert_not_called() + redis_client.get.assert_called_once_with(generation_key) + redis_client.mget.assert_called_once_with([cache_key, legacy_cache_key]) def test_fetch_plugin_model_providers_deletes_invalid_cache_and_refetches(self) -> None: - """Invalid cache payloads are tenant-scoped invalidated before falling back to the daemon.""" + """Invalid generation-scoped cache payloads are removed before falling back to the daemon.""" + generation_key = _provider_generation_key("tenant-1") + cache_key = _provider_cache_key("tenant-1", 0) + legacy_cache_key = _provider_cache_key("tenant-1") with ( patch(f"{MODULE}.redis_client") as redis_client, patch(f"{MODULE}.dify_config") as mock_config, ): - redis_client.get.return_value = "not-json" + redis_client.get.side_effect = [None, None] + redis_client.mget.return_value = ["not-json", None] mock_config.PLUGIN_MODEL_PROVIDERS_CACHE_TTL = 86400 client = Mock() client.fetch_model_providers.return_value = [_build_plugin_model_provider()] @@ -148,12 +179,13 @@ class TestPluginModelProviderCache: result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client) - cache_key = "plugin_model_providers:tenant_id:tenant-1" redis_client.delete.assert_called_once_with(cache_key) redis_client.setex.assert_called_once() assert redis_client.setex.call_args.args[0] == cache_key assert redis_client.setex.call_args.args[1] == 86400 assert [provider.provider for provider in result] == ["langgenius/openai/openai"] + redis_client.get.assert_has_calls([call(generation_key), call(generation_key)]) + redis_client.mget.assert_called_once_with([cache_key, legacy_cache_key]) def test_fetch_plugin_model_providers_refetches_when_cache_read_fails(self) -> None: """Redis read failures do not block provider discovery for the tenant.""" @@ -169,10 +201,29 @@ class TestPluginModelProviderCache: client.fetch_model_providers.assert_called_once_with("tenant-1") assert [provider.provider for provider in result] == ["langgenius/openai/openai"] + def test_fetch_plugin_model_providers_refetches_when_cached_payload_batch_read_fails(self) -> None: + """Redis mget failures do not block provider discovery for the tenant.""" + cache_key = _provider_cache_key("tenant-1", 0) + legacy_cache_key = _provider_cache_key("tenant-1") + with patch(f"{MODULE}.redis_client") as redis_client: + redis_client.get.return_value = None + redis_client.mget.side_effect = RedisError("redis unavailable") + client = Mock() + client.fetch_model_providers.return_value = [_build_plugin_model_provider()] + + from core.plugin.plugin_service import PluginService + + result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client) + + client.fetch_model_providers.assert_called_once_with("tenant-1") + redis_client.mget.assert_called_once_with([cache_key, legacy_cache_key]) + assert [provider.provider for provider in result] == ["langgenius/openai/openai"] + def test_fetch_plugin_model_providers_returns_fresh_result_when_cache_write_fails(self) -> None: """Redis write failures are non-fatal after fresh provider data has been fetched.""" with patch(f"{MODULE}.redis_client") as redis_client: redis_client.get.return_value = None + redis_client.mget.return_value = [None, None] redis_client.setex.side_effect = RedisError("redis unavailable") client = Mock() client.fetch_model_providers.return_value = [_build_plugin_model_provider()] @@ -191,6 +242,7 @@ class TestPluginModelProviderCache: patch(f"{MODULE}.PluginModelClient") as client_cls, ): redis_client.get.return_value = None + redis_client.mget.return_value = [None, None] client = client_cls.return_value client.fetch_model_providers.return_value = [_build_plugin_model_provider()] @@ -202,23 +254,98 @@ class TestPluginModelProviderCache: client.fetch_model_providers.assert_called_once_with("tenant-1") assert [provider.provider for provider in result] == ["langgenius/openai/openai"] - def test_invalidate_plugin_model_providers_cache_uses_tenant_cache_key(self) -> None: - with patch(f"{MODULE}.redis_client") as redis_client: + def test_fetch_plugin_model_providers_reuses_process_local_cache(self) -> None: + generation_key = _provider_generation_key("tenant-1") + with ( + patch(f"{MODULE}.redis_client") as redis_client, + patch(f"{MODULE}.PluginModelClient") as client_cls, + ): + redis_client.get.side_effect = [None, None, None] + redis_client.mget.return_value = [None, None] + client = client_cls.return_value + client.fetch_model_providers.return_value = [_build_plugin_model_provider()] + from core.plugin.plugin_service import PluginService - PluginService.invalidate_plugin_model_providers_cache("tenant-1") + first_result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1") + redis_client.get.reset_mock() + redis_client.mget.reset_mock() + redis_client.setex.reset_mock() + client.fetch_model_providers.reset_mock() - redis_client.delete.assert_called_once_with("plugin_model_providers:tenant_id:tenant-1") + second_result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1") - def test_invalidate_plugin_model_providers_cache_ignores_redis_delete_failure(self) -> None: + redis_client.get.assert_called_once_with(generation_key) + redis_client.mget.assert_not_called() + redis_client.setex.assert_not_called() + client.fetch_model_providers.assert_not_called() + assert [provider.provider for provider in second_result] == ["langgenius/openai/openai"] + assert second_result[0] == first_result[0] + assert second_result[0] is not first_result[0] + + def test_invalidate_plugin_model_providers_cache_uses_redis_pipeline(self) -> None: with patch(f"{MODULE}.redis_client") as redis_client: - redis_client.delete.side_effect = RedisError("redis unavailable") + pipe = redis_client.pipeline.return_value from core.plugin.plugin_service import PluginService PluginService.invalidate_plugin_model_providers_cache("tenant-1") - redis_client.delete.assert_called_once_with("plugin_model_providers:tenant_id:tenant-1") + redis_client.pipeline.assert_called_once_with(transaction=False) + pipe.delete.assert_called_once_with(_provider_cache_key("tenant-1")) + pipe.incr.assert_called_once_with(_provider_generation_key("tenant-1")) + pipe.execute.assert_called_once_with() + + def test_invalidate_plugin_model_providers_cache_ignores_redis_pipeline_failure(self) -> None: + with patch(f"{MODULE}.redis_client") as redis_client: + pipe = redis_client.pipeline.return_value + pipe.execute.side_effect = RedisError("redis unavailable") + + from core.plugin.plugin_service import PluginService + + PluginService.invalidate_plugin_model_providers_cache("tenant-1") + + redis_client.pipeline.assert_called_once_with(transaction=False) + pipe.delete.assert_called_once_with(_provider_cache_key("tenant-1")) + pipe.incr.assert_called_once_with(_provider_generation_key("tenant-1")) + pipe.execute.assert_called_once_with() + + def test_invalidate_plugin_model_providers_cache_clears_process_local_cache(self) -> None: + with patch(f"{MODULE}.redis_client") as redis_client: + pipe = redis_client.pipeline.return_value + + from core.plugin.plugin_service import PluginService + + PluginService._store_in_memory_plugin_model_providers("tenant-1", 0, [_build_provider_entity()]) + PluginService.invalidate_plugin_model_providers_cache("tenant-1") + + assert PluginService._plugin_model_providers_memory_cache == {} + redis_client.pipeline.assert_called_once_with(transaction=False) + pipe.delete.assert_called_once_with(_provider_cache_key("tenant-1")) + pipe.incr.assert_called_once_with(_provider_generation_key("tenant-1")) + pipe.execute.assert_called_once_with() + + def test_fetch_plugin_model_providers_ignores_stale_process_local_cache_after_generation_bump(self) -> None: + generation_key = _provider_generation_key("tenant-1") + new_cache_key = _provider_cache_key("tenant-1", 1) + with patch(f"{MODULE}.redis_client") as redis_client: + redis_client.get.side_effect = [b"1", b"1"] + redis_client.mget.return_value = [None] + client = Mock() + client.fetch_model_providers.return_value = [_build_plugin_model_provider(provider="anthropic")] + + from core.plugin.plugin_service import PluginService + + PluginService._store_in_memory_plugin_model_providers("tenant-1", 0, [_build_provider_entity()]) + result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client) + + client.fetch_model_providers.assert_called_once_with("tenant-1") + redis_client.get.assert_has_calls([call(generation_key), call(generation_key)]) + redis_client.mget.assert_called_once_with([new_cache_key]) + redis_client.setex.assert_called_once() + assert redis_client.setex.call_args.args[0] == new_cache_key + assert PluginService._plugin_model_providers_memory_cache["tenant-1"][0] == 1 + assert [provider.provider for provider in result] == ["langgenius/anthropic/anthropic"] class TestPluginListEndpointCounts: diff --git a/api/tests/unit_tests/services/rag_pipeline/pipeline_template/test_customized_retrieval.py b/api/tests/unit_tests/services/rag_pipeline/pipeline_template/test_customized_retrieval.py index 647a2f0bfc9..106b959a78b 100644 --- a/api/tests/unit_tests/services/rag_pipeline/pipeline_template/test_customized_retrieval.py +++ b/api/tests/unit_tests/services/rag_pipeline/pipeline_template/test_customized_retrieval.py @@ -5,10 +5,6 @@ from services.rag_pipeline.pipeline_template.pipeline_template_type import Pipel def test_get_pipeline_templates(mocker) -> None: - mocker.patch( - "services.rag_pipeline.pipeline_template.customized.customized_retrieval.current_account_with_tenant", - return_value=("account-id", "tenant-id"), - ) customized_template = SimpleNamespace( id="tpl-1", name="Custom Template", @@ -27,7 +23,7 @@ def test_get_pipeline_templates(mocker) -> None: ) retrieval = CustomizedPipelineTemplateRetrieval() - result = retrieval.get_pipeline_templates("en-US") + result = retrieval.get_pipeline_templates("en-US", "tenant-id") assert retrieval.get_type() == PipelineTemplateType.CUSTOMIZED assert result == { diff --git a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_service.py b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_service.py index efb79aadde2..b255595047d 100644 --- a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_service.py +++ b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_service.py @@ -1,9 +1,14 @@ +import json import time +from datetime import datetime from types import SimpleNamespace import pytest from sqlalchemy.orm import sessionmaker +from models import Account, Tenant +from models.dataset import Dataset, Pipeline, PipelineCustomizedTemplate, PipelineRecommendedPlugin +from models.workflow import Workflow from services.entities.knowledge_entities.rag_pipeline_entities import IconInfo, PipelineTemplateInfoEntity from services.rag_pipeline.rag_pipeline import RagPipelineService @@ -25,6 +30,89 @@ class MockRepo: pass +def _make_account(account_id: str = "u1", tenant_id: str = "t1") -> Account: + account = Account(name="Test User", email=f"{account_id}@example.com") + account.id = account_id + tenant = Tenant(name="Test Tenant") + tenant.id = tenant_id + account._current_tenant = tenant + return account + + +def _make_pipeline( + *, + pipeline_id: str = "p1", + tenant_id: str = "t1", + workflow_id: str | None = None, + is_published: bool = False, +) -> Pipeline: + pipeline = Pipeline(tenant_id=tenant_id, name="Test Pipeline", description="test") + pipeline.id = pipeline_id + pipeline.workflow_id = workflow_id + pipeline.is_published = is_published + return pipeline + + +def _make_workflow( + *, + workflow_id: str = "wf-1", + tenant_id: str = "t1", + app_id: str = "p1", + graph: dict[str, object] | None = None, + features: dict[str, object] | None = None, + created_by: str = "u1", +) -> Workflow: + workflow = Workflow( + id=workflow_id, + tenant_id=tenant_id, + app_id=app_id, + type="workflow", + version="draft", + marked_name="", + marked_comment="", + graph=json.dumps(graph or {"nodes": []}), + features=json.dumps(features or {}), + created_by=created_by, + created_at=datetime(2024, 1, 1), + updated_by=None, + updated_at=datetime(2024, 1, 1), + environment_variables=[], + conversation_variables=[], + rag_pipeline_variables=[], + ) + return workflow + + +def _make_dataset(*, dataset_id: str = "d1", pipeline_id: str = "p1", tenant_id: str = "t1") -> Dataset: + dataset = Dataset( + id=dataset_id, + tenant_id=tenant_id, + name="Test Dataset", + created_by="u1", + ) + dataset.pipeline_id = pipeline_id + return dataset + + +def _make_customized_template() -> PipelineCustomizedTemplate: + return PipelineCustomizedTemplate( + tenant_id="t1", + name="old", + description="old", + chunk_structure="paragraph", + icon={}, + position=1, + yaml_content="", + install_count=0, + language="en-US", + created_by="u1", + ) + + +def _make_recommended_plugin(plugin_id: str) -> PipelineRecommendedPlugin: + return PipelineRecommendedPlugin(plugin_id=plugin_id, provider_name=plugin_id, type="tool", position=0, active=True) + + def test_get_pipeline_templates_fallbacks_to_builtin_for_non_english_empty_result(mocker) -> None: mocker.patch("services.rag_pipeline.rag_pipeline.dify_config.HOSTED_FETCH_PIPELINE_TEMPLATES_MODE", "remote") @@ -74,7 +162,7 @@ def test_get_pipeline_template_detail_uses_expected_mode(mocker, template_type: def test_get_published_workflow_returns_none_when_pipeline_has_no_workflow_id(rag_pipeline_service) -> None: - pipeline = SimpleNamespace(workflow_id=None) + pipeline = _make_pipeline(workflow_id=None) result = rag_pipeline_service.get_published_workflow(pipeline) @@ -82,7 +170,7 @@ def test_get_published_workflow_returns_none_when_pipeline_has_no_workflow_id(ra def test_get_all_published_workflow_returns_empty_for_unpublished_pipeline(rag_pipeline_service) -> None: - pipeline = SimpleNamespace(workflow_id=None) + pipeline = _make_pipeline(workflow_id=None) session = SimpleNamespace() workflows, has_more = rag_pipeline_service.get_all_published_workflow( @@ -101,7 +189,7 @@ def test_get_all_published_workflow_returns_empty_for_unpublished_pipeline(rag_p def test_get_all_published_workflow_applies_limit_and_has_more(rag_pipeline_service) -> None: scalars_result = SimpleNamespace(all=lambda: ["wf1", "wf2", "wf3"]) session = SimpleNamespace(scalars=lambda stmt: scalars_result) - pipeline = SimpleNamespace(id="pipeline-1", workflow_id="wf-live") + pipeline = _make_pipeline(pipeline_id="pipeline-1", workflow_id="wf-live") workflows, has_more = rag_pipeline_service.get_all_published_workflow( session=session, @@ -133,8 +221,8 @@ def test_sync_draft_workflow_creates_new_when_none_exists(mocker, rag_pipeline_s mocker.patch("services.rag_pipeline.rag_pipeline.db.session.flush") mocker.patch("services.rag_pipeline.rag_pipeline.db.session.commit") - pipeline = SimpleNamespace(tenant_id="t1", id="p1", workflow_id=None) - account = SimpleNamespace(id="u1") + pipeline = _make_pipeline(workflow_id=None) + account = _make_account() result = rag_pipeline_service.sync_draft_workflow( pipeline=pipeline, @@ -153,11 +241,11 @@ def test_sync_draft_workflow_creates_new_when_none_exists(mocker, rag_pipeline_s def test_sync_draft_workflow_raises_on_hash_mismatch(mocker, rag_pipeline_service) -> None: from services.errors.app import WorkflowHashNotEqualError - existing_wf = SimpleNamespace(unique_hash="hash-old") + existing_wf = _make_workflow(graph={"nodes": [{"id": "old"}]}) mocker.patch.object(rag_pipeline_service, "get_draft_workflow", return_value=existing_wf) - pipeline = SimpleNamespace(tenant_id="t1", id="p1") - account = SimpleNamespace(id="u1") + pipeline = _make_pipeline() + account = _make_account() with pytest.raises(WorkflowHashNotEqualError): rag_pipeline_service.sync_draft_workflow( @@ -184,8 +272,8 @@ def test_sync_draft_workflow_updates_existing(mocker, rag_pipeline_service) -> N mocker.patch.object(rag_pipeline_service, "get_draft_workflow", return_value=existing_wf) mocker.patch("services.rag_pipeline.rag_pipeline.db.session.commit") - pipeline = SimpleNamespace(tenant_id="t1", id="p1") - account = SimpleNamespace(id="u1") + pipeline = _make_pipeline() + account = _make_account() result = rag_pipeline_service.sync_draft_workflow( pipeline=pipeline, @@ -275,7 +363,7 @@ def test_get_rag_pipeline_paginate_workflow_runs_delegates(mocker, rag_pipeline_ repo_mock.get_paginated_workflow_runs.return_value = expected rag_pipeline_service._workflow_run_repo = repo_mock - pipeline = SimpleNamespace(tenant_id="t1", id="p1") + pipeline = _make_pipeline() result = rag_pipeline_service.get_rag_pipeline_paginate_workflow_runs(pipeline, {"limit": 10, "last_id": "abc"}) assert result is expected @@ -297,7 +385,7 @@ def test_get_rag_pipeline_workflow_run_delegates(mocker, rag_pipeline_service) - repo_mock.get_workflow_run_by_id.return_value = expected rag_pipeline_service._workflow_run_repo = repo_mock - pipeline = SimpleNamespace(tenant_id="t1", id="p1") + pipeline = _make_pipeline() result = rag_pipeline_service.get_rag_pipeline_workflow_run(pipeline, "run-1") assert result is expected @@ -310,14 +398,14 @@ def test_get_rag_pipeline_workflow_run_delegates(mocker, rag_pipeline_service) - def test_is_workflow_exist_returns_true_when_draft_exists(mocker, rag_pipeline_service) -> None: mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", return_value=1) - pipeline = SimpleNamespace(tenant_id="t1", id="p1") + pipeline = _make_pipeline() assert rag_pipeline_service.is_workflow_exist(pipeline) is True def test_is_workflow_exist_returns_false_when_no_draft(mocker, rag_pipeline_service) -> None: mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", return_value=0) - pipeline = SimpleNamespace(tenant_id="t1", id="p1") + pipeline = _make_pipeline() assert rag_pipeline_service.is_workflow_exist(pipeline) is False @@ -635,17 +723,10 @@ def test_get_second_step_parameters_success(mocker, rag_pipeline_service) -> Non def test_publish_customized_pipeline_template_success(mocker, rag_pipeline_service) -> None: - from models.dataset import Pipeline - # 1. Setup mocks - pipeline = mocker.Mock(spec=Pipeline) - pipeline.id = "p1" - pipeline.tenant_id = "t1" - pipeline.workflow_id = "wf-1" - pipeline.is_published = True + pipeline = _make_pipeline(workflow_id="wf-1", is_published=True) - workflow = mocker.Mock() - workflow.id = "wf-1" + workflow = _make_workflow(workflow_id="wf-1") # Mock db itself to avoid app context errors mock_db = mocker.patch("services.rag_pipeline.rag_pipeline.db") @@ -656,8 +737,8 @@ def test_publish_customized_pipeline_template_success(mocker, rag_pipeline_servi mock_db.session.scalar.side_effect = [None, 5] # Mock retrieve_dataset - dataset = mocker.Mock() - pipeline.retrieve_dataset.return_value = dataset + dataset = _make_dataset() + dataset.chunk_structure = "paragraph" # Mock RagPipelineDslService mock_dsl_service = mocker.Mock() @@ -665,16 +746,14 @@ def test_publish_customized_pipeline_template_success(mocker, rag_pipeline_servi mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.RagPipelineDslService", return_value=mock_dsl_service) # Mock Session and commit - mocker.patch("services.rag_pipeline.rag_pipeline.Session", return_value=mocker.MagicMock()) + session_factory = mocker.patch("services.rag_pipeline.rag_pipeline.sessionmaker") + session_factory.return_value.begin.return_value.__enter__.return_value.scalar.return_value = dataset - # Mock current_user - mock_user = mocker.Mock() - mock_user.id = "user-123" - mocker.patch("services.rag_pipeline.rag_pipeline.current_user", mock_user) + account = _make_account(account_id="user-123") # 2. Run test args = {"name": "New Template", "description": "Desc", "icon_info": {"icon": "star"}, "tags": ["tag1"]} - rag_pipeline_service.publish_customized_pipeline_template("p1", args) + rag_pipeline_service.publish_customized_pipeline_template("p1", args, account, "t1") # 3. Assertions # Verify a new template was added to session or similar? @@ -687,14 +766,10 @@ def test_publish_customized_pipeline_template_success(mocker, rag_pipeline_servi def test_get_datasource_plugins_success(mocker, rag_pipeline_service) -> None: - from models.dataset import Dataset, Pipeline - # 1. Setup mocks - dataset = mocker.Mock(spec=Dataset) - dataset.pipeline_id = "p1" + dataset = _make_dataset() - pipeline = mocker.Mock(spec=Pipeline) - pipeline.id = "p1" + pipeline = _make_pipeline() workflow = mocker.Mock() workflow.graph_dict = { @@ -835,15 +910,10 @@ def test_set_datasource_variables_success(mocker, rag_pipeline_service) -> None: def test_get_draft_workflow_success(mocker, rag_pipeline_service) -> None: - from models.dataset import Pipeline - from models.workflow import Workflow - # 1. Setup mocks - pipeline = mocker.Mock(spec=Pipeline) - pipeline.id = "p1" - pipeline.tenant_id = "t1" + pipeline = _make_pipeline() - workflow = mocker.Mock(spec=Workflow) + workflow = _make_workflow() mock_db = mocker.patch("services.rag_pipeline.rag_pipeline.db") mock_db.session.scalar.return_value = workflow @@ -856,16 +926,10 @@ def test_get_draft_workflow_success(mocker, rag_pipeline_service) -> None: def test_get_published_workflow_success(mocker, rag_pipeline_service) -> None: - from models.dataset import Pipeline - from models.workflow import Workflow - # 1. Setup mocks - pipeline = mocker.Mock(spec=Pipeline) - pipeline.id = "p1" - pipeline.tenant_id = "t1" - pipeline.workflow_id = "wf-pub" + pipeline = _make_pipeline(workflow_id="wf-pub") - workflow = mocker.Mock(spec=Workflow) + workflow = _make_workflow(workflow_id="wf-pub") mock_db = mocker.patch("services.rag_pipeline.rag_pipeline.db") mock_db.session.scalar.return_value = workflow @@ -896,8 +960,8 @@ def test_get_default_block_config_success(rag_pipeline_service) -> None: def test_publish_workflow_raises_when_draft_workflow_missing(mocker, rag_pipeline_service) -> None: session = mocker.Mock() session.scalar.return_value = None - pipeline = SimpleNamespace(id="p1", tenant_id="t1") - account = SimpleNamespace(id="u1") + pipeline = _make_pipeline() + account = _make_account() with pytest.raises(ValueError, match="No valid workflow found"): rag_pipeline_service.publish_workflow(session=session, pipeline=pipeline, account=account) @@ -929,8 +993,8 @@ def test_get_default_block_config_injects_http_request_filter(mocker, rag_pipeli def test_run_draft_workflow_node_raises_when_workflow_missing(mocker, rag_pipeline_service) -> None: - pipeline = SimpleNamespace(id="p1", tenant_id="t1") - account = SimpleNamespace(id="u1") + pipeline = _make_pipeline() + account = _make_account() mocker.patch.object(rag_pipeline_service, "get_draft_workflow", return_value=None) with pytest.raises(ValueError, match="Workflow not initialized"): @@ -939,8 +1003,8 @@ def test_run_draft_workflow_node_raises_when_workflow_missing(mocker, rag_pipeli def test_run_draft_workflow_node_saves_execution_and_variables(mocker, rag_pipeline_service) -> None: mocker.patch("services.rag_pipeline.rag_pipeline.db", mocker.Mock(engine=mocker.Mock())) - pipeline = SimpleNamespace(id="p1", tenant_id="t1") - account = SimpleNamespace(id="u1") + pipeline = _make_pipeline() + account = _make_account() draft_workflow = mocker.Mock(id="wf-1") draft_workflow.get_node_config_by_id.return_value = {"id": "node-1"} draft_workflow.get_enclosing_node_type_and_id.return_value = ("loop", "enclosing-node") @@ -1163,11 +1227,11 @@ def test_get_second_step_parameters_handles_string_and_list_variable_references( def test_get_rag_pipeline_workflow_run_node_executions_empty_when_run_missing(mocker, rag_pipeline_service) -> None: - pipeline = SimpleNamespace(id="p1", tenant_id="t1") + pipeline = _make_pipeline() mocker.patch.object(rag_pipeline_service, "get_rag_pipeline_workflow_run", return_value=None) result = rag_pipeline_service.get_rag_pipeline_workflow_run_node_executions( - pipeline=pipeline, run_id="run-1", user=SimpleNamespace(id="u1") + pipeline=pipeline, run_id="run-1", user=_make_account() ) assert result == [] @@ -1175,14 +1239,14 @@ def test_get_rag_pipeline_workflow_run_node_executions_empty_when_run_missing(mo def test_get_rag_pipeline_workflow_run_node_executions_returns_sorted_executions(mocker, rag_pipeline_service) -> None: mocker.patch("services.rag_pipeline.rag_pipeline.db", mocker.Mock(engine=mocker.Mock())) - pipeline = SimpleNamespace(id="p1", tenant_id="t1") + pipeline = _make_pipeline() mocker.patch.object(rag_pipeline_service, "get_rag_pipeline_workflow_run", return_value=SimpleNamespace(id="run-1")) repo = mocker.Mock() repo.get_db_models_by_workflow_run.return_value = ["n1", "n2"] mocker.patch("services.rag_pipeline.rag_pipeline.SQLAlchemyWorkflowNodeExecutionRepository", return_value=repo) result = rag_pipeline_service.get_rag_pipeline_workflow_run_node_executions( - pipeline=pipeline, run_id="run-1", user=SimpleNamespace(id="u1") + pipeline=pipeline, run_id="run-1", user=_make_account() ) assert result == ["n1", "n2"] @@ -1192,7 +1256,7 @@ def test_get_recommended_plugins_returns_empty_when_no_active_plugins(mocker, ra mock_db = mocker.patch("services.rag_pipeline.rag_pipeline.db") mock_db.session.scalars.return_value.all.return_value = [] - result = rag_pipeline_service.get_recommended_plugins("all") + result = rag_pipeline_service.get_recommended_plugins("all", _make_account(), "t1") assert result == { "installed_recommended_plugins": [], @@ -1201,11 +1265,10 @@ def test_get_recommended_plugins_returns_empty_when_no_active_plugins(mocker, ra def test_get_recommended_plugins_returns_installed_and_uninstalled(mocker, rag_pipeline_service) -> None: - plugin_a = SimpleNamespace(plugin_id="plugin-a") - plugin_b = SimpleNamespace(plugin_id="plugin-b") + plugin_a = _make_recommended_plugin("plugin-a") + plugin_b = _make_recommended_plugin("plugin-b") mock_db = mocker.patch("services.rag_pipeline.rag_pipeline.db") mock_db.session.scalars.return_value.all.return_value = [plugin_a, plugin_b] - mocker.patch("services.rag_pipeline.rag_pipeline.current_user", SimpleNamespace(id="u1", current_tenant_id="t1")) mocker.patch( "services.rag_pipeline.rag_pipeline.BuiltinToolManageService.list_builtin_tools", return_value=[SimpleNamespace(plugin_id="plugin-a", to_dict=lambda: {"plugin_id": "plugin-a"})], @@ -1215,7 +1278,7 @@ def test_get_recommended_plugins_returns_installed_and_uninstalled(mocker, rag_p return_value=[{"plugin_id": "plugin-b", "name": "Plugin B"}], ) - result = rag_pipeline_service.get_recommended_plugins("custom") + result = rag_pipeline_service.get_recommended_plugins("custom", _make_account(), "t1") assert result["installed_recommended_plugins"] == [{"plugin_id": "plugin-a"}] assert result["uninstalled_recommended_plugins"] == [{"plugin_id": "plugin-b", "name": "Plugin B"}] @@ -1229,8 +1292,8 @@ def test_get_node_last_run_delegates_to_repository(mocker, rag_pipeline_service) "services.rag_pipeline.rag_pipeline.DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository", return_value=repo, ) - pipeline = SimpleNamespace(id="p1", tenant_id="t1") - workflow = SimpleNamespace(id="wf1") + pipeline = _make_pipeline() + workflow = _make_workflow(workflow_id="wf1") result = rag_pipeline_service.get_node_last_run(pipeline, workflow, "node-1") @@ -1572,15 +1635,17 @@ def test_publish_customized_pipeline_template_raises_for_missing_pipeline(mocker mocker.patch("services.rag_pipeline.rag_pipeline.db.session.get", return_value=None) with pytest.raises(ValueError, match="Pipeline not found"): - rag_pipeline_service.publish_customized_pipeline_template("p1", {}) + rag_pipeline_service.publish_customized_pipeline_template("p1", {}, _make_account(), "t1") def test_publish_customized_pipeline_template_raises_for_missing_workflow_id(mocker, rag_pipeline_service) -> None: - pipeline = SimpleNamespace(id="p1", tenant_id="t1", workflow_id=None) + pipeline = _make_pipeline(workflow_id=None) mocker.patch("services.rag_pipeline.rag_pipeline.db.session.get", return_value=pipeline) with pytest.raises(ValueError, match="Pipeline workflow not found"): - rag_pipeline_service.publish_customized_pipeline_template("p1", {"name": "template-name"}) + rag_pipeline_service.publish_customized_pipeline_template( + "p1", {"name": "template-name"}, _make_account(), "t1" + ) def test_get_pipeline_raises_when_dataset_missing(mocker, rag_pipeline_service) -> None: @@ -1630,13 +1695,12 @@ def test_get_pipeline_templates_builtin_en_us_no_fallback(mocker) -> None: def test_update_customized_pipeline_template_commits_when_name_empty(mocker) -> None: - template = SimpleNamespace(name="old", description="old", icon={}, updated_by=None) + template = _make_customized_template() mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", return_value=template) commit = mocker.patch("services.rag_pipeline.rag_pipeline.db.session.commit") - mocker.patch("services.rag_pipeline.rag_pipeline.current_user", SimpleNamespace(id="u1", current_tenant_id="t1")) info = PipelineTemplateInfoEntity(name="", description="updated", icon_info=IconInfo(icon="i")) - result = RagPipelineService.update_customized_pipeline_template("tpl-1", info) + result = RagPipelineService.update_customized_pipeline_template("tpl-1", info, _make_account(), "t1") assert result.description == "updated" commit.assert_called_once() @@ -1644,7 +1708,7 @@ def test_update_customized_pipeline_template_commits_when_name_empty(mocker) -> def test_get_all_published_workflow_without_filters_has_no_more(rag_pipeline_service) -> None: session = SimpleNamespace(scalars=lambda stmt: SimpleNamespace(all=lambda: ["wf1"])) - pipeline = SimpleNamespace(id="p1", workflow_id="wf-live") + pipeline = _make_pipeline(workflow_id="wf-live") workflows, has_more = rag_pipeline_service.get_all_published_workflow( session=session, @@ -1856,38 +1920,34 @@ def test_run_free_workflow_node_delegates_to_handle_result(mocker, rag_pipeline_ def test_publish_customized_pipeline_template_raises_when_workflow_missing(mocker, rag_pipeline_service) -> None: - pipeline = SimpleNamespace(id="p1", tenant_id="t1", workflow_id="wf-1") + pipeline = _make_pipeline(workflow_id="wf-1") mocker.patch("services.rag_pipeline.rag_pipeline.db.session.get", side_effect=[pipeline, None]) with pytest.raises(ValueError, match="Workflow not found"): - rag_pipeline_service.publish_customized_pipeline_template("p1", {}) + rag_pipeline_service.publish_customized_pipeline_template("p1", {}, _make_account(), "t1") def test_publish_customized_pipeline_template_raises_when_dataset_missing(mocker, rag_pipeline_service) -> None: - pipeline = SimpleNamespace(id="p1", tenant_id="t1", workflow_id="wf-1") - workflow = SimpleNamespace(id="wf-1") + pipeline = _make_pipeline(workflow_id="wf-1") + workflow = _make_workflow(workflow_id="wf-1") mock_db = mocker.patch("services.rag_pipeline.rag_pipeline.db") mock_db.engine = mocker.Mock() mock_db.session.get.side_effect = [pipeline, workflow] - session_ctx = mocker.MagicMock() - session_ctx.__enter__.return_value = SimpleNamespace() - session_ctx.__exit__.return_value = False - mocker.patch("services.rag_pipeline.rag_pipeline.Session", return_value=session_ctx) - pipeline.retrieve_dataset = lambda session: None + session_factory = mocker.patch("services.rag_pipeline.rag_pipeline.sessionmaker") + session_factory.return_value.begin.return_value.__enter__.return_value.scalar.return_value = None with pytest.raises(ValueError, match="Dataset not found"): - rag_pipeline_service.publish_customized_pipeline_template("p1", {}) + rag_pipeline_service.publish_customized_pipeline_template("p1", {}, _make_account(), "t1") def test_get_recommended_plugins_skips_manifest_when_missing(mocker, rag_pipeline_service) -> None: - plugin = SimpleNamespace(plugin_id="plugin-a") + plugin = _make_recommended_plugin("plugin-a") mock_db = mocker.patch("services.rag_pipeline.rag_pipeline.db") mock_db.session.scalars.return_value.all.return_value = [plugin] - mocker.patch("services.rag_pipeline.rag_pipeline.current_user", SimpleNamespace(id="u1", current_tenant_id="t1")) mocker.patch("services.rag_pipeline.rag_pipeline.BuiltinToolManageService.list_builtin_tools", return_value=[]) mocker.patch("services.rag_pipeline.rag_pipeline.marketplace.batch_fetch_plugin_by_ids", return_value=[]) - result = rag_pipeline_service.get_recommended_plugins("all") + result = rag_pipeline_service.get_recommended_plugins("all", _make_account(), "t1") assert result["installed_recommended_plugins"] == [] assert result["uninstalled_recommended_plugins"] == [] @@ -1918,8 +1978,8 @@ def test_retry_error_document_raises_when_workflow_missing(mocker, rag_pipeline_ def test_get_datasource_plugins_returns_empty_for_non_datasource_nodes(mocker, rag_pipeline_service) -> None: - dataset = SimpleNamespace(pipeline_id="p1") - pipeline = SimpleNamespace(id="p1", tenant_id="t1") + dataset = _make_dataset() + pipeline = _make_pipeline() workflow = SimpleNamespace( graph_dict={"nodes": [{"id": "n1", "data": {"type": "start"}}]}, rag_pipeline_variables=[] ) @@ -2079,8 +2139,8 @@ def test_set_datasource_variables_raises_when_workflow_missing(mocker, rag_pipel def test_get_datasource_plugins_handles_empty_datasource_data_and_non_published(mocker, rag_pipeline_service) -> None: - dataset = SimpleNamespace(pipeline_id="p1") - pipeline = SimpleNamespace(id="p1", tenant_id="t1") + dataset = _make_dataset() + pipeline = _make_pipeline() workflow = SimpleNamespace( graph_dict={"nodes": [{"id": "n1", "data": {"type": "datasource", "datasource_parameters": {}}}]}, rag_pipeline_variables=[{"variable": "v1", "belong_to_node_id": "shared"}], @@ -2097,8 +2157,8 @@ def test_get_datasource_plugins_handles_empty_datasource_data_and_non_published( def test_get_datasource_plugins_extracts_user_inputs_and_credentials(mocker, rag_pipeline_service) -> None: - dataset = SimpleNamespace(pipeline_id="p1") - pipeline = SimpleNamespace(id="p1", tenant_id="t1") + dataset = _make_dataset() + pipeline = _make_pipeline() workflow = SimpleNamespace( graph_dict={ "nodes": [ @@ -2139,8 +2199,8 @@ def test_get_datasource_plugins_extracts_user_inputs_and_credentials(mocker, rag def test_get_pipeline_returns_pipeline_when_found(mocker, rag_pipeline_service) -> None: - dataset = SimpleNamespace(pipeline_id="p1") - pipeline = SimpleNamespace(id="p1") + dataset = _make_dataset() + pipeline = _make_pipeline() mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", side_effect=[dataset, pipeline]) result = rag_pipeline_service.get_pipeline("t1", "d1") diff --git a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_transform_service.py b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_transform_service.py index 3f511a109ae..f6a3f524fef 100644 --- a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_transform_service.py +++ b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_transform_service.py @@ -67,7 +67,7 @@ def test_deal_file_extensions_returns_original_when_empty() -> None: assert result is node -def test_deal_dependencies_installs_missing_marketplace_plugins(mocker) -> None: +def test_deal_dependencies_installs_missing_marketplace_plugins(mocker: MockerFixture) -> None: service = RagPipelineTransformService() installer_cls = mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginInstaller") @@ -92,7 +92,7 @@ def test_deal_dependencies_installs_missing_marketplace_plugins(mocker) -> None: install_mock.assert_called_once_with("tenant-1", ["missing-plugin:1.0.0"]) -def test_transform_to_empty_pipeline_updates_dataset_and_commits(mocker) -> None: +def test_transform_to_empty_pipeline_updates_dataset_and_commits(mocker: MockerFixture) -> None: service = RagPipelineTransformService() mocker.patch( "services.rag_pipeline.rag_pipeline_transform_service.current_user", @@ -142,7 +142,7 @@ def test_transform_to_empty_pipeline_updates_dataset_and_commits(mocker) -> None # --- transform_dataset --- -def test_transform_dataset_returns_early_when_pipeline_exists(mocker) -> None: +def test_transform_dataset_returns_early_when_pipeline_exists(mocker: MockerFixture) -> None: service = RagPipelineTransformService() dataset = SimpleNamespace( id="d1", @@ -151,30 +151,21 @@ def test_transform_dataset_returns_early_when_pipeline_exists(mocker) -> None: ) session_mock = mocker.Mock() session_mock.get.return_value = dataset - mocker.patch( - "services.rag_pipeline.rag_pipeline_transform_service.db", - new=SimpleNamespace(session=session_mock), - ) - result = service.transform_dataset("d1") + result = service.transform_dataset("d1", session_mock) assert result == {"pipeline_id": "p1", "dataset_id": "d1", "status": "success"} -def test_transform_dataset_raises_for_dataset_not_found(mocker) -> None: +def test_transform_dataset_raises_for_dataset_not_found(mocker: MockerFixture) -> None: service = RagPipelineTransformService() session_mock = mocker.Mock() session_mock.get.return_value = None - mocker.patch( - "services.rag_pipeline.rag_pipeline_transform_service.db", - new=SimpleNamespace(session=session_mock), - ) - with pytest.raises(ValueError, match="Dataset not found"): - service.transform_dataset("d1") + service.transform_dataset("d1", session_mock) -def test_transform_dataset_raises_for_external_dataset(mocker) -> None: +def test_transform_dataset_raises_for_external_dataset(mocker: MockerFixture) -> None: service = RagPipelineTransformService() dataset = SimpleNamespace( id="d1", @@ -184,16 +175,12 @@ def test_transform_dataset_raises_for_external_dataset(mocker) -> None: ) session_mock = mocker.Mock() session_mock.get.return_value = dataset - mocker.patch( - "services.rag_pipeline.rag_pipeline_transform_service.db", - new=SimpleNamespace(session=session_mock), - ) with pytest.raises(ValueError, match="External dataset is not supported"): - service.transform_dataset("d1") + service.transform_dataset("d1", session_mock) -def test_transform_dataset_calls_empty_pipeline_when_no_datasource(mocker) -> None: +def test_transform_dataset_calls_empty_pipeline_when_no_datasource(mocker: MockerFixture) -> None: service = RagPipelineTransformService() dataset = SimpleNamespace( id="d1", @@ -205,20 +192,16 @@ def test_transform_dataset_calls_empty_pipeline_when_no_datasource(mocker) -> No ) session_mock = mocker.Mock() session_mock.get.return_value = dataset - mocker.patch( - "services.rag_pipeline.rag_pipeline_transform_service.db", - new=SimpleNamespace(session=session_mock), - ) empty_result = {"pipeline_id": "p-empty", "dataset_id": "d1", "status": "success"} mocker.patch.object(service, "_transform_to_empty_pipeline", return_value=empty_result) - result = service.transform_dataset("d1") + result = service.transform_dataset("d1", session_mock) assert result == empty_result -def test_transform_dataset_calls_empty_pipeline_when_no_doc_form(mocker) -> None: +def test_transform_dataset_calls_empty_pipeline_when_no_doc_form(mocker: MockerFixture) -> None: service = RagPipelineTransformService() dataset = SimpleNamespace( id="d1", @@ -231,15 +214,11 @@ def test_transform_dataset_calls_empty_pipeline_when_no_doc_form(mocker) -> None ) session_mock = mocker.Mock() session_mock.get.return_value = dataset - mocker.patch( - "services.rag_pipeline.rag_pipeline_transform_service.db", - new=SimpleNamespace(session=session_mock), - ) empty_result = {"pipeline_id": "p-empty", "dataset_id": "d1", "status": "success"} mocker.patch.object(service, "_transform_to_empty_pipeline", return_value=empty_result) - result = service.transform_dataset("d1") + result = service.transform_dataset("d1", session_mock) assert result == empty_result @@ -247,7 +226,7 @@ def test_transform_dataset_calls_empty_pipeline_when_no_doc_form(mocker) -> None # --- _deal_knowledge_index --- -def test_deal_knowledge_index_high_quality_sets_embedding(mocker) -> None: +def test_deal_knowledge_index_high_quality_sets_embedding(mocker: MockerFixture) -> None: service = RagPipelineTransformService() dataset = cast( Dataset, @@ -299,7 +278,7 @@ def test_deal_knowledge_index_high_quality_sets_embedding(mocker) -> None: # --- _deal_document_data --- -def test_deal_document_data_notion(mocker) -> None: +def test_deal_document_data_notion(mocker: MockerFixture) -> None: service = RagPipelineTransformService() dataset = SimpleNamespace(id="d1", pipeline_id="p1") doc = SimpleNamespace( @@ -324,12 +303,8 @@ def test_deal_document_data_notion(mocker) -> None: session_mock = mocker.Mock() session_mock.scalars.return_value = scalars_mock add_mock = session_mock.add - mocker.patch( - "services.rag_pipeline.rag_pipeline_transform_service.db", - new=SimpleNamespace(session=session_mock), - ) - service._deal_document_data(cast(Dataset, dataset)) + service._deal_document_data(cast(Dataset, dataset), session_mock) assert doc.data_source_type == "online_document" assert "page1" in doc.data_source_info @@ -337,7 +312,7 @@ def test_deal_document_data_notion(mocker) -> None: @pytest.mark.parametrize(("provider", "node_id"), [("firecrawl", "1752565402678"), ("jinareader", "1752491761974")]) -def test_deal_document_data_website(mocker, provider: str, node_id: str) -> None: +def test_deal_document_data_website(mocker: MockerFixture, provider: str, node_id: str) -> None: service = RagPipelineTransformService() dataset = SimpleNamespace(id="d1", pipeline_id="p1") doc = SimpleNamespace( @@ -359,12 +334,8 @@ def test_deal_document_data_website(mocker, provider: str, node_id: str) -> None session_mock = mocker.Mock() session_mock.scalars.return_value = scalars_mock add_mock = session_mock.add - mocker.patch( - "services.rag_pipeline.rag_pipeline_transform_service.db", - new=SimpleNamespace(session=session_mock), - ) - service._deal_document_data(cast(Dataset, dataset)) + service._deal_document_data(cast(Dataset, dataset), session_mock) assert doc.data_source_type == "website_crawl" assert "example.com" in doc.data_source_info @@ -376,7 +347,7 @@ def test_deal_document_data_website(mocker, provider: str, node_id: str) -> None # --- transform_dataset complex flow --- -def test_transform_dataset_full_flow(mocker) -> None: +def test_transform_dataset_full_flow(mocker: MockerFixture) -> None: service = RagPipelineTransformService() dataset = SimpleNamespace( id="d1", @@ -398,10 +369,6 @@ def test_transform_dataset_full_flow(mocker) -> None: session_mock = mocker.Mock() session_mock.get.return_value = dataset - mocker.patch( - "services.rag_pipeline.rag_pipeline_transform_service.db", - new=SimpleNamespace(session=session_mock), - ) mocker.patch.object(service, "_deal_dependencies") mocker.patch.object(service, "_deal_document_data") @@ -414,14 +381,14 @@ def test_transform_dataset_full_flow(mocker) -> None: pipeline = SimpleNamespace(id="p-new") mocker.patch.object(service, "_create_pipeline", return_value=pipeline) - result = service.transform_dataset("d1") + result = service.transform_dataset("d1", session_mock) assert result["pipeline_id"] == "p-new" assert dataset.runtime_mode == "rag_pipeline" assert dataset.chunk_structure == "text_model" -def test_transform_dataset_raises_for_unsupported_doc_form_after_pipeline_create(mocker) -> None: +def test_transform_dataset_raises_for_unsupported_doc_form_after_pipeline_create(mocker: MockerFixture) -> None: service = RagPipelineTransformService() dataset = SimpleNamespace( id="d1", @@ -447,10 +414,10 @@ def test_transform_dataset_raises_for_unsupported_doc_form_after_pipeline_create mocker.patch.object(service, "_create_pipeline", return_value=SimpleNamespace(id="p-new")) with pytest.raises(ValueError, match="Unsupported doc form"): - service.transform_dataset("d1") + service.transform_dataset("d1", session_mock) -def test_transform_dataset_raises_when_transform_yaml_missing_workflow(mocker) -> None: +def test_transform_dataset_raises_when_transform_yaml_missing_workflow(mocker: MockerFixture) -> None: service = RagPipelineTransformService() dataset = SimpleNamespace( id="d1", @@ -467,15 +434,11 @@ def test_transform_dataset_raises_when_transform_yaml_missing_workflow(mocker) - ) session_mock = mocker.Mock() session_mock.get.return_value = dataset - mocker.patch( - "services.rag_pipeline.rag_pipeline_transform_service.db", - new=SimpleNamespace(session=session_mock), - ) mocker.patch.object(service, "_get_transform_yaml", return_value={}) mocker.patch.object(service, "_deal_dependencies") with pytest.raises(ValueError, match="Missing workflow data for rag pipeline"): - service.transform_dataset("d1") + service.transform_dataset("d1", session_mock) def test_create_pipeline_raises_when_workflow_data_missing() -> None: @@ -485,7 +448,7 @@ def test_create_pipeline_raises_when_workflow_data_missing() -> None: service._create_pipeline({"rag_pipeline": {"name": "N"}}) -def test_deal_document_data_upload_file_with_existing_file(mocker) -> None: +def test_deal_document_data_upload_file_with_existing_file(mocker: MockerFixture) -> None: service = RagPipelineTransformService() dataset = SimpleNamespace(id="d1", pipeline_id="p1") document = SimpleNamespace( @@ -506,12 +469,8 @@ def test_deal_document_data_upload_file_with_existing_file(mocker) -> None: session_mock.scalars.return_value = scalars_mock session_mock.get.return_value = upload_file add_mock = session_mock.add - mocker.patch( - "services.rag_pipeline.rag_pipeline_transform_service.db", - new=SimpleNamespace(session=session_mock), - ) - service._deal_document_data(cast(Dataset, dataset)) + service._deal_document_data(cast(Dataset, dataset), session_mock) assert document.data_source_type == "local_file" assert "real_file_id" in document.data_source_info @@ -522,7 +481,9 @@ def _make_service(): return RagPipelineTransformService.__new__(RagPipelineTransformService) -def test_deal_dependencies_skips_marketplace_when_disabled(mocker: MockerFixture, caplog) -> None: +def test_deal_dependencies_skips_marketplace_when_disabled( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture +) -> None: mocker.patch( "services.rag_pipeline.rag_pipeline_transform_service.dify_config.MARKETPLACE_ENABLED", False, diff --git a/api/tests/unit_tests/services/retention/workflow_run/test_clear_free_plan_expired_workflow_run_logs.py b/api/tests/unit_tests/services/retention/workflow_run/test_clear_free_plan_expired_workflow_run_logs.py index 7d30645d38a..1e15a72f476 100644 --- a/api/tests/unit_tests/services/retention/workflow_run/test_clear_free_plan_expired_workflow_run_logs.py +++ b/api/tests/unit_tests/services/retention/workflow_run/test_clear_free_plan_expired_workflow_run_logs.py @@ -7,15 +7,16 @@ from unittest.mock import MagicMock, patch import pytest +from repositories.api_workflow_run_repository import WorkflowRunCleanupRef from services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs import WorkflowRunCleanup -def make_run(tenant_id: str = "t1", run_id: str = "r1", created_at: datetime.datetime | None = None): - run = MagicMock() - run.tenant_id = tenant_id - run.id = run_id - run.created_at = created_at or datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC) - return run +def make_ref(tenant_id: str = "t1", run_id: str = "r1", created_at: datetime.datetime | None = None): + return WorkflowRunCleanupRef( + id=run_id, + tenant_id=tenant_id, + created_at=created_at or datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC), + ) @pytest.fixture @@ -341,28 +342,28 @@ class TestRunDeleteMode: return WorkflowRunCleanup(days=30, batch_size=10, workflow_run_repo=mock_repo) def test_no_rows_stops_immediately(self, mock_repo): - mock_repo.get_runs_batch_by_time_range.return_value = [] + mock_repo.get_cleanup_refs_batch_by_time_range.return_value = [] c = self._make_cleanup(mock_repo) with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.BILLING_ENABLED = False c.run() - mock_repo.delete_runs_with_related.assert_not_called() + mock_repo.delete_runs_with_related_by_ids.assert_not_called() def test_all_paid_skips_delete(self, mock_repo): - run = make_run("t1") - mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []] + ref = make_ref("t1") + mock_repo.get_cleanup_refs_batch_by_time_range.side_effect = [[ref], []] c = self._make_cleanup(mock_repo) # billing disabled -> all free; but let's override _filter_free_tenants to return empty c._filter_free_tenants = MagicMock(return_value=set()) with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.BILLING_ENABLED = False c.run() - mock_repo.delete_runs_with_related.assert_not_called() + mock_repo.delete_runs_with_related_by_ids.assert_not_called() def test_runs_deleted_successfully(self, mock_repo): - run = make_run("t1") - mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []] - mock_repo.delete_runs_with_related.return_value = { + ref = make_ref("t1") + mock_repo.get_cleanup_refs_batch_by_time_range.side_effect = [[ref], [ref], []] + mock_repo.delete_runs_with_related_by_ids.return_value = { "runs": 1, "node_executions": 0, "offloads": 0, @@ -376,12 +377,12 @@ class TestRunDeleteMode: cfg.BILLING_ENABLED = False with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.time.sleep"): c.run() - mock_repo.delete_runs_with_related.assert_called_once() + mock_repo.delete_runs_with_related_by_ids.assert_called_once() def test_delete_exception_reraises(self, mock_repo): - run = make_run("t1") - mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []] - mock_repo.delete_runs_with_related.side_effect = RuntimeError("db error") + ref = make_ref("t1") + mock_repo.get_cleanup_refs_batch_by_time_range.side_effect = [[ref], [ref]] + mock_repo.delete_runs_with_related_by_ids.side_effect = RuntimeError("db error") c = self._make_cleanup(mock_repo) with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.BILLING_ENABLED = False @@ -389,7 +390,7 @@ class TestRunDeleteMode: c.run() def test_summary_with_window_start(self, mock_repo): - mock_repo.get_runs_batch_by_time_range.return_value = [] + mock_repo.get_cleanup_refs_batch_by_time_range.return_value = [] with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD = 0 cfg.BILLING_ENABLED = False @@ -421,9 +422,10 @@ class TestRunDryRunMode: ) def test_dry_run_no_delete_called(self, mock_repo): - run = make_run("t1") - mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []] - mock_repo.count_runs_with_related.return_value = { + ref = make_ref("t1") + mock_repo.get_cleanup_refs_batch_by_time_range.side_effect = [[ref], [ref], []] + mock_repo.count_runs_with_related_by_ids.return_value = { + "runs": 1, "node_executions": 2, "offloads": 0, "app_logs": 0, @@ -435,11 +437,11 @@ class TestRunDryRunMode: with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.BILLING_ENABLED = False c.run() - mock_repo.delete_runs_with_related.assert_not_called() - mock_repo.count_runs_with_related.assert_called_once() + mock_repo.delete_runs_with_related_by_ids.assert_not_called() + mock_repo.count_runs_with_related_by_ids.assert_called_once() def test_dry_run_summary_with_window_start(self, mock_repo): - mock_repo.get_runs_batch_by_time_range.return_value = [] + mock_repo.get_cleanup_refs_batch_by_time_range.return_value = [] with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD = 0 cfg.BILLING_ENABLED = False @@ -454,14 +456,14 @@ class TestRunDryRunMode: c.run() def test_dry_run_all_paid_skips_count(self, mock_repo): - run = make_run("t1") - mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []] + ref = make_ref("t1") + mock_repo.get_cleanup_refs_batch_by_time_range.side_effect = [[ref], []] c = self._make_dry_cleanup(mock_repo) c._filter_free_tenants = MagicMock(return_value=set()) with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.BILLING_ENABLED = False c.run() - mock_repo.count_runs_with_related.assert_not_called() + mock_repo.count_runs_with_related_by_ids.assert_not_called() # --------------------------------------------------------------------------- @@ -492,7 +494,7 @@ class TestTriggerLogMethods: # --------------------------------------------------------------------------- -# _count_node_executions / _delete_node_executions +# _count_node_executions_by_run_ids / _delete_node_executions_by_run_ids # --------------------------------------------------------------------------- @@ -500,25 +502,23 @@ class TestNodeExecutionMethods: def test_count_node_executions(self, cleanup): session = MagicMock() session.get_bind.return_value = MagicMock() - runs = [make_run("t1", "r1")] with patch( "services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.DifyAPIRepositoryFactory" ) as factory: repo = factory.create_api_workflow_node_execution_repository.return_value repo.count_by_runs.return_value = (10, 2) with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.sessionmaker"): - result = cleanup._count_node_executions(session, runs) + result = cleanup._count_node_executions_by_run_ids(session, ["r1"]) assert result == (10, 2) def test_delete_node_executions(self, cleanup): session = MagicMock() session.get_bind.return_value = MagicMock() - runs = [make_run("t1", "r1")] with patch( "services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.DifyAPIRepositoryFactory" ) as factory: repo = factory.create_api_workflow_node_execution_repository.return_value repo.delete_by_runs.return_value = (5, 1) with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.sessionmaker"): - result = cleanup._delete_node_executions(session, runs) + result = cleanup._delete_node_executions_by_run_ids(session, ["r1"]) assert result == (5, 1) diff --git a/api/tests/unit_tests/services/test_account_service.py b/api/tests/unit_tests/services/test_account_service.py index 626c4b97063..db79cf4cb55 100644 --- a/api/tests/unit_tests/services/test_account_service.py +++ b/api/tests/unit_tests/services/test_account_service.py @@ -65,6 +65,7 @@ class TestAccountAssociatedDataFactory: tenant_join.account_id = account_id tenant_join.current = current tenant_join.role = role + tenant_join.last_opened_at = kwargs.pop("last_opened_at", None) for key, value in kwargs.items(): setattr(tenant_join, key, value) return tenant_join @@ -489,11 +490,13 @@ class TestAccountService: # Mock datetime with ( patch("services.account_service.datetime") as mock_datetime, + patch("services.account_service.naive_utc_now") as mock_naive_utc_now, patch.object(AccountService, "_refresh_account_last_active") as mock_refresh_last_active, ): mock_now = datetime.now() mock_datetime.now.return_value = mock_now mock_datetime.UTC = "UTC" + mock_naive_utc_now.return_value = mock_now # Execute test result = AccountService.load_user("user-123") @@ -501,6 +504,7 @@ class TestAccountService: # Verify results assert result == mock_account assert mock_available_tenant.current is True + assert mock_available_tenant.last_opened_at == mock_now self._assert_database_operations_called(mock_db_dependencies["db"]) mock_refresh_last_active.assert_called_once_with(mock_account) @@ -922,11 +926,16 @@ class TestTenantService: # Mock scalar for the join query mock_db.session.scalar.return_value = mock_tenant_join - # Execute test - TenantService.switch_tenant(mock_account, "tenant-456") + with patch("services.account_service.naive_utc_now") as mock_naive_utc_now: + mock_now = datetime(2026, 6, 5, 11, 0, 0) + mock_naive_utc_now.return_value = mock_now + + # Execute test + TenantService.switch_tenant(mock_account, "tenant-456") # Verify tenant was switched assert mock_tenant_join.current is True + assert mock_tenant_join.last_opened_at == mock_now self._assert_database_operations_called(mock_db) def test_switch_tenant_no_tenant_id(self): diff --git a/api/tests/unit_tests/services/test_agent_app_sandbox_service.py b/api/tests/unit_tests/services/test_agent_app_sandbox_service.py new file mode 100644 index 00000000000..c1ec5cd1ffe --- /dev/null +++ b/api/tests/unit_tests/services/test_agent_app_sandbox_service.py @@ -0,0 +1,313 @@ +"""Unit tests for the Agent App / workflow sandbox services.""" + +from __future__ import annotations + +from collections.abc import Generator +from datetime import datetime + +import pytest +from agenton.compositor import CompositorSessionSnapshot +from agenton.compositor.schemas import LayerSessionSnapshot +from agenton.layers.base import LifecycleState +from dify_agent.protocol import RuntimeLayerSpec, SandboxListResponse, SandboxReadResponse, SandboxUploadResponse +from sqlalchemy import delete + +from core.app.apps.agent_app.session_store import AgentAppSessionScope, StoredAgentAppSession +from core.db.session_factory import session_factory +from models.agent import AgentRuntimeSession, AgentRuntimeSessionOwnerType, AgentRuntimeSessionStatus +from services.agent_app_sandbox_service import ( + AgentAppSandboxService, + AgentSandboxInspectorError, + WorkflowAgentSandboxService, + _default_client_factory, +) + + +def _snapshot(*, session_id: str = "abc1234") -> CompositorSessionSnapshot: + return CompositorSessionSnapshot( + layers=[ + LayerSessionSnapshot(name="execution_context", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}), + LayerSessionSnapshot( + name="shell", + lifecycle_state=LifecycleState.SUSPENDED, + runtime_state={"session_id": session_id, "workspace_cwd": f"~/workspace/{session_id}"}, + ), + ] + ) + + +def _runtime_layer_specs() -> list[RuntimeLayerSpec]: + return [ + RuntimeLayerSpec(name="execution_context", type="dify.execution_context", config={"tenant_id": "tenant-1"}), + RuntimeLayerSpec(name="shell", type="dify.shell", deps={"execution_context": "execution_context"}, config={}), + ] + + +class FakeStore: + def __init__(self, session: StoredAgentAppSession | None) -> None: + self.session = session + self.scope: tuple[str, str, str] | None = None + + def load_active_session_for_conversation(self, *, tenant_id: str, app_id: str, conversation_id: str): + self.scope = (tenant_id, app_id, conversation_id) + return self.session + + +class FakeClient: + def __init__(self) -> None: + self.calls: list[tuple[str, str]] = [] + self.locators: list[object] = [] + + def list_sandbox_files_sync(self, locator, path: str) -> SandboxListResponse: + self.locators.append(locator) + self.calls.append(("list", path)) + return SandboxListResponse(path=path, entries=[], truncated=False) + + def read_sandbox_file_sync(self, locator, path: str, max_bytes: int = 262144) -> SandboxReadResponse: + del max_bytes + self.locators.append(locator) + self.calls.append(("read", path)) + return SandboxReadResponse(path=path, size=5, truncated=False, binary=False, text="hello") + + def upload_sandbox_file_sync(self, locator, path: str) -> SandboxUploadResponse: + self.locators.append(locator) + self.calls.append(("upload", path)) + return SandboxUploadResponse( + path=path, file={"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"} + ) + + +def _stored_session() -> StoredAgentAppSession: + return StoredAgentAppSession( + scope=AgentAppSessionScope( + tenant_id="tenant-1", + app_id="app-1", + conversation_id="conv-1", + agent_id="agent-1", + agent_config_snapshot_id="snapshot-1", + ), + session_snapshot=_snapshot(), + backend_run_id="run-1", + runtime_layer_specs=_runtime_layer_specs(), + ) + + +def test_agent_app_sandbox_service_builds_locator_and_proxies() -> None: + store = FakeStore(_stored_session()) + client = FakeClient() + service = AgentAppSandboxService(session_store=store, client_factory=lambda: client) # type: ignore[arg-type] + + result = service.list_files(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1", path=".") + + assert result.path == "." + assert client.calls == [("list", ".")] + assert store.scope == ("tenant-1", "app-1", "conv-1") + + +def test_agent_app_sandbox_service_raises_when_no_active_session() -> None: + service = AgentAppSandboxService(session_store=FakeStore(None), client_factory=lambda: FakeClient()) # type: ignore[arg-type] + + with pytest.raises(AgentSandboxInspectorError) as exc_info: + service.read_file(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1", path="note.txt") + + assert exc_info.value.code == "no_active_session" + assert exc_info.value.status_code == 404 + + +def test_agent_app_sandbox_service_raises_when_runtime_specs_cannot_build_locator() -> None: + broken_session = StoredAgentAppSession( + scope=AgentAppSessionScope( + tenant_id="tenant-1", + app_id="app-1", + conversation_id="conv-1", + agent_id="agent-1", + agent_config_snapshot_id="snapshot-1", + ), + session_snapshot=_snapshot(), + backend_run_id="run-1", + runtime_layer_specs=[], + ) + service = AgentAppSandboxService(session_store=FakeStore(broken_session), client_factory=lambda: FakeClient()) # type: ignore[arg-type] + + with pytest.raises(AgentSandboxInspectorError) as exc_info: + service.list_files(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1", path=".") + + assert exc_info.value.code == "no_sandbox" + + +def test_default_client_factory_requires_agent_backend_base_url(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("services.agent_app_sandbox_service.dify_config.AGENT_BACKEND_BASE_URL", "") + + with pytest.raises(AgentSandboxInspectorError) as exc_info: + _default_client_factory() + + assert exc_info.value.code == "inspector_unavailable" + assert exc_info.value.status_code == 503 + + +@pytest.fixture +def _runtime_session_table() -> Generator[None, None, None]: + engine = session_factory.get_session_maker().kw["bind"] + AgentRuntimeSession.__table__.create(bind=engine, checkfirst=True) + yield + with session_factory.create_session() as session: + session.execute(delete(AgentRuntimeSession)) + session.commit() + AgentRuntimeSession.__table__.drop(bind=engine, checkfirst=True) + + +def _insert_workflow_session( + *, + runtime_layer_specs: str | None = None, + workflow_run_id: str = "run-1", + node_id: str = "node-1", + node_execution_id: str = "node-exec-1", + binding_id: str = "binding-1", + backend_run_id: str = "backend-run-1", + updated_at: datetime | None = None, + session_id: str = "abc1234", +) -> None: + default_runtime_layer_specs = ( + '[{"name":"execution_context","type":"dify.execution_context","config":{"tenant_id":"tenant-1"}},' + '{"name":"shell","type":"dify.shell","deps":{"execution_context":"execution_context"},"config":{}}]' + ) + with session_factory.create_session() as session: + row = AgentRuntimeSession( + tenant_id="tenant-1", + app_id="app-1", + owner_type=AgentRuntimeSessionOwnerType.WORKFLOW_RUN, + workflow_id="workflow-1", + workflow_run_id=workflow_run_id, + node_id=node_id, + node_execution_id=node_execution_id, + binding_id=binding_id, + agent_id="agent-1", + agent_config_snapshot_id="snapshot-1", + backend_run_id=backend_run_id, + session_snapshot=_snapshot(session_id=session_id).model_dump_json(), + composition_layer_specs=runtime_layer_specs or default_runtime_layer_specs, + status=AgentRuntimeSessionStatus.ACTIVE, + ) + if updated_at is not None: + row.updated_at = updated_at + session.add(row) + session.commit() + + +@pytest.mark.usefixtures("_runtime_session_table") +def test_workflow_sandbox_service_resolves_locator_and_proxies() -> None: + _insert_workflow_session() + client = FakeClient() + service = WorkflowAgentSandboxService(client_factory=lambda: client) # type: ignore[arg-type] + + result = service.upload_file( + tenant_id="tenant-1", + app_id="app-1", + workflow_run_id="run-1", + node_id="node-1", + node_execution_id="node-exec-1", + path="report.txt", + ) + + assert result.file.reference == "dify-file-ref:file-1" + assert client.calls == [("upload", "report.txt")] + + +@pytest.mark.usefixtures("_runtime_session_table") +def test_workflow_sandbox_service_filters_by_node_execution_id() -> None: + _insert_workflow_session( + node_execution_id="node-exec-1", + binding_id="binding-1", + backend_run_id="run-a", + session_id="abc1234", + ) + _insert_workflow_session( + node_execution_id="node-exec-2", + binding_id="binding-2", + backend_run_id="run-b", + session_id="def5678", + ) + client = FakeClient() + service = WorkflowAgentSandboxService(client_factory=lambda: client) # type: ignore[arg-type] + + result = service.read_file( + tenant_id="tenant-1", + app_id="app-1", + workflow_run_id="run-1", + node_id="node-1", + node_execution_id="node-exec-2", + path="out.txt", + ) + + assert result.text == "hello" + assert client.calls == [("read", "out.txt")] + assert client.locators[0].session_snapshot.layers[1].runtime_state["session_id"] == "def5678" + + +@pytest.mark.usefixtures("_runtime_session_table") +def test_workflow_sandbox_service_uses_latest_active_session_when_execution_id_omitted() -> None: + _insert_workflow_session( + node_execution_id="node-exec-1", + binding_id="binding-1", + backend_run_id="run-older", + updated_at=datetime(2026, 1, 1, 0, 0, 0), + session_id="abc1234", + ) + _insert_workflow_session( + node_execution_id="node-exec-2", + binding_id="binding-2", + backend_run_id="run-newer", + updated_at=datetime(2026, 1, 1, 0, 0, 1), + session_id="def5678", + ) + client = FakeClient() + service = WorkflowAgentSandboxService(client_factory=lambda: client) # type: ignore[arg-type] + + result = service.list_files( + tenant_id="tenant-1", + app_id="app-1", + workflow_run_id="run-1", + node_id="node-1", + node_execution_id=None, + path=".", + ) + + assert result.path == "." + assert client.calls == [("list", ".")] + assert client.locators[0].session_snapshot.layers[1].runtime_state["session_id"] == "def5678" + + +@pytest.mark.usefixtures("_runtime_session_table") +def test_workflow_sandbox_service_raises_when_no_active_session() -> None: + service = WorkflowAgentSandboxService(client_factory=lambda: FakeClient()) # type: ignore[arg-type] + + with pytest.raises(AgentSandboxInspectorError) as exc_info: + service.list_files( + tenant_id="tenant-1", + app_id="app-1", + workflow_run_id="run-1", + node_id="node-1", + node_execution_id=None, + path=".", + ) + + assert exc_info.value.code == "no_active_session" + assert exc_info.value.status_code == 404 + + +@pytest.mark.usefixtures("_runtime_session_table") +def test_workflow_sandbox_service_raises_when_runtime_specs_missing() -> None: + _insert_workflow_session(runtime_layer_specs="[]") + service = WorkflowAgentSandboxService(client_factory=lambda: FakeClient()) # type: ignore[arg-type] + + with pytest.raises(AgentSandboxInspectorError) as exc_info: + service.list_files( + tenant_id="tenant-1", + app_id="app-1", + workflow_run_id="run-1", + node_id="node-1", + node_execution_id=None, + path=".", + ) + + assert exc_info.value.code == "no_sandbox" diff --git a/api/tests/unit_tests/services/test_agent_app_workspace_service.py b/api/tests/unit_tests/services/test_agent_app_workspace_service.py deleted file mode 100644 index dd8d69ba88e..00000000000 --- a/api/tests/unit_tests/services/test_agent_app_workspace_service.py +++ /dev/null @@ -1,284 +0,0 @@ -"""Unit tests for the Agent App sandbox workspace inspector service. - -These cover session-id resolution from the conversation snapshot and proxying to -the backend client, with fakes for the session store and client (no DB / no HTTP). -""" - -from __future__ import annotations - -from collections.abc import Generator - -import pytest -from agenton.compositor import CompositorSessionSnapshot -from agenton.compositor.schemas import LayerSessionSnapshot -from agenton.layers.base import LifecycleState -from sqlalchemy import delete - -from clients.agent_backend.workspace_files_client import ( - WorkspaceDownloadResult, - WorkspaceFileEntry, - WorkspaceListResult, - WorkspacePreviewResult, -) -from core.db.session_factory import session_factory -from models.agent import AgentRuntimeSession, AgentRuntimeSessionOwnerType, AgentRuntimeSessionStatus -from services.agent_app_workspace_service import ( - AgentAppWorkspaceService, - AgentWorkspaceInspectorError, - WorkflowAgentWorkspaceService, - _default_client_factory, -) - - -def _snapshot(*, shell: bool = True, session_id: str | None = "abc1234") -> CompositorSessionSnapshot: - layers = [LayerSessionSnapshot(name="history", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={})] - if shell and session_id is not None: - layers.append( - LayerSessionSnapshot( - name="shell", - lifecycle_state=LifecycleState.SUSPENDED, - runtime_state={"session_id": session_id, "workspace_cwd": f"~/workspace/{session_id}"}, - ) - ) - elif shell: - layers.append(LayerSessionSnapshot(name="shell", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={})) - return CompositorSessionSnapshot(layers=layers) - - -class FakeStore: - def __init__(self, snapshot: CompositorSessionSnapshot | None) -> None: - self._snapshot = snapshot - self.scope: tuple[str, str, str] | None = None - - def load_active_snapshot_for_conversation( - self, *, tenant_id: str, app_id: str, conversation_id: str - ) -> CompositorSessionSnapshot | None: - self.scope = (tenant_id, app_id, conversation_id) - return self._snapshot - - -class FakeClient: - def __init__(self) -> None: - self.calls: list[tuple[str, str, str]] = [] - - def list_files(self, session_id: str, path: str) -> WorkspaceListResult: - self.calls.append(("list", session_id, path)) - return WorkspaceListResult( - path=path, entries=[WorkspaceFileEntry(name="a.txt", type="file", size=1, mtime=1)], truncated=False - ) - - def preview(self, session_id: str, path: str) -> WorkspacePreviewResult: - self.calls.append(("preview", session_id, path)) - return WorkspacePreviewResult(path=path, size=5, truncated=False, binary=False, text="hello") - - def download(self, session_id: str, path: str) -> WorkspaceDownloadResult: - self.calls.append(("download", session_id, path)) - return WorkspaceDownloadResult(path=path, size=3, truncated=False, content=b"abc") - - -def _service( - snapshot: CompositorSessionSnapshot | None, -) -> tuple[AgentAppWorkspaceService, FakeClient, FakeStore]: - store = FakeStore(snapshot) - client = FakeClient() - service = AgentAppWorkspaceService(session_store=store, client_factory=lambda: client) # type: ignore[arg-type] - return service, client, store - - -def test_list_resolves_session_id_and_proxies() -> None: - service, client, store = _service(_snapshot(session_id="abc1234")) - - result = service.list_files(tenant_id="t1", app_id="app1", conversation_id="conv1", path="sub") - - assert result.entries[0].name == "a.txt" - assert client.calls == [("list", "abc1234", "sub")] - assert store.scope == ("t1", "app1", "conv1") - - -def test_preview_and_download_use_resolved_session() -> None: - service, client, _ = _service(_snapshot(session_id="abc1234")) - - preview = service.preview(tenant_id="t", app_id="a", conversation_id="c", path="n.txt") - download = service.download(tenant_id="t", app_id="a", conversation_id="c", path="b.bin") - - assert preview.text == "hello" - assert download.content == b"abc" - assert client.calls == [("preview", "abc1234", "n.txt"), ("download", "abc1234", "b.bin")] - - -def test_no_active_session_raises_404() -> None: - service, client, _ = _service(None) - - with pytest.raises(AgentWorkspaceInspectorError) as exc_info: - service.list_files(tenant_id="t", app_id="a", conversation_id="c", path=".") - - assert exc_info.value.code == "no_active_session" - assert exc_info.value.status_code == 404 - assert client.calls == [] - - -def test_snapshot_without_shell_layer_raises_no_sandbox() -> None: - service, _, _ = _service(_snapshot(shell=False)) - - with pytest.raises(AgentWorkspaceInspectorError) as exc_info: - service.list_files(tenant_id="t", app_id="a", conversation_id="c", path=".") - - assert exc_info.value.code == "no_sandbox" - assert exc_info.value.status_code == 404 - - -def test_shell_layer_without_session_id_raises_no_sandbox() -> None: - service, _, _ = _service(_snapshot(session_id=None)) - - with pytest.raises(AgentWorkspaceInspectorError) as exc_info: - service.preview(tenant_id="t", app_id="a", conversation_id="c", path="n.txt") - - assert exc_info.value.code == "no_sandbox" - - -def test_default_client_factory_requires_agent_backend_base_url(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr("services.agent_app_workspace_service.dify_config.AGENT_BACKEND_BASE_URL", "") - - with pytest.raises(AgentWorkspaceInspectorError) as exc_info: - _default_client_factory() - - assert exc_info.value.code == "inspector_unavailable" - assert exc_info.value.status_code == 503 - - -@pytest.fixture -def _runtime_session_table() -> Generator[None, None, None]: - engine = session_factory.get_session_maker().kw["bind"] - AgentRuntimeSession.__table__.create(bind=engine, checkfirst=True) - yield - with session_factory.create_session() as session: - session.execute(delete(AgentRuntimeSession)) - session.commit() - AgentRuntimeSession.__table__.drop(bind=engine, checkfirst=True) - - -def _insert_workflow_session( - *, - workflow_run_id: str = "run-1", - node_id: str = "node-1", - node_execution_id: str = "node-exec-1", - binding_id: str = "binding-1", - session_id: str = "abc1234", -) -> None: - with session_factory.create_session() as session: - session.add( - AgentRuntimeSession( - tenant_id="tenant-1", - app_id="app-1", - owner_type=AgentRuntimeSessionOwnerType.WORKFLOW_RUN, - workflow_id="workflow-1", - workflow_run_id=workflow_run_id, - node_id=node_id, - node_execution_id=node_execution_id, - binding_id=binding_id, - agent_id="agent-1", - agent_config_snapshot_id="snapshot-1", - backend_run_id="backend-run-1", - session_snapshot=_snapshot(session_id=session_id).model_dump_json(), - composition_layer_specs="[]", - status=AgentRuntimeSessionStatus.ACTIVE, - ) - ) - session.commit() - - -@pytest.mark.usefixtures("_runtime_session_table") -def test_workflow_workspace_service_resolves_run_node_session_and_proxies() -> None: - _insert_workflow_session(session_id="def5678") - client = FakeClient() - service = WorkflowAgentWorkspaceService(client_factory=lambda: client) # type: ignore[arg-type] - - result = service.list_files( - tenant_id="tenant-1", - app_id="app-1", - workflow_run_id="run-1", - node_id="node-1", - node_execution_id="node-exec-1", - path=".", - ) - - assert result.entries[0].name == "a.txt" - assert client.calls == [("list", "def5678", ".")] - - -@pytest.mark.usefixtures("_runtime_session_table") -def test_workflow_workspace_service_filters_by_node_execution_id() -> None: - _insert_workflow_session(node_execution_id="node-exec-1", session_id="abc1234") - _insert_workflow_session(node_execution_id="node-exec-2", binding_id="binding-2", session_id="def5678") - client = FakeClient() - service = WorkflowAgentWorkspaceService(client_factory=lambda: client) # type: ignore[arg-type] - - _ = service.preview( - tenant_id="tenant-1", - app_id="app-1", - workflow_run_id="run-1", - node_id="node-1", - node_execution_id="node-exec-2", - path="out.txt", - ) - - assert client.calls == [("preview", "def5678", "out.txt")] - - -@pytest.mark.usefixtures("_runtime_session_table") -def test_workflow_workspace_service_download_uses_latest_active_session_when_execution_id_is_omitted() -> None: - _insert_workflow_session(node_execution_id="node-exec-1", session_id="abc1234") - client = FakeClient() - service = WorkflowAgentWorkspaceService(client_factory=lambda: client) # type: ignore[arg-type] - - result = service.download( - tenant_id="tenant-1", - app_id="app-1", - workflow_run_id="run-1", - node_id="node-1", - node_execution_id=None, - path="out.bin", - ) - - assert result.content == b"abc" - assert client.calls == [("download", "abc1234", "out.bin")] - - -@pytest.mark.usefixtures("_runtime_session_table") -def test_workflow_workspace_service_raises_when_no_active_session_exists() -> None: - client = FakeClient() - service = WorkflowAgentWorkspaceService(client_factory=lambda: client) # type: ignore[arg-type] - - with pytest.raises(AgentWorkspaceInspectorError) as exc_info: - service.list_files( - tenant_id="tenant-1", - app_id="app-1", - workflow_run_id="run-1", - node_id="node-1", - node_execution_id=None, - path=".", - ) - - assert exc_info.value.code == "no_active_session" - assert exc_info.value.status_code == 404 - assert client.calls == [] - - -@pytest.mark.usefixtures("_runtime_session_table") -def test_workflow_workspace_service_raises_when_snapshot_has_no_shell_session() -> None: - _insert_workflow_session(session_id="") - client = FakeClient() - service = WorkflowAgentWorkspaceService(client_factory=lambda: client) # type: ignore[arg-type] - - with pytest.raises(AgentWorkspaceInspectorError) as exc_info: - service.preview( - tenant_id="tenant-1", - app_id="app-1", - workflow_run_id="run-1", - node_id="node-1", - node_execution_id=None, - path="out.txt", - ) - - assert exc_info.value.code == "no_sandbox" - assert client.calls == [] diff --git a/api/tests/unit_tests/services/test_agent_drive_service.py b/api/tests/unit_tests/services/test_agent_drive_service.py index 3ff9726668b..9f8170bfd11 100644 --- a/api/tests/unit_tests/services/test_agent_drive_service.py +++ b/api/tests/unit_tests/services/test_agent_drive_service.py @@ -337,3 +337,166 @@ def test_manifest_download_url_none_when_unresolvable(): ): items = AgentDriveService().manifest(tenant_id=TENANT, agent_id=AGENT, include_download_url=True) assert items[0]["download_url"] is None + + +# ── ENG-625 D5: delete ──────────────────────────────────────────────────────── + + +def test_delete_by_key_cleans_drive_owned_value(): + tf = _seed_tool_file(name="doomed.txt") + _commit("files/doomed.txt", tf, owned=True) + + with patch("services.agent_drive_service.storage") as storage_mock: + removed = AgentDriveService().delete(tenant_id=TENANT, agent_id=AGENT, key="files/doomed.txt") + storage_mock.delete.assert_called_once() + + assert removed == ["files/doomed.txt"] + with session_factory.create_session() as session: + assert session.scalar(select(ToolFile).where(ToolFile.id == tf)) is None + assert list(session.scalars(select(AgentDriveFile))) == [] + + +def test_delete_by_prefix_removes_all_skill_keys(): + md = _seed_tool_file(name="SKILL.md") + zf = _seed_tool_file(name="full.zip") + _commit("tender-analyzer/SKILL.md", md, owned=True) + _commit("tender-analyzer/.DIFY-SKILL-FULL.zip", zf, owned=True) + other = _seed_tool_file(name="other.txt") + _commit("files/other.txt", other, owned=True) + + with patch("services.agent_drive_service.storage"): + removed = AgentDriveService().delete(tenant_id=TENANT, agent_id=AGENT, prefix="tender-analyzer/") + + assert sorted(removed) == ["tender-analyzer/.DIFY-SKILL-FULL.zip", "tender-analyzer/SKILL.md"] + with session_factory.create_session() as session: + # both skill ToolFiles physically removed, the unrelated file untouched + assert session.scalar(select(ToolFile).where(ToolFile.id == md)) is None + assert session.scalar(select(ToolFile).where(ToolFile.id == zf)) is None + assert session.scalar(select(ToolFile).where(ToolFile.id == other)) is not None + keys = [row.key for row in session.scalars(select(AgentDriveFile))] + assert keys == ["files/other.txt"] + + +def test_delete_is_idempotent(): + assert AgentDriveService().delete(tenant_id=TENANT, agent_id=AGENT, key="files/never-there.txt") == [] + assert AgentDriveService().delete(tenant_id=TENANT, agent_id=AGENT, prefix="ghost-skill/") == [] + + +def test_delete_requires_exactly_one_scope(): + with pytest.raises(AgentDriveError) as exc_info: + AgentDriveService().delete(tenant_id=TENANT, agent_id=AGENT) + assert exc_info.value.code == "invalid_delete_scope" + with pytest.raises(AgentDriveError): + AgentDriveService().delete(tenant_id=TENANT, agent_id=AGENT, prefix="a/", key="a/b") + + +def test_delete_keeps_shared_value_records(): + tf = _seed_tool_file(name="shared.txt") + _commit("files/shared.txt", tf, owned=False) + + with patch("services.agent_drive_service.storage") as storage_mock: + removed = AgentDriveService().delete(tenant_id=TENANT, agent_id=AGENT, key="files/shared.txt") + storage_mock.delete.assert_not_called() + + assert removed == ["files/shared.txt"] + with session_factory.create_session() as session: + # only the KV row dropped; the shared ToolFile survives + assert session.scalar(select(ToolFile).where(ToolFile.id == tf)) is not None + + +def test_restandardize_same_slug_overwrites_both_keys_and_cleans_old_toolfiles(): + """ENG-625 §5.3 replacement semantics: re-standardizing a same-name skill + overwrites /SKILL.md and /.DIFY-SKILL-FULL.zip, physically + cleaning both old drive-owned ToolFiles.""" + old_md = _seed_tool_file(name="SKILL.md") + old_zip = _seed_tool_file(name="full-v1.zip") + _commit("pdf-toolkit/SKILL.md", old_md, owned=True) + _commit("pdf-toolkit/.DIFY-SKILL-FULL.zip", old_zip, owned=True) + + new_md = _seed_tool_file(name="SKILL-v2.md") + new_zip = _seed_tool_file(name="full-v2.zip") + with patch("services.agent_drive_service.storage") as storage_mock: + _commit("pdf-toolkit/SKILL.md", new_md, owned=True) + _commit("pdf-toolkit/.DIFY-SKILL-FULL.zip", new_zip, owned=True) + assert storage_mock.delete.call_count == 2 + + with session_factory.create_session() as session: + assert session.scalar(select(ToolFile).where(ToolFile.id == old_md)) is None + assert session.scalar(select(ToolFile).where(ToolFile.id == old_zip)) is None + rows = {row.key: row.file_id for row in session.scalars(select(AgentDriveFile))} + assert rows == { + "pdf-toolkit/SKILL.md": new_md, + "pdf-toolkit/.DIFY-SKILL-FULL.zip": new_zip, + } + + +# ── ENG-624: console drive inspector (service layer) ───────────────────────── + + +def test_preview_returns_text_with_truncation_flags(): + tf = _seed_tool_file(name="SKILL.md") + _commit("pdf-toolkit/SKILL.md", tf) + + with patch("services.agent_drive_service.storage") as storage_mock: + storage_mock.load_stream.return_value = iter([b"# PDF Toolkit\nUse responsibly.\n"]) + result = AgentDriveService().preview(tenant_id=TENANT, agent_id=AGENT, key="pdf-toolkit/SKILL.md") + + assert result == { + "key": "pdf-toolkit/SKILL.md", + "size": 5, + "truncated": False, + "binary": False, + "text": "# PDF Toolkit\nUse responsibly.\n", + } + + +def test_preview_marks_binary_and_oversized_content(): + tf = _seed_tool_file(name="blob.bin") + _commit("files/blob.bin", tf) + + with patch("services.agent_drive_service.storage") as storage_mock: + storage_mock.load_stream.return_value = iter([b"\x00\x01\x02"]) + binary = AgentDriveService().preview(tenant_id=TENANT, agent_id=AGENT, key="files/blob.bin") + assert binary["binary"] is True + assert binary["text"] is None + + with patch("services.agent_drive_service.storage") as storage_mock: + storage_mock.load_stream.return_value = iter([b"x" * (AgentDriveService.PREVIEW_MAX_BYTES + 10)]) + oversized = AgentDriveService().preview(tenant_id=TENANT, agent_id=AGENT, key="files/blob.bin") + assert oversized["truncated"] is True + assert oversized["binary"] is False + assert len(oversized["text"]) == AgentDriveService.PREVIEW_MAX_BYTES + + +def test_preview_unknown_key_is_404(): + with pytest.raises(AgentDriveError) as exc_info: + AgentDriveService().preview(tenant_id=TENANT, agent_id=AGENT, key="ghost/SKILL.md") + assert exc_info.value.code == "drive_key_not_found" + assert exc_info.value.status_code == 404 + + +def test_preview_rejects_cross_tenant_agent(): + with pytest.raises(AgentDriveError) as exc_info: + AgentDriveService().preview( + tenant_id="99999999-9999-9999-9999-999999999999", agent_id=AGENT, key="pdf-toolkit/SKILL.md" + ) + assert exc_info.value.code == "agent_not_found" + + +def test_download_url_signs_external_audience(): + tf = _seed_tool_file(name="full.zip") + _commit("pdf-toolkit/.DIFY-SKILL-FULL.zip", tf) + + with patch.object(AgentDriveService, "_resolve_download_url", return_value="https://signed.example/x") as resolver: + url = AgentDriveService().download_url(tenant_id=TENANT, agent_id=AGENT, key="pdf-toolkit/.DIFY-SKILL-FULL.zip") + + assert url == "https://signed.example/x" + # console downloads are for browsers: external signing, never the internal URL + assert resolver.call_args.kwargs["for_external"] is True + + +def test_manifest_items_carry_created_at_for_inspector(): + tf = _seed_tool_file() + _commit("files/x.txt", tf) + items = AgentDriveService().manifest(tenant_id=TENANT, agent_id=AGENT) + assert items[0]["created_at"] is None or isinstance(items[0]["created_at"], int) diff --git a/api/tests/unit_tests/services/test_app_service.py b/api/tests/unit_tests/services/test_app_service.py index b0edfeaf901..bb764112640 100644 --- a/api/tests/unit_tests/services/test_app_service.py +++ b/api/tests/unit_tests/services/test_app_service.py @@ -1,5 +1,6 @@ from __future__ import annotations +from types import SimpleNamespace from unittest.mock import MagicMock, patch from models.model import App @@ -155,3 +156,83 @@ class TestAgentAppType: app = App() app.mode = AppMode.CHAT assert app.bound_agent_id is None + + def test_update_agent_app_syncs_backing_agent_identity(self): + from models.agent import AgentIconType + from models.model import AppMode, IconType + from services.app_service import AppService + + app = SimpleNamespace( + id="app-1", + tenant_id="tenant-1", + mode=AppMode.AGENT, + name="Old", + description="old", + icon_type=IconType.EMOJI, + icon="robot", + icon_background="#fff", + use_icon_as_answer_icon=False, + max_active_requests=None, + created_by="account-1", + ) + backing_agent = SimpleNamespace( + name="Old", + description="old", + icon_type=AgentIconType.EMOJI, + icon="robot", + icon_background="#fff", + updated_by=None, + updated_at=None, + ) + + with ( + patch("services.app_service.db") as mock_db, + patch("services.app_service.current_user", SimpleNamespace(id="account-2")), + ): + mock_db.session.scalar.return_value = backing_agent + updated_app = AppService().update_app( + app, # type: ignore[arg-type] + { + "name": "Iris", + "description": "agent app", + "icon_type": "image", + "icon": "file-id", + "icon_background": "#123456", + "use_icon_as_answer_icon": False, + "max_active_requests": 0, + }, + ) + + assert updated_app.name == "Iris" + assert backing_agent.name == "Iris" + assert backing_agent.description == "agent app" + assert backing_agent.icon_type == AgentIconType.IMAGE + assert backing_agent.icon == "file-id" + assert backing_agent.icon_background == "#123456" + assert backing_agent.updated_by == "account-2" + assert backing_agent.updated_at == updated_app.updated_at + + def test_delete_agent_app_archives_backing_agent(self): + from models.agent import AgentStatus + from models.model import AppMode + from services.app_service import AppService + + app = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.AGENT) + backing_agent = SimpleNamespace(status=AgentStatus.ACTIVE, archived_by=None, archived_at=None) + + with ( + patch("services.app_service.db") as mock_db, + patch("services.app_service.current_user", SimpleNamespace(id="account-2")), + patch("services.app_service.BillingService"), + patch("services.app_service.EnterpriseService"), + patch("services.app_service.FeatureService"), + patch("services.app_service.dify_config"), + patch("services.app_service.remove_app_and_related_data_task"), + ): + mock_db.session.scalar.return_value = backing_agent + AppService().delete_app(app) # type: ignore[arg-type] + + assert backing_agent.status == AgentStatus.ARCHIVED + assert backing_agent.archived_by == "account-2" + assert backing_agent.archived_at is not None + mock_db.session.delete.assert_called_once_with(app) diff --git a/api/tests/unit_tests/services/test_clear_free_plan_expired_workflow_run_logs.py b/api/tests/unit_tests/services/test_clear_free_plan_expired_workflow_run_logs.py index 6bf78d34117..60488beb248 100644 --- a/api/tests/unit_tests/services/test_clear_free_plan_expired_workflow_run_logs.py +++ b/api/tests/unit_tests/services/test_clear_free_plan_expired_workflow_run_logs.py @@ -3,38 +3,27 @@ from typing import Any import pytest +from repositories.api_workflow_run_repository import WorkflowRunCleanupRef from services.billing_service import SubscriptionPlan from services.retention.workflow_run import clear_free_plan_expired_workflow_run_logs as cleanup_module from services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs import WorkflowRunCleanup -class FakeRun: - def __init__( - self, - run_id: str, - tenant_id: str, - created_at: datetime.datetime, - app_id: str = "app-1", - workflow_id: str = "wf-1", - triggered_from: str = "workflow-run", - ) -> None: - self.id = run_id - self.tenant_id = tenant_id - self.app_id = app_id - self.workflow_id = workflow_id - self.triggered_from = triggered_from - self.created_at = created_at +def make_ref(run_id: str, tenant_id: str, created_at: datetime.datetime) -> WorkflowRunCleanupRef: + return WorkflowRunCleanupRef(id=run_id, tenant_id=tenant_id, created_at=created_at) class FakeRepo: def __init__( self, - batches: list[list[FakeRun]], + batches: list[list[WorkflowRunCleanupRef]], delete_result: dict[str, int] | None = None, count_result: dict[str, int] | None = None, ) -> None: self.batches = batches - self.call_idx = 0 + self.candidate_call_idx = 0 + self.last_candidate_batch: list[WorkflowRunCleanupRef] = [] + self.cleanup_ref_calls: list[dict[str, object]] = [] self.deleted: list[list[str]] = [] self.counted: list[list[str]] = [] self.delete_result = delete_result or { @@ -56,7 +45,7 @@ class FakeRepo: "pause_reasons": 0, } - def get_runs_batch_by_time_range( + def get_cleanup_refs_batch_by_time_range( self, start_from: datetime.datetime | None, end_before: datetime.datetime, @@ -65,27 +54,50 @@ class FakeRepo: run_types=None, tenant_ids=None, workflow_ids=None, - ) -> list[FakeRun]: - if self.call_idx >= len(self.batches): + upper_bound: tuple[datetime.datetime, str] | None = None, + ) -> list[WorkflowRunCleanupRef]: + self.cleanup_ref_calls.append( + { + "start_from": start_from, + "end_before": end_before, + "last_seen": last_seen, + "batch_size": batch_size, + "run_types": run_types, + "tenant_ids": tenant_ids, + "workflow_ids": workflow_ids, + "upper_bound": upper_bound, + } + ) + if tenant_ids is not None or upper_bound is not None: + refs = self.last_candidate_batch + if tenant_ids is not None: + tenant_id_set = set(tenant_ids) + refs = [ref for ref in refs if ref.tenant_id in tenant_id_set] + if upper_bound is not None: + refs = [ref for ref in refs if (ref.created_at, ref.id) <= upper_bound] + return refs[:batch_size] + + if self.candidate_call_idx >= len(self.batches): return [] - batch = self.batches[self.call_idx] - self.call_idx += 1 + batch = self.batches[self.candidate_call_idx] + self.candidate_call_idx += 1 + self.last_candidate_batch = batch return batch - def delete_runs_with_related( - self, runs: list[FakeRun], delete_node_executions=None, delete_trigger_logs=None + def delete_runs_with_related_by_ids( + self, run_ids: list[str], delete_node_executions=None, delete_trigger_logs=None ) -> dict[str, int]: - self.deleted.append([run.id for run in runs]) + self.deleted.append(list(run_ids)) result = self.delete_result.copy() - result["runs"] = len(runs) + result["runs"] = len(run_ids) return result - def count_runs_with_related( - self, runs: list[FakeRun], count_node_executions=None, count_trigger_logs=None + def count_runs_with_related_by_ids( + self, run_ids: list[str], count_node_executions=None, count_trigger_logs=None ) -> dict[str, int]: - self.counted.append([run.id for run in runs]) + self.counted.append(list(run_ids)) result = self.count_result.copy() - result["runs"] = len(runs) + result["runs"] = len(run_ids) return result @@ -218,8 +230,8 @@ def test_run_deletes_only_free_tenants(monkeypatch: pytest.MonkeyPatch) -> None: repo = FakeRepo( batches=[ [ - FakeRun("run-free", "t_free", cutoff), - FakeRun("run-paid", "t_paid", cutoff), + make_ref("run-free", "t_free", cutoff), + make_ref("run-paid", "t_paid", cutoff), ] ] ) @@ -240,11 +252,43 @@ def test_run_deletes_only_free_tenants(monkeypatch: pytest.MonkeyPatch) -> None: cleanup.run() assert repo.deleted == [["run-free"]] + assert repo.cleanup_ref_calls[1]["tenant_ids"] == ["t_free"] + + +def test_run_filters_candidate_tenants_before_target_query(monkeypatch: pytest.MonkeyPatch) -> None: + cutoff = datetime.datetime.now() + repo = FakeRepo( + batches=[ + [ + make_ref("run-free", "t_free", cutoff), + make_ref("run-paid", "t_paid", cutoff), + ] + ] + ) + cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10) + + monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", True) + billing_calls: list[list[str]] = [] + + def fake_bulk(tenant_ids: list[str]) -> dict[str, SubscriptionPlan]: + billing_calls.append(tenant_ids) + return { + "t_free": plan_info("sandbox", -1), + "t_paid": plan_info("team", -1), + } + + monkeypatch.setattr(cleanup_module.BillingService, "get_plan_bulk_with_cache", staticmethod(fake_bulk)) + + cleanup.run() + + assert billing_calls == [["t_free", "t_paid"]] + assert repo.cleanup_ref_calls[1]["tenant_ids"] == ["t_free"] + assert repo.deleted == [["run-free"]] def test_run_skips_when_no_free_tenants(monkeypatch: pytest.MonkeyPatch) -> None: cutoff = datetime.datetime.now() - repo = FakeRepo(batches=[[FakeRun("run-paid", "t_paid", cutoff)]]) + repo = FakeRepo(batches=[[make_ref("run-paid", "t_paid", cutoff)]]) cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10) monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", True) @@ -257,6 +301,53 @@ def test_run_skips_when_no_free_tenants(monkeypatch: pytest.MonkeyPatch) -> None cleanup.run() assert repo.deleted == [] + assert len(repo.cleanup_ref_calls) == 2 + + +def test_run_paid_only_records_skipped_metrics(monkeypatch: pytest.MonkeyPatch) -> None: + cutoff = datetime.datetime.now() + repo = FakeRepo(batches=[[make_ref("run-paid", "t_paid", cutoff)]]) + cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10) + + monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", True) + monkeypatch.setattr( + cleanup_module.BillingService, + "get_plan_bulk_with_cache", + staticmethod(lambda tenant_ids: {tenant_id: plan_info("team", 1893456000) for tenant_id in tenant_ids}), + ) + batch_calls: list[dict[str, object]] = [] + monkeypatch.setattr(cleanup._metrics, "record_batch", lambda **kwargs: batch_calls.append(kwargs)) + + cleanup.run() + + assert repo.deleted == [] + assert repo.counted == [] + assert batch_calls[0]["batch_rows"] == 1 + assert batch_calls[0]["targeted_runs"] == 0 + assert batch_calls[0]["skipped_runs"] == 1 + assert batch_calls[0]["deleted_runs"] == 0 + + +def test_run_target_query_is_bounded_by_candidate_high_water(monkeypatch: pytest.MonkeyPatch) -> None: + first_created_at = datetime.datetime(2024, 1, 1, 0, 0, 0) + second_created_at = datetime.datetime(2024, 1, 1, 0, 1, 0) + repo = FakeRepo( + batches=[ + [ + make_ref("run-free-1", "t_free", first_created_at), + make_ref("run-free-2", "t_free", second_created_at), + ] + ] + ) + cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=2) + + monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", False) + + cleanup.run() + + assert repo.cleanup_ref_calls[1]["last_seen"] is None + assert repo.cleanup_ref_calls[1]["upper_bound"] == (second_created_at, "run-free-2") + assert repo.cleanup_ref_calls[2]["last_seen"] == (second_created_at, "run-free-2") def test_run_exits_on_empty_batch(monkeypatch: pytest.MonkeyPatch) -> None: @@ -268,7 +359,7 @@ def test_run_exits_on_empty_batch(monkeypatch: pytest.MonkeyPatch) -> None: def test_run_records_metrics_on_success(monkeypatch: pytest.MonkeyPatch) -> None: cutoff = datetime.datetime.now() repo = FakeRepo( - batches=[[FakeRun("run-free", "t_free", cutoff)]], + batches=[[make_ref("run-free", "t_free", cutoff)]], delete_result={ "runs": 0, "node_executions": 2, @@ -300,13 +391,13 @@ def test_run_records_metrics_on_success(monkeypatch: pytest.MonkeyPatch) -> None def test_run_records_failed_metrics(monkeypatch: pytest.MonkeyPatch) -> None: class FailingRepo(FakeRepo): - def delete_runs_with_related( - self, runs: list[FakeRun], delete_node_executions=None, delete_trigger_logs=None + def delete_runs_with_related_by_ids( + self, run_ids: list[str], delete_node_executions=None, delete_trigger_logs=None ) -> dict[str, int]: raise RuntimeError("delete failed") cutoff = datetime.datetime.now() - repo = FailingRepo(batches=[[FakeRun("run-free", "t_free", cutoff)]]) + repo = FailingRepo(batches=[[make_ref("run-free", "t_free", cutoff)]]) cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10) monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", False) @@ -323,7 +414,7 @@ def test_run_records_failed_metrics(monkeypatch: pytest.MonkeyPatch) -> None: def test_run_dry_run_skips_deletions(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: cutoff = datetime.datetime.now() repo = FakeRepo( - batches=[[FakeRun("run-free", "t_free", cutoff)]], + batches=[[make_ref("run-free", "t_free", cutoff)]], count_result={ "runs": 0, "node_executions": 2, diff --git a/api/tests/unit_tests/services/test_document_indexing_task_proxy.py b/api/tests/unit_tests/services/test_document_indexing_task_proxy.py index 28de9efa57f..082bb7aa865 100644 --- a/api/tests/unit_tests/services/test_document_indexing_task_proxy.py +++ b/api/tests/unit_tests/services/test_document_indexing_task_proxy.py @@ -10,7 +10,7 @@ class DocumentIndexingTaskProxyTestDataFactory: """Factory class for creating test data and mock objects for DocumentIndexingTaskProxy tests.""" @staticmethod - def create_mock_features(billing_enabled: bool = False, plan: CloudPlan = CloudPlan.SANDBOX) -> Mock: + def create_mock_features(billing_enabled: bool = False, plan: CloudPlan | str | None = CloudPlan.SANDBOX) -> Mock: """Create mock features with billing configuration.""" features = Mock() features.billing = Mock() diff --git a/api/tests/unit_tests/services/test_duplicate_document_indexing_task_proxy.py b/api/tests/unit_tests/services/test_duplicate_document_indexing_task_proxy.py index 20358d6a0cd..e0370edec9e 100644 --- a/api/tests/unit_tests/services/test_duplicate_document_indexing_task_proxy.py +++ b/api/tests/unit_tests/services/test_duplicate_document_indexing_task_proxy.py @@ -12,7 +12,7 @@ class DuplicateDocumentIndexingTaskProxyTestDataFactory: """Factory class for creating test data and mock objects for DuplicateDocumentIndexingTaskProxy tests.""" @staticmethod - def create_mock_features(billing_enabled: bool = False, plan: CloudPlan = CloudPlan.SANDBOX) -> Mock: + def create_mock_features(billing_enabled: bool = False, plan: CloudPlan | str | None = CloudPlan.SANDBOX) -> Mock: """Create mock features with billing configuration.""" features = Mock() features.billing = Mock() diff --git a/api/tests/unit_tests/services/test_human_input_service.py b/api/tests/unit_tests/services/test_human_input_service.py index a0434f9b436..01d918cd897 100644 --- a/api/tests/unit_tests/services/test_human_input_service.py +++ b/api/tests/unit_tests/services/test_human_input_service.py @@ -281,6 +281,36 @@ def test_submit_form_by_token_calls_repository_and_enqueue( enqueue_spy.assert_called_once_with(sample_form_record.workflow_run_id) +def test_submit_form_by_token_enqueues_agent_app_resume_for_conversation_form( + sample_form_record, mock_session_factory, mocker: MockerFixture +): + # ENG-635: a conversation-owned (Agent v2 chat) form routes to the chat + # resume, not the workflow resume. + session_factory, _ = mock_session_factory + repo = MagicMock(spec=HumanInputFormSubmissionRepository) + conversation_record = dataclasses.replace( + sample_form_record, + workflow_run_id=None, + conversation_id="conv-1", + ) + repo.get_by_token.return_value = conversation_record + repo.mark_submitted.return_value = conversation_record + service = HumanInputService(session_factory, form_repository=repo) + workflow_enqueue_spy = mocker.patch.object(service, "enqueue_resume") + chat_enqueue_spy = mocker.patch.object(service, "enqueue_agent_app_resume") + + service.submit_form_by_token( + recipient_type=RecipientType.STANDALONE_WEB_APP, + form_token="token", + selected_action_id="submit", + form_data={"field": "value"}, + submission_end_user_id="end-user-id", + ) + + chat_enqueue_spy.assert_called_once_with(conversation_id="conv-1", form_id=conversation_record.form_id) + workflow_enqueue_spy.assert_not_called() + + def test_submit_form_by_token_skips_enqueue_for_delivery_test( sample_form_record, mock_session_factory, mocker: MockerFixture ): diff --git a/api/tests/unit_tests/services/test_metadata_bug_complete.py b/api/tests/unit_tests/services/test_metadata_bug_complete.py index fc3a2fc416d..36ea1fac1a4 100644 --- a/api/tests/unit_tests/services/test_metadata_bug_complete.py +++ b/api/tests/unit_tests/services/test_metadata_bug_complete.py @@ -1,65 +1,62 @@ from pathlib import Path -from unittest.mock import Mock, create_autospec, patch +from typing import cast +from unittest.mock import Mock import pytest -from models.account import Account +from models import Account, Tenant from services.entities.knowledge_entities.knowledge_entities import MetadataArgs from services.metadata_service import MetadataService +def _make_account(account_id: str = "user-456", tenant_id: str = "tenant-123") -> Account: + account = Account(name="Test User", email=f"{account_id}@example.com") + account.id = account_id + tenant = Tenant(name="Test Tenant") + tenant.id = tenant_id + account._current_tenant = tenant + return account + + class TestMetadataBugCompleteValidation: """Complete test suite to verify the metadata nullable bug and its fix.""" - def test_1_pydantic_layer_validation(self): + def test_1_pydantic_layer_validation(self) -> None: """Test Layer 1: Pydantic model validation correctly rejects None values.""" # Pydantic should reject None values for required fields with pytest.raises((ValueError, TypeError)): - MetadataArgs(type=None, name=None) + MetadataArgs(type=None, name=None) # pyrefly: ignore[bad-argument-type] with pytest.raises((ValueError, TypeError)): - MetadataArgs(type="string", name=None) + MetadataArgs(type="string", name=None) # pyrefly: ignore[bad-argument-type] with pytest.raises((ValueError, TypeError)): - MetadataArgs(type=None, name="test") + MetadataArgs(type=None, name="test") # pyrefly: ignore[bad-argument-type] # Valid values should work valid_args = MetadataArgs(type="string", name="test_name") assert valid_args.type == "string" assert valid_args.name == "test_name" - def test_2_business_logic_layer_crashes_on_none(self): + def test_2_business_logic_layer_crashes_on_none(self) -> None: """Test Layer 2: Business logic crashes when None values slip through.""" # Create mock that bypasses Pydantic validation mock_metadata_args = Mock() mock_metadata_args.name = None mock_metadata_args.type = "string" - mock_user = create_autospec(Account, instance=True) - mock_user.current_tenant_id = "tenant-123" - mock_user.id = "user-456" - - with patch( - "services.metadata_service.current_account_with_tenant", - return_value=(mock_user, mock_user.current_tenant_id), - ): - # Should crash with TypeError - with pytest.raises(TypeError, match="object of type 'NoneType' has no len"): - MetadataService.create_metadata("dataset-123", mock_metadata_args) + account = _make_account() + # Should crash with TypeError + with pytest.raises(TypeError, match="object of type 'NoneType' has no len"): + MetadataService.create_metadata("dataset-123", mock_metadata_args, account, "tenant-123") # Test update method as well - mock_user = create_autospec(Account, instance=True) - mock_user.current_tenant_id = "tenant-123" - mock_user.id = "user-456" + account = _make_account() + none_name = cast(str, None) + with pytest.raises(TypeError, match="object of type 'NoneType' has no len"): + MetadataService.update_metadata_name("dataset-123", "metadata-456", none_name, account, "tenant-123") - with patch( - "services.metadata_service.current_account_with_tenant", - return_value=(mock_user, mock_user.current_tenant_id), - ): - with pytest.raises(TypeError, match="object of type 'NoneType' has no len"): - MetadataService.update_metadata_name("dataset-123", "metadata-456", None) - - def test_3_database_constraints_verification(self): + def test_3_database_constraints_verification(self) -> None: """Test Layer 3: Verify database model has nullable=False constraints.""" from sqlalchemy import inspect @@ -75,7 +72,7 @@ class TestMetadataBugCompleteValidation: assert type_column.nullable is False, "type column should be nullable=False" assert name_column.nullable is False, "name column should be nullable=False" - def test_4_fixed_api_layer_rejects_null(self): + def test_4_fixed_api_layer_rejects_null(self) -> None: """Test Layer 4: Fixed API configuration properly rejects null values using Pydantic.""" with pytest.raises((ValueError, TypeError)): MetadataArgs.model_validate({"type": None, "name": None}) @@ -86,30 +83,23 @@ class TestMetadataBugCompleteValidation: with pytest.raises((ValueError, TypeError)): MetadataArgs.model_validate({"type": None, "name": "test"}) - def test_5_fixed_api_accepts_valid_values(self): + def test_5_fixed_api_accepts_valid_values(self) -> None: """Test that fixed API still accepts valid non-null values.""" args = MetadataArgs.model_validate({"type": "string", "name": "valid_name"}) assert args.type == "string" assert args.name == "valid_name" - def test_6_simulated_buggy_behavior(self): + def test_6_simulated_buggy_behavior(self) -> None: """Test simulating the original buggy behavior by bypassing Pydantic validation.""" mock_metadata_args = Mock() mock_metadata_args.name = None mock_metadata_args.type = None - mock_user = create_autospec(Account, instance=True) - mock_user.current_tenant_id = "tenant-123" - mock_user.id = "user-456" + account = _make_account() + with pytest.raises(TypeError, match="object of type 'NoneType' has no len"): + MetadataService.create_metadata("dataset-123", mock_metadata_args, account, "tenant-123") - with patch( - "services.metadata_service.current_account_with_tenant", - return_value=(mock_user, mock_user.current_tenant_id), - ): - with pytest.raises(TypeError, match="object of type 'NoneType' has no len"): - MetadataService.create_metadata("dataset-123", mock_metadata_args) - - def test_7_end_to_end_validation_layers(self): + def test_7_end_to_end_validation_layers(self) -> None: """Test all validation layers work together correctly.""" # Layer 1: API should reject null at parameter level (with fix) # Layer 2: Pydantic should reject null at model level @@ -128,7 +118,7 @@ class TestMetadataBugCompleteValidation: assert len(metadata_args.name) <= 255 # This should not crash assert len(metadata_args.type) > 0 # This should not crash - def test_8_verify_specific_fix_locations(self): + def test_8_verify_specific_fix_locations(self) -> None: """Verify that the specific locations mentioned in bug report are fixed.""" # Read the actual files to verify fixes import os @@ -152,7 +142,7 @@ class TestMetadataBugCompleteValidation: class TestMetadataValidationSummary: """Summary tests that demonstrate the complete validation architecture.""" - def test_validation_layer_architecture(self): + def test_validation_layer_architecture(self) -> None: """Document and test the 4-layer validation architecture.""" # Layer 1: API Parameter Validation (Flask-RESTful reqparse) # - Role: First line of defense, validates HTTP request parameters diff --git a/api/tests/unit_tests/services/test_metadata_nullable_bug.py b/api/tests/unit_tests/services/test_metadata_nullable_bug.py index f43f394489a..27570a86f1a 100644 --- a/api/tests/unit_tests/services/test_metadata_nullable_bug.py +++ b/api/tests/unit_tests/services/test_metadata_nullable_bug.py @@ -1,56 +1,53 @@ -from unittest.mock import Mock, create_autospec, patch +from typing import cast +from unittest.mock import Mock import pytest -from models.account import Account +from models import Account, Tenant from services.entities.knowledge_entities.knowledge_entities import MetadataArgs from services.metadata_service import MetadataService +def _make_account(account_id: str = "user-456", tenant_id: str = "tenant-123") -> Account: + account = Account(name="Test User", email=f"{account_id}@example.com") + account.id = account_id + tenant = Tenant(name="Test Tenant") + tenant.id = tenant_id + account._current_tenant = tenant + return account + + class TestMetadataNullableBug: """Test case to reproduce the metadata nullable validation bug.""" - def test_metadata_args_with_none_values_should_fail(self): + def test_metadata_args_with_none_values_should_fail(self) -> None: """Test that MetadataArgs validation should reject None values.""" # This test demonstrates the expected behavior - should fail validation with pytest.raises((ValueError, TypeError)): # This should fail because Pydantic expects non-None values - MetadataArgs(type=None, name=None) + MetadataArgs(type=None, name=None) # pyrefly: ignore[bad-argument-type] - def test_metadata_service_create_with_none_name_crashes(self): + def test_metadata_service_create_with_none_name_crashes(self) -> None: """Test that MetadataService.create_metadata crashes when name is None.""" # Mock the MetadataArgs to bypass Pydantic validation mock_metadata_args = Mock() mock_metadata_args.name = None # This will cause len() to crash mock_metadata_args.type = "string" - mock_user = create_autospec(Account, instance=True) - mock_user.current_tenant_id = "tenant-123" - mock_user.id = "user-456" + account = _make_account() + # This should crash with TypeError when calling len(None) + with pytest.raises(TypeError, match="object of type 'NoneType' has no len"): + MetadataService.create_metadata("dataset-123", mock_metadata_args, account, "tenant-123") - with patch( - "services.metadata_service.current_account_with_tenant", - return_value=(mock_user, mock_user.current_tenant_id), - ): - # This should crash with TypeError when calling len(None) - with pytest.raises(TypeError, match="object of type 'NoneType' has no len"): - MetadataService.create_metadata("dataset-123", mock_metadata_args) - - def test_metadata_service_update_with_none_name_crashes(self): + def test_metadata_service_update_with_none_name_crashes(self) -> None: """Test that MetadataService.update_metadata_name crashes when name is None.""" - mock_user = create_autospec(Account, instance=True) - mock_user.current_tenant_id = "tenant-123" - mock_user.id = "user-456" + account = _make_account() + none_name = cast(str, None) + # This should crash with TypeError when calling len(None) + with pytest.raises(TypeError, match="object of type 'NoneType' has no len"): + MetadataService.update_metadata_name("dataset-123", "metadata-456", none_name, account, "tenant-123") - with patch( - "services.metadata_service.current_account_with_tenant", - return_value=(mock_user, mock_user.current_tenant_id), - ): - # This should crash with TypeError when calling len(None) - with pytest.raises(TypeError, match="object of type 'NoneType' has no len"): - MetadataService.update_metadata_name("dataset-123", "metadata-456", None) - - def test_api_layer_now_uses_pydantic_validation(self): + def test_api_layer_now_uses_pydantic_validation(self) -> None: """Verify that API layer relies on Pydantic validation instead of reqparse.""" invalid_payload = {"type": None, "name": None} with pytest.raises((ValueError, TypeError)): diff --git a/api/tests/unit_tests/services/test_model_load_balancing_service.py b/api/tests/unit_tests/services/test_model_load_balancing_service.py index beecf73caa4..827567f1afe 100644 --- a/api/tests/unit_tests/services/test_model_load_balancing_service.py +++ b/api/tests/unit_tests/services/test_model_load_balancing_service.py @@ -112,6 +112,29 @@ def test_enable_disable_model_load_balancing_should_call_provider_configuration_ ) +@pytest.mark.parametrize( + ("method_name", "expected_provider_method"), + [ + ("enable_model_load_balancing", "enable_model_load_balancing"), + ("disable_model_load_balancing", "disable_model_load_balancing"), + ], +) +def test_enable_disable_model_load_balancing_uses_model_type_constructor_directly( + method_name: str, + expected_provider_method: str, + service: ModelLoadBalancingService, + monkeypatch: pytest.MonkeyPatch, +) -> None: + provider_configuration = _build_provider_configuration(provider_schema=_build_provider_credential_schema()) + service.provider_manager.get_configurations.return_value = {"openai": provider_configuration} + + getattr(service, method_name)("tenant-1", "openai", "gpt-4o-mini", "text-generation") + + getattr(provider_configuration, expected_provider_method).assert_called_once_with( + model="gpt-4o-mini", model_type=ModelType.LLM + ) + + @pytest.mark.parametrize( "method_name", ["enable_model_load_balancing", "disable_model_load_balancing"], diff --git a/api/tests/unit_tests/services/test_model_provider_service.py b/api/tests/unit_tests/services/test_model_provider_service.py index 9e4eeb2d6ed..806be013497 100644 --- a/api/tests/unit_tests/services/test_model_provider_service.py +++ b/api/tests/unit_tests/services/test_model_provider_service.py @@ -368,6 +368,70 @@ class TestModelProviderServiceDelegation: if method_name == "get_model_credential": assert result == {"api_key": "x"} + @pytest.mark.parametrize( + ("method_name", "method_kwargs", "provider_method_name", "expected_kwargs"), + [ + ( + "get_model_credential", + { + "tenant_id": "tenant-1", + "provider": "openai", + "model_type": "text-generation", + "model": "gpt-4o", + "credential_id": "cred-1", + }, + "get_custom_model_credential", + {"model_type": ModelType.LLM, "model": "gpt-4o", "credential_id": "cred-1"}, + ), + ( + "create_model_credential", + { + "tenant_id": "tenant-1", + "provider": "openai", + "model_type": "text-generation", + "model": "gpt-4o", + "credentials": {"api_key": "x"}, + "credential_name": "cred-a", + }, + "create_custom_model_credential", + { + "model_type": ModelType.LLM, + "model": "gpt-4o", + "credentials": {"api_key": "x"}, + "credential_name": "cred-a", + }, + ), + ( + "remove_model", + { + "tenant_id": "tenant-1", + "provider": "openai", + "model_type": "text-generation", + "model": "gpt-4o", + }, + "delete_custom_model", + {"model_type": ModelType.LLM, "model": "gpt-4o"}, + ), + ], + ) + def test_custom_model_methods_use_model_type_constructor_directly( + self, + method_name: str, + method_kwargs: dict[str, Any], + provider_method_name: str, + expected_kwargs: dict[str, Any], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + service = ModelProviderService() + provider_configuration = MagicMock() + get_provider_config_mock = MagicMock(return_value=provider_configuration) + monkeypatch.setattr(service, "_get_provider_configuration", get_provider_config_mock) + + getattr(service, method_name)(**method_kwargs) + + get_provider_config_mock.assert_called_once_with("tenant-1", "openai") + getattr(provider_configuration, provider_method_name).assert_called_once_with(**expected_kwargs) + class TestModelProviderServiceListingsAndDefaults: def test_get_models_by_model_type_should_group_active_non_deprecated_models(self) -> None: diff --git a/api/tests/unit_tests/services/test_rag_pipeline_task_proxy.py b/api/tests/unit_tests/services/test_rag_pipeline_task_proxy.py index cfc685e4cbd..ce14d55d4de 100644 --- a/api/tests/unit_tests/services/test_rag_pipeline_task_proxy.py +++ b/api/tests/unit_tests/services/test_rag_pipeline_task_proxy.py @@ -1,4 +1,5 @@ import json +import logging from unittest.mock import Mock, patch import pytest @@ -468,16 +469,14 @@ class TestRagPipelineTaskProxy: # Assert proxy._dispatch.assert_called_once() - @patch("services.rag_pipeline.rag_pipeline_task_proxy.logger") - def test_delay_method_with_empty_entities(self, mock_logger): + def test_delay_method_with_empty_entities(self, caplog): """Test delay method with empty rag_pipeline_invoke_entities.""" # Arrange proxy = RagPipelineTaskProxy("tenant-123", "user-456", []) # Act - proxy.delay() + with caplog.at_level(logging.WARNING, logger="services.rag_pipeline.rag_pipeline_task_proxy"): + proxy.delay() # Assert - mock_logger.warning.assert_called_once_with( - "Received empty rag pipeline invoke entities, no tasks delivered: %s %s", "tenant-123", "user-456" - ) + assert "Received empty rag pipeline invoke entities, no tasks delivered: tenant-123 user-456" in caplog.text diff --git a/api/tests/unit_tests/services/test_recommended_app_service.py b/api/tests/unit_tests/services/test_recommended_app_service.py deleted file mode 100644 index 980b8291e23..00000000000 --- a/api/tests/unit_tests/services/test_recommended_app_service.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Unit tests for RecommendedAppService.get_recommend_app_detail null handling. - -Regression tests for #36096: accessing result['id'] when the retrieval -returns None causes a TypeError / KeyError in self-hosted mode. -""" - -from types import SimpleNamespace -from unittest.mock import MagicMock, patch - -from services.recommended_app_service import RecommendedAppService - - -class TestGetRecommendAppDetailNullCheck: - @patch("services.recommended_app_service.FeatureService", autospec=True) - @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) - @patch("services.recommended_app_service.dify_config") - def test_returns_none_when_retrieval_returns_none_and_trial_disabled( - self, mock_config, mock_factory_class, mock_feature_service - ): - mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" - mock_instance = MagicMock() - mock_instance.get_recommend_app_detail.return_value = None - mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance) - mock_feature_service.get_system_features.return_value = SimpleNamespace(enable_trial_app=False) - - result = RecommendedAppService.get_recommend_app_detail("nonexistent") - - assert result is None - - @patch("services.recommended_app_service.FeatureService", autospec=True) - @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) - @patch("services.recommended_app_service.dify_config") - def test_returns_none_when_retrieval_returns_none_and_trial_enabled( - self, mock_config, mock_factory_class, mock_feature_service - ): - """Regression for #36096: must not crash when result is None and enable_trial_app is True.""" - mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" - mock_instance = MagicMock() - mock_instance.get_recommend_app_detail.return_value = None - mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance) - mock_feature_service.get_system_features.return_value = SimpleNamespace(enable_trial_app=True) - - result = RecommendedAppService.get_recommend_app_detail("nonexistent") - - assert result is None - mock_instance.get_recommend_app_detail.assert_called_once_with("nonexistent") diff --git a/api/tests/unit_tests/tasks/test_human_input_timeout_tasks.py b/api/tests/unit_tests/tasks/test_human_input_timeout_tasks.py index 591da56f494..f2203bae25f 100644 --- a/api/tests/unit_tests/tasks/test_human_input_timeout_tasks.py +++ b/api/tests/unit_tests/tasks/test_human_input_timeout_tasks.py @@ -63,6 +63,7 @@ class _FakeFormRepo: return SimpleNamespace( form_id=form_id, workflow_run_id=getattr(form, "workflow_run_id", None), + conversation_id=getattr(form, "conversation_id", None), node_id=getattr(form, "node_id", None), ) @@ -70,11 +71,15 @@ class _FakeFormRepo: class _FakeService: def __init__(self, _session_factory, form_repository=None): self.enqueued: list[str] = [] + self.agent_app_resumed: list[tuple[str, str]] = [] def enqueue_resume(self, workflow_run_id: str | None) -> None: if workflow_run_id is not None: self.enqueued.append(workflow_run_id) + def enqueue_agent_app_resume(self, *, conversation_id: str, form_id: str) -> None: + self.agent_app_resumed.append((conversation_id, form_id)) + def _build_form( *, @@ -84,6 +89,7 @@ def _build_form( expiration_time: datetime, workflow_run_id: str | None, node_id: str, + conversation_id: str | None = None, ) -> SimpleNamespace: return SimpleNamespace( id=form_id, @@ -91,6 +97,7 @@ def _build_form( created_at=created_at, expiration_time=expiration_time, workflow_run_id=workflow_run_id, + conversation_id=conversation_id, node_id=node_id, status=HumanInputFormStatus.WAITING, ) @@ -208,3 +215,45 @@ def test_check_and_handle_human_input_timeouts_omits_global_filter_when_disabled assert stmt is not None stmt_text = str(stmt) assert "created_at <=" not in stmt_text + + +def test_check_and_handle_human_input_timeouts_routes_conversation_owned_form_to_agent_app_resume( + monkeypatch: pytest.MonkeyPatch, +): + # ENG-635 (review): a conversation-owned Agent v2 chat ask_human form has no + # workflow_run_id. On timeout it must enqueue the Agent App resume (so the + # timeout is threaded back as the ask_human result), instead of asserting on + # workflow_run_id — which previously raised and was swallowed by the except. + now = datetime(2025, 1, 1, 12, 0, 0) + monkeypatch.setattr(task_module, "naive_utc_now", lambda: now) + monkeypatch.setattr(task_module.dify_config, "HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS", 3600) + monkeypatch.setattr(task_module, "db", SimpleNamespace(engine=object())) + + forms = [ + _build_form( + form_id="form-chat", + form_kind=HumanInputFormKind.RUNTIME, + created_at=now - timedelta(minutes=5), + expiration_time=now - timedelta(seconds=1), + workflow_run_id=None, + conversation_id="conv-1", + node_id="agent", + ), + ] + capture: dict[str, Any] = {} + monkeypatch.setattr(task_module, "sessionmaker", lambda *args, **kwargs: _FakeSessionFactory(forms, capture)) + + repo = _FakeFormRepo(form_map={form.id: form for form in forms}) + service = _FakeService(None) + monkeypatch.setattr(task_module, "HumanInputFormSubmissionRepository", lambda: repo) + monkeypatch.setattr(task_module, "HumanInputService", lambda *_args, **_kwargs: service) + monkeypatch.setattr(task_module, "_handle_global_timeout", lambda **_kwargs: None) + + task_module.check_and_handle_human_input_timeouts(limit=100) + + # Node timeout (conversation forms are never "global"), routed to Agent App resume. + assert repo.calls == [ + {"form_id": "form-chat", "timeout_status": HumanInputFormStatus.TIMEOUT, "reason": "node_timeout"} + ] + assert service.agent_app_resumed == [("conv-1", "form-chat")] + assert service.enqueued == [] diff --git a/api/tests/unit_tests/tasks/test_mail_send_task.py b/api/tests/unit_tests/tasks/test_mail_send_task.py index 5cb933e3f3f..2b14deaf6df 100644 --- a/api/tests/unit_tests/tasks/test_mail_send_task.py +++ b/api/tests/unit_tests/tasks/test_mail_send_task.py @@ -8,6 +8,7 @@ This module tests the mail sending functionality including: - Error handling and logging """ +import logging import smtplib from unittest.mock import ANY, MagicMock, patch @@ -371,8 +372,7 @@ class TestMailTaskRetryLogic: @patch("tasks.mail_register_task.get_email_i18n_service") @patch("tasks.mail_register_task.mail") - @patch("tasks.mail_register_task.logger") - def test_mail_task_logs_success(self, mock_logger, mock_mail, mock_email_service): + def test_mail_task_logs_success(self, mock_mail, mock_email_service, caplog): """Test that successful mail sends are logged properly.""" # Arrange mock_mail.is_inited.return_value = True @@ -380,7 +380,8 @@ class TestMailTaskRetryLogic: mock_email_service.return_value = mock_service # Act - send_email_register_mail_task(language="en-US", to="test@example.com", code="123456") + with caplog.at_level(logging.INFO, logger="tasks.mail_register_task"): + send_email_register_mail_task(language="en-US", to="test@example.com", code="123456") # Assert mock_service.send_email.assert_called_once_with( @@ -390,12 +391,14 @@ class TestMailTaskRetryLogic: template_context={"to": "test@example.com", "code": "123456"}, ) # Verify logging calls - assert mock_logger.info.call_count == 2 # Start and success logs + log_messages = [record.getMessage() for record in caplog.records] + assert len(log_messages) == 2 # Start and success logs + assert "Start email register mail to test@example.com" in log_messages[0] + assert "Send email register mail to test@example.com succeeded" in log_messages[1] @patch("tasks.mail_register_task.get_email_i18n_service") @patch("tasks.mail_register_task.mail") - @patch("tasks.mail_register_task.logger") - def test_mail_task_logs_failure(self, mock_logger, mock_mail, mock_email_service): + def test_mail_task_logs_failure(self, mock_mail, mock_email_service, caplog): """Test that failed mail sends are logged with exception details.""" # Arrange mock_mail.is_inited.return_value = True @@ -404,10 +407,13 @@ class TestMailTaskRetryLogic: mock_email_service.return_value = mock_service # Act - send_email_register_mail_task(language="en-US", to="test@example.com", code="123456") + with caplog.at_level(logging.ERROR, logger="tasks.mail_register_task"): + send_email_register_mail_task(language="en-US", to="test@example.com", code="123456") # Assert - mock_logger.exception.assert_called_once_with("Send email register mail to %s failed", "test@example.com") + assert "Send email register mail to test@example.com failed" in caplog.text + assert len(caplog.records) == 1 + assert caplog.records[0].exc_info is not None @patch("tasks.mail_reset_password_task.get_email_i18n_service") @patch("tasks.mail_reset_password_task.mail") @@ -574,8 +580,7 @@ class TestInnerEmailTask: @patch("tasks.mail_inner_task.get_email_i18n_service") @patch("tasks.mail_inner_task.mail") @patch("tasks.mail_inner_task._render_template_with_strategy") - @patch("tasks.mail_inner_task.logger") - def test_inner_email_task_logs_failure(self, mock_logger, mock_render, mock_mail, mock_email_service): + def test_inner_email_task_logs_failure(self, mock_render, mock_mail, mock_email_service, caplog): """Test inner email task logs failures properly.""" # Arrange mock_mail.is_inited.return_value = True @@ -587,10 +592,13 @@ class TestInnerEmailTask: to_list = ["user@example.com"] # Act - send_inner_email_task(to=to_list, subject="Test", body="Body", substitutions={}) + with caplog.at_level(logging.ERROR, logger="tasks.mail_inner_task"): + send_inner_email_task(to=to_list, subject="Test", body="Body", substitutions={}) # Assert - mock_logger.exception.assert_called_once() + assert "Send enterprise mail to ['user@example.com'] failed" in caplog.text + assert len(caplog.records) == 1 + assert caplog.records[0].exc_info is not None class TestSendGridIntegration: @@ -890,9 +898,8 @@ class TestPerformanceAndTiming: @patch("tasks.mail_register_task.get_email_i18n_service") @patch("tasks.mail_register_task.mail") - @patch("tasks.mail_register_task.logger") @patch("tasks.mail_register_task.time") - def test_mail_task_tracks_execution_time(self, mock_time, mock_logger, mock_mail, mock_email_service): + def test_mail_task_tracks_execution_time(self, mock_time, mock_mail, mock_email_service, caplog): """Test that mail tasks track and log execution time.""" # Arrange mock_mail.is_inited.return_value = True @@ -903,13 +910,14 @@ class TestPerformanceAndTiming: mock_time.perf_counter.side_effect = [100.0, 100.5] # 0.5 second execution # Act - send_email_register_mail_task(language="en-US", to="test@example.com", code="123456") + with caplog.at_level(logging.INFO, logger="tasks.mail_register_task"): + send_email_register_mail_task(language="en-US", to="test@example.com", code="123456") # Assert assert mock_time.perf_counter.call_count == 2 # Verify latency is logged - success_log_call = mock_logger.info.call_args_list[1] - assert "latency" in str(success_log_call) + assert len(caplog.records) == 2 + assert "latency: 0.5" in caplog.records[1].getMessage() class TestEdgeCasesAndErrorHandling: @@ -1457,8 +1465,7 @@ class TestLoggingAndMonitoring: @patch("tasks.mail_register_task.get_email_i18n_service") @patch("tasks.mail_register_task.mail") - @patch("tasks.mail_register_task.logger") - def test_mail_task_logs_recipient_information(self, mock_logger, mock_mail, mock_email_service): + def test_mail_task_logs_recipient_information(self, mock_mail, mock_email_service, caplog): """ Test that mail tasks log recipient information for audit trails. @@ -1471,17 +1478,16 @@ class TestLoggingAndMonitoring: mock_email_service.return_value = mock_service # Act - send_email_register_mail_task(language="en-US", to="audit@example.com", code="123456") + with caplog.at_level(logging.INFO, logger="tasks.mail_register_task"): + send_email_register_mail_task(language="en-US", to="audit@example.com", code="123456") # Assert # Check that recipient is logged in start message - start_log_call = mock_logger.info.call_args_list[0] - assert "audit@example.com" in str(start_log_call) + assert "audit@example.com" in caplog.records[0].getMessage() @patch("tasks.mail_inner_task.get_email_i18n_service") @patch("tasks.mail_inner_task.mail") - @patch("tasks.mail_inner_task.logger") - def test_inner_email_task_logs_subject_for_tracking(self, mock_logger, mock_mail, mock_email_service): + def test_inner_email_task_logs_subject_for_tracking(self, mock_mail, mock_email_service, caplog): """ Test that inner email task logs subject for tracking purposes. @@ -1494,11 +1500,11 @@ class TestLoggingAndMonitoring: mock_email_service.return_value = mock_service # Act - send_inner_email_task( - to=["user@example.com"], subject="Important Notification", body="

Body

", substitutions={} - ) + with caplog.at_level(logging.INFO, logger="tasks.mail_inner_task"): + send_inner_email_task( + to=["user@example.com"], subject="Important Notification", body="

Body

", substitutions={} + ) # Assert # Check that subject is logged - start_log_call = mock_logger.info.call_args_list[0] - assert "Important Notification" in str(start_log_call) + assert "Important Notification" in caplog.records[0].getMessage() diff --git a/api/tests/unit_tests/tasks/test_process_tenant_plugin_autoupgrade_check_task.py b/api/tests/unit_tests/tasks/test_process_tenant_plugin_autoupgrade_check_task.py index 75d8b920443..a4412c1ee93 100644 --- a/api/tests/unit_tests/tasks/test_process_tenant_plugin_autoupgrade_check_task.py +++ b/api/tests/unit_tests/tasks/test_process_tenant_plugin_autoupgrade_check_task.py @@ -4,19 +4,25 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch from core.plugin.entities.marketplace import MarketplacePluginSnapshot -from core.plugin.entities.plugin import PluginInstallationSource +from core.plugin.entities.plugin import PluginCategory, PluginInstallationSource from models.account import TenantPluginAutoUpgradeStrategy MODULE = "tasks.process_tenant_plugin_autoupgrade_check_task" -def _make_plugin(plugin_id: str, version: str, source=PluginInstallationSource.Marketplace): +def _make_plugin( + plugin_id: str, + version: str, + source=PluginInstallationSource.Marketplace, + category: PluginCategory = PluginCategory.Tool, +): """Build a minimal stand-in for a PluginInstallation entry returned by manager.list_plugins.""" return SimpleNamespace( plugin_id=plugin_id, version=version, plugin_unique_identifier=f"{plugin_id}:{version}@deadbeef", source=source, + declaration=SimpleNamespace(category=category), ) @@ -39,6 +45,7 @@ def _run_task( upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL, exclude_plugins=None, include_plugins=None, + category=None, ): """ Execute the celery task synchronously with mocks for the plugin manager, @@ -72,6 +79,7 @@ def _run_task( upgrade_mode, exclude_plugins or [], include_plugins or [], + category, ) return upgrade_mock, upgrade_calls @@ -246,6 +254,26 @@ class TestUpgradeMode: assert upgrade_mock.call_count == 1 assert calls[0][1] == plugins[0].plugin_unique_identifier + def test_category_strategy_only_upgrades_matching_category(self): + plugins = [ + _make_plugin("acme/model-provider", "1.0.0", category=PluginCategory.Model), + _make_plugin("acme/tool-provider", "1.0.0", category=PluginCategory.Tool), + ] + manifests = [ + _make_manifest("acme/model-provider", "1.0.1"), + _make_manifest("acme/tool-provider", "1.0.1"), + ] + + upgrade_mock, calls = _run_task( + plugins=plugins, + manifests=manifests, + upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL, + category=TenantPluginAutoUpgradeStrategy.PluginCategory.MODEL, + ) + + upgrade_mock.assert_called_once() + assert calls[0][1] == plugins[0].plugin_unique_identifier + class TestErrorIsolation: def test_one_plugin_failure_does_not_block_others(self): diff --git a/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py b/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py index 626d1ee0a8f..a91b61111ca 100644 --- a/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py +++ b/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py @@ -1,9 +1,11 @@ +import logging from unittest.mock import MagicMock, call, patch import pytest from libs.archive_storage import ArchiveStorageNotConfiguredError from tasks.remove_app_and_related_data_task import ( + _delete_app_stars, _delete_app_workflow_archive_logs, _delete_archived_workflow_run_files, _delete_draft_variable_offload_data, @@ -48,8 +50,7 @@ class TestDeleteDraftVariableOffloadData: assert result == 0 mock_conn.execute.assert_not_called() - @patch("tasks.remove_app_and_related_data_task.logging") - def test_delete_draft_variable_offload_data_database_failure(self, mock_logging): + def test_delete_draft_variable_offload_data_database_failure(self, caplog): """Test handling of database operation failures.""" mock_conn = MagicMock() file_ids = ["file-1"] @@ -58,13 +59,14 @@ class TestDeleteDraftVariableOffloadData: mock_conn.execute.side_effect = Exception("Database error") # Execute function - should not raise, but log error - result = _delete_draft_variable_offload_data(mock_conn, file_ids) + with caplog.at_level(logging.ERROR): + result = _delete_draft_variable_offload_data(mock_conn, file_ids) # Should return 0 when error occurs assert result == 0 # Verify error was logged - mock_logging.exception.assert_called_once_with("Error deleting draft variable offload data:") + assert "Error deleting draft variable offload data:" in caplog.text class TestDeleteWorkflowArchiveLogs: @@ -89,39 +91,59 @@ class TestDeleteWorkflowArchiveLogs: mock_session.execute.assert_called_once() +class TestDeleteAppStars: + @patch("tasks.remove_app_and_related_data_task._delete_records") + def test_delete_app_stars_calls_delete_records(self, mock_delete_records): + tenant_id = "tenant-1" + app_id = "app-1" + + _delete_app_stars(tenant_id, app_id) + + mock_delete_records.assert_called_once() + query_sql, params, delete_func, name = mock_delete_records.call_args[0] + assert "app_stars" in query_sql + assert params == {"tenant_id": tenant_id, "app_id": app_id} + assert name == "app star" + + mock_session = MagicMock() + + delete_func(mock_session, "star-1") + + mock_session.execute.assert_called_once() + + class TestDeleteArchivedWorkflowRunFiles: @patch("tasks.remove_app_and_related_data_task.get_archive_storage") - @patch("tasks.remove_app_and_related_data_task.logger") - def test_delete_archived_workflow_run_files_not_configured(self, mock_logger, mock_get_storage): + def test_delete_archived_workflow_run_files_not_configured(self, mock_get_storage, caplog): mock_get_storage.side_effect = ArchiveStorageNotConfiguredError("missing config") - _delete_archived_workflow_run_files("tenant-1", "app-1") + with caplog.at_level(logging.INFO, logger="tasks.remove_app_and_related_data_task"): + _delete_archived_workflow_run_files("tenant-1", "app-1") - assert mock_logger.info.call_count == 1 - assert "Archive storage not configured" in mock_logger.info.call_args[0][0] + assert caplog.text.count("Archive storage not configured") == 1 @patch("tasks.remove_app_and_related_data_task.get_archive_storage") - @patch("tasks.remove_app_and_related_data_task.logger") - def test_delete_archived_workflow_run_files_list_failure(self, mock_logger, mock_get_storage): + def test_delete_archived_workflow_run_files_list_failure(self, mock_get_storage, caplog): storage = MagicMock() storage.list_objects.side_effect = Exception("list failed") mock_get_storage.return_value = storage - _delete_archived_workflow_run_files("tenant-1", "app-1") + with caplog.at_level(logging.ERROR, logger="tasks.remove_app_and_related_data_task"): + _delete_archived_workflow_run_files("tenant-1", "app-1") storage.list_objects.assert_called_once_with("tenant-1/app_id=app-1/") storage.delete_object.assert_not_called() - mock_logger.exception.assert_called_once_with("Failed to list archive files for app %s", "app-1") + assert "Failed to list archive files for app app-1" in caplog.text @patch("tasks.remove_app_and_related_data_task.get_archive_storage") - @patch("tasks.remove_app_and_related_data_task.logger") - def test_delete_archived_workflow_run_files_success(self, mock_logger, mock_get_storage): + def test_delete_archived_workflow_run_files_success(self, mock_get_storage, caplog): storage = MagicMock() storage.list_objects.return_value = ["key-1", "key-2"] mock_get_storage.return_value = storage - _delete_archived_workflow_run_files("tenant-1", "app-1") + with caplog.at_level(logging.INFO, logger="tasks.remove_app_and_related_data_task"): + _delete_archived_workflow_run_files("tenant-1", "app-1") storage.list_objects.assert_called_once_with("tenant-1/app_id=app-1/") storage.delete_object.assert_has_calls([call("key-1"), call("key-2")], any_order=False) - mock_logger.info.assert_called_with("Deleted %s archive objects for app %s", 2, "app-1") + assert "Deleted 2 archive objects for app app-1" in caplog.text diff --git a/api/tests/unit_tests/tasks/test_resume_agent_app_task.py b/api/tests/unit_tests/tasks/test_resume_agent_app_task.py new file mode 100644 index 00000000000..237b43200de --- /dev/null +++ b/api/tests/unit_tests/tasks/test_resume_agent_app_task.py @@ -0,0 +1,138 @@ +"""Unit tests for the ``resume_agent_app_execution`` celery task (ENG-635). + +Every DB access (``db.session.get``) and the generator are patched at the module +level, so the task's branch logic is exercised without a database or live stack. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from pytest_mock import MockerFixture + +from models.account import Account +from models.human_input import HumanInputForm +from models.model import App, Conversation, EndUser +from tasks.app_generate import resume_agent_app_task as mod + +MODULE = "tasks.app_generate.resume_agent_app_task" + + +def _form(conversation_id: str = "conv-1", app_id: str = "app-1") -> MagicMock: + return MagicMock(conversation_id=conversation_id, app_id=app_id) + + +def _wire_db( + mocker: MockerFixture, + *, + form=None, + app=None, + conversation=None, + account=None, + end_user=None, +) -> MagicMock: + """Patch the module ``db`` so ``db.session.get(Model, id)`` dispatches by model.""" + table = { + HumanInputForm: form, + App: app, + Conversation: conversation, + Account: account, + EndUser: end_user, + } + db = mocker.patch(f"{MODULE}.db") + db.session.get.side_effect = lambda model, _id: table.get(model) + return db + + +def test_resume_happy_path_account_user_sets_tenant_and_runs(mocker: MockerFixture): + conversation = MagicMock(from_account_id="acct-1", from_end_user_id=None) + account = MagicMock() + app = MagicMock(tenant_id="tenant-1") + _wire_db(mocker, form=_form(), app=app, conversation=conversation, account=account) + gen = mocker.patch(f"{MODULE}.AgentAppGenerator") + + mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") + + account.set_tenant_id.assert_called_once_with("tenant-1") + gen.return_value.resume_after_form_submission.assert_called_once() + kwargs = gen.return_value.resume_after_form_submission.call_args.kwargs + assert kwargs["conversation_id"] == "conv-1" + assert kwargs["user"] is account + assert kwargs["app_model"] is app + + +def test_resume_end_user_path(mocker: MockerFixture): + conversation = MagicMock(from_account_id=None, from_end_user_id="eu-1") + end_user = MagicMock() + _wire_db(mocker, form=_form(), app=MagicMock(tenant_id="t"), conversation=conversation, end_user=end_user) + gen = mocker.patch(f"{MODULE}.AgentAppGenerator") + + mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") + + assert gen.return_value.resume_after_form_submission.call_args.kwargs["user"] is end_user + + +def test_resume_returns_when_form_missing(mocker: MockerFixture): + _wire_db(mocker, form=None) + gen = mocker.patch(f"{MODULE}.AgentAppGenerator") + + mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") + + gen.assert_not_called() + + +def test_resume_returns_on_conversation_mismatch(mocker: MockerFixture): + _wire_db(mocker, form=_form(conversation_id="other-conv")) + gen = mocker.patch(f"{MODULE}.AgentAppGenerator") + + mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") + + gen.assert_not_called() + + +def test_resume_returns_when_app_missing(mocker: MockerFixture): + _wire_db(mocker, form=_form(), app=None) + gen = mocker.patch(f"{MODULE}.AgentAppGenerator") + + mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") + + gen.assert_not_called() + + +def test_resume_returns_when_conversation_missing(mocker: MockerFixture): + _wire_db(mocker, form=_form(), app=MagicMock(), conversation=None) + gen = mocker.patch(f"{MODULE}.AgentAppGenerator") + + mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") + + gen.assert_not_called() + + +def test_resume_returns_when_no_user_resolvable(mocker: MockerFixture): + conversation = MagicMock(from_account_id=None, from_end_user_id=None) + _wire_db(mocker, form=_form(), app=MagicMock(), conversation=conversation) + gen = mocker.patch(f"{MODULE}.AgentAppGenerator") + + mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") + + gen.assert_not_called() + + +def test_resume_returns_when_account_id_set_but_account_gone(mocker: MockerFixture): + conversation = MagicMock(from_account_id="acct-x", from_end_user_id=None) + _wire_db(mocker, form=_form(), app=MagicMock(), conversation=conversation, account=None) + gen = mocker.patch(f"{MODULE}.AgentAppGenerator") + + mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") + + gen.assert_not_called() + + +def test_resume_swallows_generator_exception(mocker: MockerFixture): + conversation = MagicMock(from_account_id="acct-1", from_end_user_id=None) + _wire_db(mocker, form=_form(), app=MagicMock(tenant_id="t"), conversation=conversation, account=MagicMock()) + gen = mocker.patch(f"{MODULE}.AgentAppGenerator") + gen.return_value.resume_after_form_submission.side_effect = RuntimeError("boom") + + # The task must not propagate the failure (it is logged and the session closed). + mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") diff --git a/api/uv.lock b/api/uv.lock index 6b979782297..3445ec78321 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1293,7 +1293,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "fastapi", marker = "extra == 'server'", specifier = "==0.136.0" }, - { name = "graphon", marker = "extra == 'server'", specifier = "==0.2.2" }, + { name = "graphon", marker = "extra == 'server'", specifier = "==0.5.1" }, { name = "grpclib", extras = ["protobuf"], marker = "extra == 'grpc'", specifier = ">=0.4.9,<0.5.0" }, { name = "httpx", specifier = "==0.28.1" }, { name = "jsonschema", marker = "extra == 'server'", specifier = ">=4.23.0,<5.0.0" }, @@ -1630,13 +1630,13 @@ requires-dist = [ { name = "flask-login", specifier = "==0.6.3" }, { name = "flask-migrate", specifier = ">=4.1.0,<5.0.0" }, { name = "flask-orjson", specifier = ">=2.0.0,<3.0.0" }, - { name = "flask-restx", specifier = ">=1.3.2,<2.0.0" }, + { name = "flask-restx", git = "https://github.com/asukaminato0721/flask-restx?rev=27758e26f8f740d7525d5039c51a9e524b6e2b68" }, { name = "gevent", specifier = ">=26.4.0,<26.5.0" }, { name = "gevent-websocket", specifier = "==0.10.1" }, { name = "gmpy2", specifier = ">=2.3.0,<3.0.0" }, { name = "google-api-python-client", specifier = ">=2.196.0,<3.0.0" }, { name = "google-cloud-aiplatform", specifier = ">=1.151.0,<2.0.0" }, - { name = "graphon", specifier = "==0.4.0" }, + { name = "graphon", specifier = "==0.5.1" }, { name = "gunicorn", specifier = ">=26.0.0,<27.0.0" }, { name = "httpx", extras = ["socks"], specifier = "==0.28.1" }, { name = "httpx-sse", specifier = "==0.4.3" }, @@ -2570,8 +2570,8 @@ wheels = [ [[package]] name = "flask-restx" -version = "1.3.2" -source = { registry = "https://pypi.org/simple" } +version = "1.3.3.dev0" +source = { git = "https://github.com/asukaminato0721/flask-restx?rev=27758e26f8f740d7525d5039c51a9e524b6e2b68#27758e26f8f740d7525d5039c51a9e524b6e2b68" } dependencies = [ { name = "aniso8601" }, { name = "flask" }, @@ -2580,10 +2580,6 @@ dependencies = [ { name = "referencing" }, { name = "werkzeug" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/89/9b9ca58cbb8e9ec46f4a510ba93878e0c88d518bf03c350e3b1b7ad85cbe/flask-restx-1.3.2.tar.gz", hash = "sha256:0ae13d77e7d7e4dce513970cfa9db45364aef210e99022de26d2b73eb4dbced5", size = 2814719, upload-time = "2025-09-23T20:34:25.21Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/3f/b82cd8e733a355db1abb8297afbf59ec972c00ef90bf8d4eed287958b204/flask_restx-1.3.2-py2.py3-none-any.whl", hash = "sha256:6e035496e8223668044fc45bf769e526352fd648d9e159bd631d94fd645a687b", size = 2799859, upload-time = "2025-09-23T20:34:23.055Z" }, -] [[package]] name = "flask-sqlalchemy" @@ -2991,7 +2987,7 @@ httpx = [ [[package]] name = "graphon" -version = "0.4.0" +version = "0.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "charset-normalizer" }, @@ -3012,9 +3008,9 @@ dependencies = [ { name = "unstructured", extra = ["docx", "epub", "md", "ppt", "pptx"] }, { name = "webvtt-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/76/24/eb1e7983404dcac84816b76ea450e1bb97023e55e00c699d609340bc361e/graphon-0.4.0.tar.gz", hash = "sha256:afb0c7a58f89e09cfa585296429b4d08cd0df80b9ac54d550f88e7d76ec48ee0", size = 261812, upload-time = "2026-05-13T11:48:39.198Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/fa/432fa802bcb13f7f51dc323ddef92594b15333eafef181d937ffa554116e/graphon-0.5.1.tar.gz", hash = "sha256:ca38cc62ef3fbc2f3072b68235bcb41e32a6369a1753b46418c1d761c57125fe", size = 269741, upload-time = "2026-06-11T03:01:38.197Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/de/bad6b3fd1e4b4defc16e6ea106e55c44725a159f1d191a99877bce1c9931/graphon-0.4.0-py3-none-any.whl", hash = "sha256:b33f95886da823d5b1b53d663a4f5f8fa383c37740f3bd19297b8d140fcb804c", size = 372711, upload-time = "2026-05-13T11:48:37.712Z" }, + { url = "https://files.pythonhosted.org/packages/e9/c5/61e8634b89c320af9453083213e8be436071634dbc69cb14b5fe646763e4/graphon-0.5.1-py3-none-any.whl", hash = "sha256:70b49c244a46fb6e338905210cc895bd67584d9ab1412f6ba3cd4ed284010091", size = 381866, upload-time = "2026-06-11T03:01:36.693Z" }, ] [[package]] diff --git a/cli/README.md b/cli/README.md index 61414a22768..8a8521e8d1b 100644 --- a/cli/README.md +++ b/cli/README.md @@ -2,25 +2,33 @@ CLI client for [Dify] platform. Browser device-flow signin, list/inspect apps, run with structured input, parse output as JSON, YAML, or human text. -## Install +## Install (edge, internal) -Builds are standalone binaries (Bun-compiled) published as **GitHub Actions workflow artifacts** — no npm, no GitHub Release assets. The installer fetches the latest successful `cli-release.yml` run on `main`, verifies sha256, and copies the binary into `$HOME/.local/bin/difyctl`. +Per-commit `edge` builds are published to Cloudflare R2. The installer script lives in this repo; binaries are fetched from R2 via `DIFYCTL_R2_BASE` (shared internally): ```sh -# GH_TOKEN with `actions:read` scope is required — workflow artifact downloads -# need auth even on public repos. -export GH_TOKEN= -curl -fsSL https://raw.githubusercontent.com/langgenius/dify/main/cli/scripts/install-cli.sh | sh +curl -fsSL https://raw.githubusercontent.com/langgenius/dify/main/cli/scripts/install-r2.sh | DIFYCTL_R2_BASE= sh ``` -| Env | Default | Purpose | -| ---------------- | ----------------- | ----------------------------------------------------- | -| `GH_TOKEN` | — | GitHub PAT (or `GITHUB_TOKEN`) with `actions:read`. | -| `DIFYCTL_PREFIX` | `$HOME/.local` | Install root. Binary lands at `/bin/difyctl`. | -| `DIFYCTL_REPO` | `langgenius/dify` | Source repo. | -| `DIFYCTL_BRANCH` | `main` | Branch to pick the latest successful run from. | +| Env | Default | Purpose | +| ----------------------- | ------------------ | ------------------------------------------------------------------- | +| `DIFYCTL_R2_BASE` | — (required) | R2 public base, e.g. `https://pub-….r2.dev`. | +| `DIFYCTL_CHANNEL` | `edge` | Channel to install. | +| `DIFYCTL_INSTALL_DIR` | `$HOME/.local/bin` | Directory the binary is written to (`/difyctl`). | +| `DIFYCTL_VERSION` | latest | Pin an exact published version. | +| `DIFYCTL_COMMIT` | latest | Pin by git commit (short or full sha). | +| `DIFYCTL_R2_PREFIX` | `difyctl` | R2 key root for the pointer JSONs (`manifest.json` / `index.json`). | +| `DIFYCTL_R2_BIN_PREFIX` | `difyctl/bin` | R2 key root for binaries (the lifecycle/TTL target). | -Supported targets: `darwin-arm64`, `darwin-x64`, `linux-arm64`, `linux-x64`, `windows-x64.exe`. The shell installer covers Linux + macOS; Windows users can download the `.exe` directly from the same artifact. +By default the channel pointer (latest build) is installed. Set `DIFYCTL_COMMIT` (e.g. `ce4af86`) or `DIFYCTL_VERSION` to install a specific past build — both resolve through the channel's `index.json`: + +```sh +curl -fsSL https://raw.githubusercontent.com/langgenius/dify/main/cli/scripts/install-r2.sh | DIFYCTL_R2_BASE= DIFYCTL_COMMIT=ce4af86 sh +``` + +Windows: `$env:DIFYCTL_R2_BASE=''; irm https://raw.githubusercontent.com/langgenius/dify/main/cli/scripts/install-r2.ps1 | iex` (same env vars, e.g. `$env:DIFYCTL_COMMIT='ce4af86'`). + +Re-run to upgrade. For tagged `rc`/`stable` builds, use the GitHub installer (`install-cli.sh`). ## Quickstart diff --git a/cli/scripts/install-local.sh b/cli/scripts/install-local.sh index 3a1b76f78d9..892bddf9d90 100755 --- a/cli/scripts/install-local.sh +++ b/cli/scripts/install-local.sh @@ -1,6 +1,10 @@ #!/bin/sh -# install-local.sh — install difyctl from a locally built tarball. -# Run via: pnpm install:local +# install-local.sh — install difyctl from locally built standalone binaries. +# Run via: pnpm install:local (after `pnpm build:bin`) +# +# Consumes the raw, self-contained binaries emitted by scripts/release-build.sh +# into dist/bin (difyctl-v--). No GitHub Release needed: build on +# one machine, copy dist/bin to the tester, point this script at that directory. set -eu PREFIX="${DIFYCTL_PREFIX:-${HOME}/.local}" @@ -10,7 +14,7 @@ BIN_DIR="${PREFIX}/bin" case "$(uname -s)" in Linux*) os=linux ;; Darwin*) os=darwin ;; - *) echo "unsupported OS: $(uname -s)" >&2; exit 1 ;; + *) echo "unsupported OS: $(uname -s) (use install.ps1 on Windows)" >&2; exit 1 ;; esac case "$(uname -m)" in @@ -20,20 +24,21 @@ case "$(uname -m)" in esac # Accept an optional directory path as the first argument. -# Default to the cli/dist directory if not provided. -ARTIFACT_DIR="${1:-$(cd "$(dirname "$0")/../dist" && pwd)}" -TARBALL="$(ls "${ARTIFACT_DIR}"/difyctl-*-${os}-${arch}.tar.xz 2>/dev/null | head -1)" +# Default to the cli/dist/bin directory if not provided. +ARTIFACT_DIR="${1:-$(cd "$(dirname "$0")/../dist/bin" 2>/dev/null && pwd || true)}" +BINARY="$(ls "${ARTIFACT_DIR}"/difyctl-v*-${os}-${arch} 2>/dev/null | sort -V | tail -1)" -if [ -z "$TARBALL" ]; then - echo "no tarball found for ${os}-${arch} in ${ARTIFACT_DIR}" >&2 - echo "run: pnpm pack:tarballs" >&2 +if [ -z "$BINARY" ]; then + echo "no binary found for ${os}-${arch} in ${ARTIFACT_DIR:-}" >&2 + echo "run: pnpm build:bin" >&2 exit 1 fi -echo "installing from $(basename "$TARBALL") ..." +echo "installing from $(basename "$BINARY") ..." rm -rf "$SHARE_DIR" -mkdir -p "$SHARE_DIR" "$BIN_DIR" -tar -xJf "$TARBALL" -C "$SHARE_DIR" --strip-components=1 +mkdir -p "${SHARE_DIR}/bin" "$BIN_DIR" +cp "$BINARY" "${SHARE_DIR}/bin/difyctl" +chmod +x "${SHARE_DIR}/bin/difyctl" ln -sf "${SHARE_DIR}/bin/difyctl" "${BIN_DIR}/difyctl" echo "installed: ${BIN_DIR}/difyctl" diff --git a/cli/scripts/install-r2.ps1 b/cli/scripts/install-r2.ps1 new file mode 100644 index 00000000000..44321d13fac --- /dev/null +++ b/cli/scripts/install-r2.ps1 @@ -0,0 +1,104 @@ +# install-r2.ps1 — one-line difyctl installer (Windows) from Cloudflare R2. +# Usage: $env:DIFYCTL_R2_BASE=''; irm https://raw.githubusercontent.com/langgenius/dify/main/cli/scripts/install-r2.ps1 | iex +# Env: DIFYCTL_R2_BASE (required), DIFYCTL_CHANNEL (default edge), +# DIFYCTL_INSTALL_DIR (default %LOCALAPPDATA%\difyctl\bin; binary written here as difyctl.exe), +# DIFYCTL_VERSION (pin exact published version), +# DIFYCTL_COMMIT (pin by git commit, short or full sha; via index.json), +# DIFYCTL_R2_PREFIX (default difyctl; key root for pointer JSONs), +# DIFYCTL_R2_BIN_PREFIX (default /bin; key root for binaries). +# With no pin the channel pointer (latest) is installed. +$ErrorActionPreference = 'Stop' + +function Get-TargetField($manifest, [string]$target, [string]$field) { + $t = $manifest.targets.$target + if (-not $t) { return $null } + return $t.$field +} + +# First build in index.json matching by exact version or commit prefix. +function Resolve-IndexBuild($index, [string]$kind, [string]$want) { + foreach ($b in $index.builds) { + $sel = if ($kind -eq 'commit') { $b.commit } else { $b.version } + $hit = if ($kind -eq 'commit') { $sel.StartsWith($want) } else { $sel -eq $want } + if ($hit) { return $b } + } + return $null +} + +# Parse a checksums.txt (" ") for the line whose asset matches the +# target; returns @{ Sha; Asset } or $null. +function Get-ChecksumTarget([string]$text, [string]$target) { + foreach ($line in ($text -split "`n")) { + $line = $line.Trim() + if ($line -match "\sdifyctl-v.*-$target(\.exe)?$") { + $parts = $line -split '\s+' + return @{ Sha = $parts[0]; Asset = $parts[-1] } + } + } + return $null +} + +# Download, sha256-verify, place. Returns nothing; throws on mismatch. +function Install-DifyctlBinary([string]$dlUrl, [string]$sha, [string]$version, [string]$channel, [string]$installDir) { + $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetFileName($dlUrl)) + Invoke-WebRequest -Uri $dlUrl -OutFile $tmp + $actual = (Get-FileHash -Path $tmp -Algorithm SHA256).Hash.ToLower() + if ($actual -ne $sha.ToLower()) { throw "checksum mismatch for $dlUrl" } + + New-Item -ItemType Directory -Path $installDir -Force | Out-Null + $dest = Join-Path $installDir 'difyctl.exe' + try { Move-Item -Path $tmp -Destination $dest -Force } + catch { throw "cannot replace $dest — close any running difyctl and re-run." } + + Write-Host "difyctl $version (channel $channel) installed: $dest" + if (($env:PATH -split ';') -notcontains $installDir) { + Write-Host "$installDir is not on your PATH. Add it with:" + Write-Host " [Environment]::SetEnvironmentVariable('PATH', `"$installDir;`$env:PATH`", 'User')" + } +} + +function Install-DifyctlR2 { + $base = $env:DIFYCTL_R2_BASE + if (-not $base) { throw "set DIFYCTL_R2_BASE to the R2 public base (e.g. https://pub-….r2.dev)" } + $base = $base.TrimEnd('/') + $channel = if ($env:DIFYCTL_CHANNEL) { $env:DIFYCTL_CHANNEL } else { 'edge' } + $prefix = if ($env:DIFYCTL_R2_PREFIX) { $env:DIFYCTL_R2_PREFIX } else { 'difyctl' } + $binPrefix = if ($env:DIFYCTL_R2_BIN_PREFIX) { $env:DIFYCTL_R2_BIN_PREFIX } else { "$prefix/bin" } + $installDir = if ($env:DIFYCTL_INSTALL_DIR) { $env:DIFYCTL_INSTALL_DIR } else { Join-Path (Join-Path $env:LOCALAPPDATA 'difyctl') 'bin' } + $target = 'windows-x64' + + if ($env:DIFYCTL_VERSION -or $env:DIFYCTL_COMMIT) { + $iurl = "$base/$prefix/$channel/index.json" + try { $index = Invoke-RestMethod -Uri $iurl } + catch { throw "R2 unavailable fetching $iurl; retry." } + $build = if ($env:DIFYCTL_VERSION) { Resolve-IndexBuild $index 'version' $env:DIFYCTL_VERSION } + else { Resolve-IndexBuild $index 'commit' $env:DIFYCTL_COMMIT } + $pin = if ($env:DIFYCTL_VERSION) { $env:DIFYCTL_VERSION } else { $env:DIFYCTL_COMMIT } + if (-not $build) { throw "no build matching $pin in channel $channel" } + $version = $build.version + $vbase = "$base/$binPrefix/$channel/$($build.dir)" + try { $cf = (Invoke-WebRequest -Uri "$vbase/difyctl-v$version-checksums.txt").Content } + catch { throw "checksums missing for $version (channel $channel)" } + $ct = Get-ChecksumTarget $cf $target + if (-not $ct) { throw "no build for $target at $version" } + Install-DifyctlBinary "$vbase/$($ct.Asset)" $ct.Sha $version $channel $installDir + return + } + + $murl = "$base/$prefix/$channel/manifest.json" + try { $manifest = Invoke-RestMethod -Uri $murl } + catch { + if ($_.Exception.Response.StatusCode.value__ -eq 404) { + throw "channel '$channel' not published to R2. For rc/stable use install.ps1 (GitHub)." + } + throw "R2 unavailable fetching $murl; retry." + } + if ($manifest.channel -ne $channel) { throw "manifest channel '$($manifest.channel)' != requested '$channel'" } + + $asset = Get-TargetField $manifest $target 'asset' + $sha = Get-TargetField $manifest $target 'sha256' + if (-not $asset) { throw "no build for $target in channel $channel" } + Install-DifyctlBinary "$($manifest.baseUrl)/$asset" $sha $manifest.version $channel $installDir +} + +if ($env:DIFYCTL_INSTALL_LIB -ne '1') { Install-DifyctlR2 } diff --git a/cli/scripts/install-r2.ps1.test.ts b/cli/scripts/install-r2.ps1.test.ts new file mode 100644 index 00000000000..672f103c4aa --- /dev/null +++ b/cli/scripts/install-r2.ps1.test.ts @@ -0,0 +1,46 @@ +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const SCRIPT = fileURLToPath(new URL('./install-r2.ps1', import.meta.url)) +const hasPwsh = spawnSync('pwsh', ['-v'], { encoding: 'utf8' }).status === 0 +const d = hasPwsh ? describe : describe.skip + +const MANIFEST = JSON.stringify({ + schema: 1, + name: 'difyctl', + channel: 'edge', + version: '0.1.0-edge.2fd7b82', + baseUrl: 'https://pub.example.r2.dev/difyctl/edge/0.1.0-edge.2fd7b82', + targets: { 'windows-x64': { asset: 'difyctl-v0.1.0-edge.2fd7b82-windows-x64.exe', sha256: 'deadbeef' } }, +}) + +function pwsh(program: string): { code: number, stdout: string, stderr: string } { + const full = `. '${SCRIPT}'\n${program}` + const r = spawnSync('pwsh', ['-NoProfile', '-Command', full], { + encoding: 'utf8', + env: { ...process.env, DIFYCTL_INSTALL_LIB: '1' }, + }) + return { code: r.status ?? 1, stdout: (r.stdout ?? '').replace(/\r\n/g, '\n').trim(), stderr: r.stderr ?? '' } +} + +d('install-r2.ps1', () => { + it('parses a target asset + sha from the manifest', () => { + const prog = `$m = ConvertFrom-Json @'\n${MANIFEST}\n'@\n` + + `Write-Output (Get-TargetField $m 'windows-x64' 'asset')\n` + + `Write-Output (Get-TargetField $m 'windows-x64' 'sha256')` + const { stdout } = pwsh(prog) + expect(stdout).toBe('difyctl-v0.1.0-edge.2fd7b82-windows-x64.exe\ndeadbeef') + }) + + it('errors when DIFYCTL_R2_BASE is unset', () => { + const r = spawnSync('pwsh', ['-NoProfile', '-File', SCRIPT], { + encoding: 'utf8', + env: { ...process.env, DIFYCTL_R2_BASE: '' }, + }) + if (hasPwsh) { + expect(r.status).not.toBe(0) + expect(r.stderr + r.stdout).toMatch(/DIFYCTL_R2_BASE/) + } + }) +}) diff --git a/cli/scripts/install-r2.sh b/cli/scripts/install-r2.sh new file mode 100755 index 00000000000..ffb4d980429 --- /dev/null +++ b/cli/scripts/install-r2.sh @@ -0,0 +1,164 @@ +#!/bin/sh +# install-r2.sh — one-line difyctl installer from Cloudflare R2. +# Reads a per-channel pointer manifest, sha256-verifies, installs to PATH. +# Usage: curl -fsSL https://raw.githubusercontent.com/langgenius/dify/main/cli/scripts/install-r2.sh | DIFYCTL_R2_BASE= sh +# Env: +# DIFYCTL_R2_BASE (required) R2 public base, e.g. https://pub-….r2.dev +# DIFYCTL_CHANNEL (default edge) +# DIFYCTL_INSTALL_DIR (default $HOME/.local/bin) directory the binary is written to as /difyctl +# DIFYCTL_VERSION pin an exact published version (e.g. 0.1.0-edge.ce4af868) +# DIFYCTL_COMMIT pin by git commit (short or full sha); resolved via index.json +# DIFYCTL_R2_PREFIX (default difyctl) key root for pointer JSONs +# DIFYCTL_R2_BIN_PREFIX (default /bin) key root for binaries +# With no pin the channel pointer (latest) is installed. A pin resolves through +# //index.json -> the build's immutable dir under the bin prefix. +set -eu + +# --- library functions (sourced for tests when DIFYCTL_INSTALL_LIB=1) --- +tmp_m="$(mktemp 2>/dev/null || echo /tmp/difyctl-manifest.$$)" +trap 'rm -f "$tmp_m" "${tmp_c:-}" "${tmp_b:-}"' EXIT INT TERM + +err() { printf '%s\n' "install-r2: $*" >&2; } +die() { err "$*"; exit 1; } +need() { command -v "$1" >/dev/null 2>&1 || die "$1 is required"; } + +detect_target() { + case "$(uname -s)" in + Linux*) _os=linux ;; + Darwin*) _os=darwin ;; + *) die "unsupported OS: $(uname -s) (use install.ps1 on Windows)" ;; + esac + case "$(uname -m)" in + x86_64|amd64) _arch=x64 ;; + arm64|aarch64) _arch=arm64 ;; + *) die "unsupported arch: $(uname -m)" ;; + esac + printf '%s-%s' "$_os" "$_arch" +} + +# grep/sed (no jq). Correct only because release-r2-edge.mjs renders one key per line. +# manifest_str -> value of a top-level string field +manifest_str() { + grep "\"$2\"[[:space:]]*:" "$1" | head -1 \ + | sed -E "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"([^\"]+)\".*/\\1/" +} + +# manifest_target_field -> value on that target's line +manifest_target_field() { + grep "\"$2\"[[:space:]]*:" "$1" | head -1 \ + | sed -E "s/.*\"$3\"[[:space:]]*:[[:space:]]*\"([^\"]+)\".*/\\1/" +} + +# Resolve a pinned build from index.json (no jq). Correct only because +# release-r2-edge.mjs renders each build's fields one per line, dir last. +# Prints "\t" of the first match, nothing if none. +# index_resolve (commit = prefix match) +index_resolve() { + awk -v kind="$2" -v want="$3" ' + function val(s) { sub(/^[^:]*:[[:space:]]*"/, "", s); sub(/".*$/, "", s); return s } + /"version"[[:space:]]*:/ { v = val($0) } + /"commit"[[:space:]]*:/ { c = val($0) } + /"dir"[[:space:]]*:/ { + d = val($0) + sel = (kind == "commit") ? c : v + if (kind == "commit") { if (index(sel, want) == 1) { print v "\t" d; exit } } + else if (sel == want) { print v "\t" d; exit } + } + ' "$1" +} + +# checksums_target -> "\t" +# checksums lines are " "; match asset ending - or -.exe. +checksums_target() { + grep -E "[[:space:]]difyctl-v.*-$2(\.exe)?\$" "$1" | head -1 \ + | awk '{ print $1 "\t" $NF }' +} + +sha256_check() { + # $1 = file, $2 = expected hex + if command -v sha256sum >/dev/null 2>&1; then _a="$(sha256sum "$1" | awk '{print $1}')" + elif command -v shasum >/dev/null 2>&1; then _a="$(shasum -a 256 "$1" | awk '{print $1}')" + else die "no sha256 tool (need sha256sum or shasum)"; fi + [ "$_a" = "$2" ] || die "checksum mismatch for $1" +} + +# fetch_verify_install +fetch_verify_install() { + tmp_b="$(mktemp)" + # NOTE: no --compressed — must hash the raw bytes + curl -fsSL "$1" -o "$tmp_b" || die "download failed: $1" + sha256_check "$tmp_b" "$2" + + mkdir -p "$install_dir" + chmod +x "$tmp_b" + mv "$tmp_b" "${install_dir}/difyctl" 2>/dev/null || { cp "$tmp_b" "${install_dir}/difyctl"; rm -f "$tmp_b"; } + + printf 'difyctl %s (channel %s) installed: %s\n' "$3" "$4" "${install_dir}/difyctl" + case ":${PATH}:" in + *":${install_dir}:"*) ;; + *) printf 'note: add %s to your PATH\n' "$install_dir" ;; + esac +} + +# Resolve a pinned build into download url + sha. Sets: version, dl_url, dl_sha. +resolve_pinned() { + iurl="${base}/${prefix}/${channel}/index.json" + curl -fsSL "$iurl" -o "$tmp_m" || die "R2 unavailable fetching ${iurl}; retry." + if [ -n "${DIFYCTL_VERSION:-}" ]; then res="$(index_resolve "$tmp_m" version "$DIFYCTL_VERSION")" + else res="$(index_resolve "$tmp_m" commit "$DIFYCTL_COMMIT")"; fi + [ -n "$res" ] || die "no build matching ${DIFYCTL_VERSION:-$DIFYCTL_COMMIT} in channel ${channel}" + version="$(printf '%s' "$res" | cut -f1)" + dir="$(printf '%s' "$res" | cut -f2)" + + vbase="${base}/${bin_prefix}/${channel}/${dir}" + tmp_c="$(mktemp)" + curl -fsSL "${vbase}/difyctl-v${version}-checksums.txt" -o "$tmp_c" \ + || die "checksums missing for ${version} (channel ${channel})" + line="$(checksums_target "$tmp_c" "$target")" + [ -n "$line" ] || die "no build for ${target} at ${version}" + dl_sha="$(printf '%s' "$line" | cut -f1)" + dl_url="${vbase}/$(printf '%s' "$line" | cut -f2)" +} + +# Resolve the channel pointer (latest) into download url + sha. Sets the same. +resolve_pointer() { + murl="${base}/${prefix}/${channel}/manifest.json" + _code="$(curl -fsS -o "$tmp_m" -w '%{http_code}' "$murl" 2>/dev/null || true)" + if [ ! -s "$tmp_m" ]; then + case "$_code" in + 404) die "channel '${channel}' not published to R2. For rc/stable use the GitHub installer (install-cli.sh)." ;; + *) die "R2 unavailable (HTTP ${_code:-?}) fetching ${murl}; retry." ;; + esac + fi + mchannel="$(manifest_str "$tmp_m" channel)" + [ "$mchannel" = "$channel" ] || die "manifest channel '${mchannel}' != requested '${channel}'" + version="$(manifest_str "$tmp_m" version)" + baseUrl="$(manifest_str "$tmp_m" baseUrl)" + asset="$(manifest_target_field "$tmp_m" "$target" asset)" + dl_sha="$(manifest_target_field "$tmp_m" "$target" sha256)" + [ -n "$asset" ] && [ -n "$dl_sha" ] || die "no build for ${target} in channel ${channel}" + dl_url="${baseUrl}/${asset}" +} + +install_main() { + need curl + [ -n "${DIFYCTL_R2_BASE:-}" ] || die "set DIFYCTL_R2_BASE to the R2 public base (e.g. https://pub-….r2.dev)" + base="${DIFYCTL_R2_BASE%/}" + channel="${DIFYCTL_CHANNEL:-edge}" + prefix="${DIFYCTL_R2_PREFIX:-difyctl}" + bin_prefix="${DIFYCTL_R2_BIN_PREFIX:-${prefix}/bin}" + install_dir="${DIFYCTL_INSTALL_DIR:-${HOME}/.local/bin}" + target="$(detect_target)" + + if [ -n "${DIFYCTL_VERSION:-}" ] || [ -n "${DIFYCTL_COMMIT:-}" ]; then + resolve_pinned + else + resolve_pointer + fi + + fetch_verify_install "$dl_url" "$dl_sha" "$version" "$channel" +} + +if [ "${DIFYCTL_INSTALL_LIB:-0}" != "1" ]; then + install_main +fi diff --git a/cli/scripts/install-r2.test.ts b/cli/scripts/install-r2.test.ts new file mode 100644 index 00000000000..3e02439d6cf --- /dev/null +++ b/cli/scripts/install-r2.test.ts @@ -0,0 +1,131 @@ +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const SCRIPT = fileURLToPath(new URL('./install-r2.sh', import.meta.url)) + +const MANIFEST = [ + '{', + ' "schema": 1,', + ' "name": "difyctl",', + ' "channel": "edge",', + ' "version": "0.1.0-edge.2fd7b82",', + ' "commit": "abc1234",', + ' "buildDate": "2026-06-14T12:00:00Z",', + ' "compat": {"minDify":"1.14.0","maxDify":"1.15.0"},', + ' "baseUrl": "https://pub.example.r2.dev/difyctl/edge/0.1.0-edge.2fd7b82",', + ' "targets": {', + ' "linux-x64": { "asset": "difyctl-v0.1.0-edge.2fd7b82-linux-x64", "sha256": "deadbeef" },', + ' "darwin-arm64": { "asset": "difyctl-v0.1.0-edge.2fd7b82-darwin-arm64", "sha256": "cafef00d" }', + ' }', + '}', +].join('\n') + +const INDEX = [ + '{', + ' "schema": 1,', + ' "channel": "edge",', + ' "updated": "2026-06-15T00:00:00Z",', + ' "builds": [', + ' {', + ' "version": "0.1.0-edge.ce4af868",', + ' "commit": "ce4af868d653f405070fabb3be3303430cc030ad",', + ' "buildDate": "2026-06-15T00:00:00Z",', + ' "dir": "0.1.0-edge.ce4af868"', + ' },', + ' {', + ' "version": "0.1.0-edge.aaaa111",', + ' "commit": "aaaa111bbbbcccc000011112222333344445555",', + ' "buildDate": "2026-06-14T00:00:00Z",', + ' "dir": "0.1.0-edge.aaaa111"', + ' }', + ' ]', + '}', +].join('\n') + +const CHECKSUMS = [ + 'deadbeef difyctl-v0.1.0-edge.ce4af868-linux-x64', + 'cafef00d difyctl-v0.1.0-edge.ce4af868-darwin-arm64', + 'beadc0de difyctl-v0.1.0-edge.ce4af868-windows-x64.exe', +].join('\n') + +function lib(program: string, env: Record = {}): { code: number, stdout: string, stderr: string } { + const full = `. "${SCRIPT}"\n${program}` + const r = spawnSync('sh', ['-c', full], { + encoding: 'utf8', + env: { ...process.env, DIFYCTL_INSTALL_LIB: '1', ...env }, + }) + return { code: r.status ?? 1, stdout: (r.stdout ?? '').trim(), stderr: r.stderr ?? '' } +} + +describe('install-r2 manifest parsing', () => { + // install-r2.sh is POSIX-only; under git-bash on Windows `uname -s` is MINGW*, + // so detect_target intentionally dies (Windows installs go through install-r2.ps1). + it.skipIf(process.platform === 'win32')('detect_target maps to one of the 5 ids', () => { + const { stdout } = lib('detect_target') + expect(['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64']).toContain(stdout) + }) + + it('manifest_str reads a top-level string field', () => { + const { stdout } = lib(`printf '%s' '${MANIFEST}' > "$tmp_m"; manifest_str "$tmp_m" channel`, {}) + expect(stdout).toBe('edge') + }) + + it('manifest_target_field extracts per-target values from a single line', () => { + const prog = `printf '%s' '${MANIFEST}' > "$tmp_m"\n` + + 'manifest_target_field "$tmp_m" darwin-arm64 asset\n' + + 'manifest_target_field "$tmp_m" darwin-arm64 sha256' + const { stdout } = lib(prog) + expect(stdout).toBe('difyctl-v0.1.0-edge.2fd7b82-darwin-arm64\ncafef00d') + }) + + it('requires DIFYCTL_R2_BASE when run as the installer (not lib)', () => { + const r = spawnSync('sh', [SCRIPT], { encoding: 'utf8', env: { ...process.env, DIFYCTL_R2_BASE: '' } }) + expect(r.status).not.toBe(0) + expect(r.stderr).toMatch(/DIFYCTL_R2_BASE/) + }) + + it('sha256_check aborts on a checksum mismatch', () => { + const r = lib('f="$(mktemp)"; printf \'hello\' > "$f"; sha256_check "$f" deadbeef') + expect(r.code).not.toBe(0) + expect(r.stderr).toMatch(/checksum mismatch/) + }) + + it('sha256_check passes on the correct hash', () => { + // sha256('hello') = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 + const r = lib('f="$(mktemp)"; printf \'hello\' > "$f"; sha256_check "$f" 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 && echo OK') + expect(r.stdout).toBe('OK') + }) +}) + +describe('install-r2 version/commit pin', () => { + it('index_resolve matches a build by exact version', () => { + const r = lib(`printf '%s' '${INDEX}' > "$tmp_m"; index_resolve "$tmp_m" version 0.1.0-edge.aaaa111`) + expect(r.stdout).toBe('0.1.0-edge.aaaa111\t0.1.0-edge.aaaa111') + }) + + it('index_resolve matches a build by commit prefix', () => { + const r = lib(`printf '%s' '${INDEX}' > "$tmp_m"; index_resolve "$tmp_m" commit ce4af868`) + expect(r.stdout).toBe('0.1.0-edge.ce4af868\t0.1.0-edge.ce4af868') + }) + + it('index_resolve matches the full 40-char commit too', () => { + const r = lib(`printf '%s' '${INDEX}' > "$tmp_m"; index_resolve "$tmp_m" commit aaaa111bbbbcccc000011112222333344445555`) + expect(r.stdout).toBe('0.1.0-edge.aaaa111\t0.1.0-edge.aaaa111') + }) + + it('index_resolve prints nothing when no build matches', () => { + const r = lib(`printf '%s' '${INDEX}' > "$tmp_m"; index_resolve "$tmp_m" commit ffffff`) + expect(r.stdout).toBe('') + }) + + it('checksums_target extracts sha and asset for a posix target', () => { + const r = lib(`printf '%s' '${CHECKSUMS}' > "$tmp_m"; checksums_target "$tmp_m" darwin-arm64`) + expect(r.stdout).toBe('cafef00d\tdifyctl-v0.1.0-edge.ce4af868-darwin-arm64') + }) + + it('checksums_target does not bleed x64 into arm64', () => { + const r = lib(`printf '%s' '${CHECKSUMS}' > "$tmp_m"; checksums_target "$tmp_m" linux-x64`) + expect(r.stdout).toBe('deadbeef\tdifyctl-v0.1.0-edge.ce4af868-linux-x64') + }) +}) diff --git a/cli/scripts/release-naming.mjs b/cli/scripts/release-naming.mjs index 17ce210222d..5d7457b59b0 100644 --- a/cli/scripts/release-naming.mjs +++ b/cli/scripts/release-naming.mjs @@ -17,19 +17,17 @@ // validate -> exit 1 if difyctl.release, version, or channel is malformed // compat-check -> exit 1 if difyVer outside compat.minDify..maxDify -import { readFileSync } from 'node:fs' +import { readFileSync, realpathSync } from 'node:fs' +import { fileURLToPath } from 'node:url' const BUN_TARGET_RE = /^bun-(linux|darwin|windows)-(x64|arm64)$/ const SEMVER_CORE_LEN = 3 -// Channel registry — single source for which version forms are releasable and -// resolvable. Each `versionForm` is pinned to exactly what the installers' -// channel filters accept (stable = no prerelease; rc = -rc.N with nothing -// trailing), so any version that passes `validate` is guaranteed resolvable at -// install time. Extend by adding an entry: { name, prerelease, versionForm }. +// Add channels here: { name, prerelease, versionForm }. const CHANNELS = [ { name: 'stable', prerelease: false, versionForm: /^\d+\.\d+\.\d+(\+[0-9A-Z.-]+)?$/i }, { name: 'rc', prerelease: true, versionForm: /^\d+\.\d+\.\d+-rc\.\d+$/ }, + { name: 'edge', prerelease: true, versionForm: /^\d+\.\d+\.\d+-edge\.[0-9a-f]{7,40}$/ }, ] const channelByName = name => CHANNELS.find(c => c.name === name) @@ -43,6 +41,41 @@ function parsePrecedence(v) { return { nums: core.split('.').map(Number), pre } } +function versionCore(v) { + return String(v).replace(/^v/, '').replace(/\+.*$/, '').split('-')[0] +} + +function edgeVersion(sha) { + if (!/^[0-9a-f]{7,40}$/.test(sha ?? '')) + die('edge-version requires a git short sha (7-40 hex chars)') + const { version } = loadPkg() + const core = versionCore(version) + if (!/^\d+\.\d+\.\d+$/.test(core)) + die(`cannot derive edge base from version: ${version}`) + return `${core}-edge.${sha}` +} + +// Returns a problem string if `version` cannot be resolved under `channel`, else +// null. Shared by validateVersionForChannel (die-now) and validateVersionChannel +// (collect for the `validate` gate). +function channelVersionProblem(version, channel) { + if (typeof version !== 'string' || version.length === 0) + return 'version must be a non-empty string' + const ch = channelByName(channel) + if (!ch) + return `unknown channel: ${channel} (expected one of: ${channelNames()})` + if (!ch.versionForm.test(version)) + return `version ${version} does not match the ${channel} channel form` + return null +} + +function validateVersionForChannel(version, channelName) { + const problem = channelVersionProblem(version, channelName) + if (problem) + die(problem) + return `valid: ${version} is a ${channelName} version` +} + function comparePre(a, b) { const aparts = a.split('.') const bparts = b.split('.') @@ -105,9 +138,7 @@ function loadPkg() { } } -// Every field downstream CI needs, as `key=value` lines for $GITHUB_ENV. Each -// job pipes this once into the environment, then references ${{ env. }} -// at use sites. +// Emits key=value lines for $GITHUB_ENV. function githubEnv() { const { version, channel, compat, release } = loadPkg() const fields = { @@ -167,19 +198,9 @@ function validateRelease(release) { return problems } -// Enforce that the version matches the form its declared channel can resolve. -// Rejects e.g. channel=rc + 1.2.3-rc5 (no dot), channel=stable + 1.2.3-rc.1, -// or any unknown channel — before a release that no installer could find ships. function validateVersionChannel(version, channel) { - const problems = [] - if (typeof version !== 'string' || version.length === 0) - return ['package.json version must be a non-empty string'] - const ch = channelByName(channel) - if (!ch) - return [`difyctl.channel ${JSON.stringify(channel)} is not a known channel (expected one of: ${channelNames()})`] - if (!ch.versionForm.test(version)) - problems.push(`version "${version}" does not match the ${channel} channel form ${ch.versionForm}; an installer could not resolve it`) - return problems + const problem = channelVersionProblem(version, channel) + return problem ? [problem] : [] } function main(argv) { @@ -223,9 +244,21 @@ function main(argv) { die(`invalid difyctl release config:\n - ${problems.join('\n - ')}`) return `difyctl release valid: version=${version} channel=${channel} targets=${release.targets.length}` } + case 'edge-version': + return edgeVersion(rest[0]) + case 'validate-version': + return validateVersionForChannel( + requireVersion(rest[0]), + rest[1] ?? die('channel argument is required'), + ) default: die(`unknown subcommand: ${cmd ?? '(none)'}`) } } -process.stdout.write(`${main(process.argv.slice(2))}\n`) +const invokedDirectly = process.argv[1] + && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url) +if (invokedDirectly) + process.stdout.write(`${main(process.argv.slice(2))}\n`) + +export { assetName, channelByName, CHANNELS, edgeVersion, loadPkg, validateVersionForChannel, versionCore } diff --git a/cli/scripts/release-naming.test.ts b/cli/scripts/release-naming.test.ts index bf971db50eb..ad2a699ef8d 100644 --- a/cli/scripts/release-naming.test.ts +++ b/cli/scripts/release-naming.test.ts @@ -73,3 +73,35 @@ describe('release-naming github-env', () => { expect(stdout).toMatch(new RegExp(`^${key}=`, 'm')) }) }) + +describe('release-naming edge channel', () => { + it('lists edge among channels', () => { + expect(run(['channels']).stdout).toMatch(/^edge$/m) + }) + + it('edge-version derives -edge. stripping the rc prerelease', () => { + // package.json version is 0.1.0-rc.1 -> core 0.1.0 + expect(run(['edge-version', '2fd7b82']).stdout.trim()).toBe('0.1.0-edge.2fd7b82') + }) + + it('edge-version accepts a 40-char sha', () => { + const sha = '2fd7b829e1f0aaaabbbbccccddddeeeeffff0000' + expect(run(['edge-version', sha]).stdout.trim()).toBe(`0.1.0-edge.${sha}`) + }) + + it('edge-version rejects a non-hex sha', () => { + expect(run(['edge-version', 'nothex!']).code).not.toBe(0) + }) + + it('edge-version requires a sha argument', () => { + expect(run(['edge-version']).code).not.toBe(0) + }) + + it('the edge version form matches a computed edge version', () => { + expect(run(['validate-version', '0.1.0-edge.2fd7b82', 'edge']).code).toBe(0) + }) + + it('validate-version rejects an rc string under the edge channel', () => { + expect(run(['validate-version', '0.1.0-rc.1', 'edge']).code).not.toBe(0) + }) +}) diff --git a/cli/scripts/release-r2-edge.mjs b/cli/scripts/release-r2-edge.mjs new file mode 100644 index 00000000000..55b40435b6f --- /dev/null +++ b/cli/scripts/release-r2-edge.mjs @@ -0,0 +1,129 @@ +#!/usr/bin/env node +// release-r2-edge.mjs — edge/R2 release metadata generator. Two subcommands: +// manifest -> the per-channel pointer manifest.json (the installer reads this) +// index -> the per-channel build-history ledger index.json +import { existsSync, readFileSync } from 'node:fs' +import { assetName, loadPkg, validateVersionForChannel } from './release-naming.mjs' + +function die(msg) { + process.stderr.write(`release-r2-edge: ${msg}\n`) + process.exit(1) +} + +function parseArgs(argv) { + const out = {} + for (let i = 0; i < argv.length; i += 2) { + const key = argv[i]?.replace(/^--/, '') + const val = argv[i + 1] + if (!key || val === undefined || val.startsWith('--')) + die(`malformed argument near ${argv[i]} (expected --key value)`) + out[key] = val + } + return out +} + +function requireArgs(args, keys) { + for (const k of keys) { + if (!args[k]) + die(`missing --${k}`) + } +} + +// checksums lines are " " +function shaMap(checksumsPath) { + const map = new Map() + for (const line of readFileSync(checksumsPath, 'utf8').split('\n')) { + const m = line.match(/^([0-9a-f]{64})\s+(\S+)$/i) + if (m) + map.set(m[2], m[1]) + } + return map +} + +function emitManifest(args) { + requireArgs(args, ['channel', 'version', 'commit', 'build-date', 'base-url', 'checksums']) + validateVersionForChannel(args.version, args.channel) + const { release, compat } = loadPkg() + const shas = shaMap(args.checksums) + + const targetLines = release.targets.map((t) => { + const asset = assetName(release, args.version, t.id) + const sha = shas.get(asset) + if (!sha) + die(`no sha256 for ${asset} in ${args.checksums}`) + // one target per line: install-r2.sh grep/sed depends on this layout + return ` ${JSON.stringify(t.id)}: { "asset": ${JSON.stringify(asset)}, "sha256": ${JSON.stringify(sha)} }` + }).join(',\n') + + const head = { + schema: 1, + name: release.binName, + channel: args.channel, + version: args.version, + commit: args.commit, + buildDate: args['build-date'], + compat: { minDify: compat.minDify, maxDify: compat.maxDify }, + baseUrl: args['base-url'], + } + const headLines = Object.entries(head).map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)}`).join(',\n') + process.stdout.write(`{\n${headLines},\n "targets": {\n${targetLines}\n }\n}\n`) +} + +// Newline-delimited dir names of binaries that still exist in R2. Absent file = +// no reconciliation (caller could not list); empty file = no survivors. +function loadExistingDirs(path) { + if (!path || !existsSync(path)) + return null + const set = new Set() + for (const line of readFileSync(path, 'utf8').split('\n')) { + const d = line.trim() + if (d) + set.add(d) + } + return set +} + +function emitIndex(args) { + requireArgs(args, ['current', 'channel', 'version', 'commit', 'build-date']) + + // empty / "-" / missing = no ledger yet (first publish) + let current = { schema: 1, channel: args.channel, builds: [] } + if (args.current !== '-' && existsSync(args.current)) { + const raw = readFileSync(args.current, 'utf8').trim() + if (raw && raw !== '-') { + try { + current = JSON.parse(raw) + } + catch { + die(`current index at ${args.current} is not valid JSON`) + } + } + } + + const entry = { version: args.version, commit: args.commit, buildDate: args['build-date'], dir: args.version } + const kept = (current.builds ?? []).filter(b => b.version !== entry.version) + let builds = [entry, ...kept] + + // Reconcile to binaries that still exist in R2: lifecycle/TTL on the bin prefix + // is the only deletion mechanism, so the ledger never advertises a build whose + // binary is gone. The new build is always kept (just uploaded). No count cap. + const existing = loadExistingDirs(args['existing-dirs']) + if (existing) + builds = builds.filter(b => b.dir === entry.dir || existing.has(b.dir)) + + const index = { schema: 1, channel: args.channel, updated: args['build-date'], builds } + process.stdout.write(`${JSON.stringify(index, null, 2)}\n`) +} + +const [cmd, ...rest] = process.argv.slice(2) +const args = parseArgs(rest) +switch (cmd) { + case 'manifest': + emitManifest(args) + break + case 'index': + emitIndex(args) + break + default: + die(`unknown subcommand: ${cmd ?? '(none)'} (expected: manifest | index)`) +} diff --git a/cli/scripts/release-r2-edge.test.ts b/cli/scripts/release-r2-edge.test.ts new file mode 100644 index 00000000000..1514c6a28e3 --- /dev/null +++ b/cli/scripts/release-r2-edge.test.ts @@ -0,0 +1,210 @@ +import { execFileSync, spawnSync } from 'node:child_process' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const SCRIPT = fileURLToPath(new URL('./release-r2-edge.mjs', import.meta.url)) + +function run(args: string[]): { code: number, stdout: string, stderr: string } { + try { + return { code: 0, stdout: execFileSync('node', [SCRIPT, ...args], { encoding: 'utf8' }), stderr: '' } + } + catch (e) { + const err = e as { status?: number, stdout?: string, stderr?: string } + return { code: err.status ?? 1, stdout: err.stdout ?? '', stderr: err.stderr ?? '' } + } +} + +// ---- manifest ---- + +function writeChecksums(version: string): string { + const dir = mkdtempSync(join(tmpdir(), 'difyctl-manifest-')) + const ids = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64'] + const lines = ids.map((id, i) => { + const exe = id === 'windows-x64' ? '.exe' : '' + const sha = String(i).repeat(64) + return `${sha} difyctl-v${version}-${id}${exe}` + }) + const file = join(dir, `difyctl-v${version}-checksums.txt`) + writeFileSync(file, `${lines.join('\n')}\n`) + return file +} + +const VERSION = '0.1.0-edge.2fd7b82' +const BASE_URL = 'https://example.r2.dev/difyctl/edge/0.1.0-edge.2fd7b82' + +type ManifestJson = { + schema: number + name: string + channel: string + version: string + commit: string + buildDate: string + compat: { minDify: string, maxDify: string } + baseUrl: string + targets: Record +} + +type IndexBuild = { + version: string + commit: string + buildDate: string + dir: string +} + +type IndexJson = { + schema: number + channel: string + updated: string + builds: IndexBuild[] +} + +function buildManifest(version = VERSION): { code: number, json: ManifestJson, stdout: string, stderr: string } { + const checksums = writeChecksums(version) + const r = run(['manifest', '--channel', 'edge', '--version', version, '--commit', 'abc1234', '--build-date', '2026-06-14T12:00:00Z', '--base-url', BASE_URL, '--checksums', checksums]) + return { code: r.code, json: (r.code === 0 ? JSON.parse(r.stdout) : null) as ManifestJson, stdout: r.stdout, stderr: r.stderr } +} + +describe('release-r2-edge manifest', () => { + it('emits the core pointer fields', () => { + const { json } = buildManifest() + expect(json.schema).toBe(1) + expect(json.name).toBe('difyctl') + expect(json.channel).toBe('edge') + expect(json.version).toBe(VERSION) + expect(json.commit).toBe('abc1234') + expect(json.buildDate).toBe('2026-06-14T12:00:00Z') + expect(json.baseUrl).toBe(BASE_URL) + }) + + it('carries the compat window from package.json', () => { + const { json } = buildManifest() + expect(json.compat).toEqual({ minDify: '1.14.0', maxDify: '1.15.0' }) + }) + + it('lists all 5 targets with asset name + sha256 from the checksums file', () => { + const { json } = buildManifest() + expect(Object.keys(json.targets).sort()).toEqual( + ['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64', 'windows-x64'], + ) + expect(json.targets['linux-x64'].asset).toBe(`difyctl-v${VERSION}-linux-x64`) + expect(json.targets['windows-x64'].asset).toBe(`difyctl-v${VERSION}-windows-x64.exe`) + expect(json.targets['linux-x64'].sha256).toMatch(/^\d{64}$/) + }) + + it('renders each target on a single line (installer greps it)', () => { + const { stdout } = buildManifest() + expect(stdout).toMatch(/^ {4}"linux-x64": \{ "asset": ".*", "sha256": ".*" \}/m) + }) + + it('rejects a version that does not match the channel form', () => { + const { code } = buildManifest('0.1.0-rc.1') + expect(code).not.toBe(0) + }) + + it('dies when a target sha is missing from the checksums file', () => { + const dir = mkdtempSync(join(tmpdir(), 'difyctl-manifest-')) + const file = join(dir, `difyctl-v${VERSION}-checksums.txt`) + writeFileSync(file, `${'0'.repeat(64)} difyctl-v${VERSION}-linux-x64\n`) // only 1 of 5 + const r = run(['manifest', '--channel', 'edge', '--version', VERSION, '--commit', 'abc1234', '--build-date', '2026-06-14T12:00:00Z', '--base-url', BASE_URL, '--checksums', file]) + expect(r.code).not.toBe(0) + }) + + it('rejects a malformed dropped-value argument (no silent misparse)', () => { + // --version has no value; --commit must NOT be swallowed as the version + const r = run(['manifest', '--channel', 'edge', '--version', '--commit', 'abc1234', '--build-date', '2026-06-14T12:00:00Z', '--base-url', 'https://x', '--checksums', '/nonexistent']) + expect(r.code).not.toBe(0) + }) +}) + +// ---- index ---- + +function runIndex(currentContent: string | null, build: Record, existingDirs?: string[]) { + let currentArg = '-' + if (currentContent !== null) { + const dir = mkdtempSync(join(tmpdir(), 'difyctl-index-')) + currentArg = join(dir, 'index.json') + writeFileSync(currentArg, currentContent) + } + const extra: string[] = [] + if (existingDirs !== undefined) { + const dir = mkdtempSync(join(tmpdir(), 'difyctl-existing-')) + const f = join(dir, 'existing.txt') + writeFileSync(f, `${existingDirs.join('\n')}\n`) + extra.push('--existing-dirs', f) + } + const r = spawnSync('node', [SCRIPT, 'index', '--current', currentArg, '--channel', 'edge', '--version', build.version, '--commit', build.commit, '--build-date', build.buildDate, ...extra], { encoding: 'utf8' }) + return { + code: r.status ?? 1, + index: (r.status === 0 ? JSON.parse(r.stdout) : null) as IndexJson, + } +} + +const B1 = { version: '0.1.0-edge.aaaaaaa', commit: 'aaaaaaa', buildDate: '2026-06-14T09:00:00Z' } +const B2 = { version: '0.1.0-edge.bbbbbbb', commit: 'bbbbbbb', buildDate: '2026-06-14T10:00:00Z' } + +describe('release-r2-edge index', () => { + it('creates a fresh index from a missing current (arg "-")', () => { + const { index } = runIndex(null, B1) + expect(index.schema).toBe(1) + expect(index.channel).toBe('edge') + expect(index.builds).toHaveLength(1) + expect(index.builds[0]).toMatchObject({ version: B1.version, commit: B1.commit, dir: B1.version }) + }) + + it('treats an empty current file as fresh (first publish, curl wrote nothing)', () => { + const { code, index } = runIndex('', B1) + expect(code).toBe(0) + expect(index.builds).toHaveLength(1) + }) + + it('treats a "-"-content current file as fresh (curl 404 fallback)', () => { + const { code, index } = runIndex('-\n', B1) + expect(code).toBe(0) + expect(index.builds).toHaveLength(1) + }) + + it('prepends the new build (publish order; newest at [0])', () => { + const current = JSON.stringify({ schema: 1, channel: 'edge', builds: [{ version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version }] }) + const { index } = runIndex(current, B2) + expect(index.builds.map(b => b.version)).toEqual([B2.version, B1.version]) + }) + + it('dedups a re-cut of the same version (no duplicate, moves to top)', () => { + const current = JSON.stringify({ schema: 1, channel: 'edge', builds: [ + { version: B2.version, commit: B2.commit, buildDate: B2.buildDate, dir: B2.version }, + { version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version }, + ] }) + const { index } = runIndex(current, B1) // re-cut B1 + expect(index.builds.map(b => b.version)).toEqual([B1.version, B2.version]) + }) + + it('reconciles to surviving binary dirs (drops a build whose binary expired)', () => { + const current = JSON.stringify({ schema: 1, channel: 'edge', builds: [ + { version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version }, + ] }) + // B1's binary is gone (not in existing); the new B2 is always kept. + const { index } = runIndex(current, B2, [B2.version]) + expect(index.builds.map(b => b.version)).toEqual([B2.version]) + }) + + it('keeps the new build even when it is absent from the existing-dirs list', () => { + const { index } = runIndex(null, B1, []) // empty survivors, fresh ledger + expect(index.builds.map(b => b.version)).toEqual([B1.version]) + }) + + it('does not reconcile when no --existing-dirs is given (list unavailable)', () => { + const current = JSON.stringify({ schema: 1, channel: 'edge', builds: [ + { version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version }, + ] }) + const { index } = runIndex(current, B2) // no existing-dirs → keep all + expect(index.builds.map(b => b.version)).toEqual([B2.version, B1.version]) + }) + + it('dies on a non-empty current file that is not valid JSON', () => { + const { code } = runIndex('{not json', B1) + expect(code).not.toBe(0) + }) +}) diff --git a/cli/scripts/release-r2-publish.sh b/cli/scripts/release-r2-publish.sh new file mode 100755 index 00000000000..4b411504012 --- /dev/null +++ b/cli/scripts/release-r2-publish.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# release-r2-publish.sh — direct-publish difyctl binaries + manifest + index to R2. +# Strict order so the pointer never references missing bytes: +# sync binaries -> HEAD-verify -> list survivors -> put index.json -> put manifest.json. +# Binaries live under the bin prefix and expire via an R2 lifecycle (TTL) rule on +# that prefix; the ledger is reconciled to surviving dirs, not pruned here. The +# pointer JSONs (manifest.json/index.json) live under the (non-expiring) prefix. +# Installers (install-r2.sh/.ps1) are served from the GitHub repo, not R2. +# Usage: release-r2-publish.sh +# Env: DIFYCTL_R2_S3_ENDPOINT DIFYCTL_R2_BUCKET DIFYCTL_R2_PUBLIC_BASE (+ AWS creds, AWS_REQUEST_CHECKSUM_CALCULATION). +# DIFYCTL_R2_PREFIX (default difyctl) key root for pointer JSONs +# DIFYCTL_R2_BIN_PREFIX (default /bin) key root for binaries (TTL target) +set -euo pipefail + +_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DIST_DIR="${DIST_DIR:-${_dir}/../dist/bin}" + +aws_s3() { aws --endpoint-url "$DIFYCTL_R2_S3_ENDPOINT" "$@"; } + +publish_main() { + local channel="$1" version="$2" + local prefix="${DIFYCTL_R2_PREFIX:-difyctl}" + local bin_prefix="${DIFYCTL_R2_BIN_PREFIX:-${prefix}/bin}" + local key="${bin_prefix}/${channel}/${version}" + local pointer_prefix="${prefix}/${channel}" + local base_url="${DIFYCTL_R2_PUBLIC_BASE}/${key}" + + local current="" new_index="" existing_dirs="" manifest="" + trap 'rm -f "$current" "$new_index" "$existing_dirs" "$manifest"' RETURN + + # 1. binaries (+ checksums) — immutable + aws_s3 s3 sync "$DIST_DIR" "s3://${DIFYCTL_R2_BUCKET}/${key}/" \ + --content-type application/octet-stream \ + --cache-control "public, max-age=31536000, immutable" + + # 2. HEAD-verify each expected target asset exists on R2. Capture targets into + # a variable first; a process-substitution producer failure bypasses set -e. + local targets + targets="$(node "${_dir}/release-naming.mjs" targets)" + [ -n "$targets" ] || { echo "release-r2-publish: no release targets resolved" >&2; exit 1; } + local verified=0 _bun id _exe asset + while IFS=$'\t' read -r _bun id _exe; do + asset="$(node "${_dir}/release-naming.mjs" asset "$version" "$id")" + aws_s3 s3api head-object --bucket "$DIFYCTL_R2_BUCKET" --key "${key}/${asset}" >/dev/null + verified=$((verified + 1)) + done <<< "$targets" + [ "$verified" -gt 0 ] || { echo "release-r2-publish: verified zero targets" >&2; exit 1; } + + # 3. survivors: binary dirs still present under the bin prefix (lifecycle/TTL may + # have deleted old ones). aws CLI auto-paginates list-objects-v2, so --query + # aggregates CommonPrefixes across all pages. On a list failure, skip + # reconciliation (keep the ledger as-is) rather than wrongly drop builds. + existing_dirs="$(mktemp)" + local listed=0 raw + raw="$(mktemp)" + if aws_s3 s3api list-objects-v2 --bucket "$DIFYCTL_R2_BUCKET" \ + --prefix "${bin_prefix}/${channel}/" --delimiter / \ + --query 'CommonPrefixes[].Prefix' --output text > "$raw" 2>/dev/null; then + # text rows are "///"; emit just . "None" = empty. + tr '\t' '\n' < "$raw" | awk -F/ 'NF>1 && $0 != "None" { print $(NF-1) }' > "$existing_dirs" + listed=1 + fi + rm -f "$raw" + + # 4. index.json: fetch current, prepend, reconcile to survivors. + current="$(mktemp)" + curl -fsSL "${DIFYCTL_R2_PUBLIC_BASE}/${pointer_prefix}/index.json" -o "$current" 2>/dev/null || echo '-' > "$current" + new_index="$(mktemp)" + if [ "$listed" = "1" ]; then + emit_index "$current" "$channel" "$version" --existing-dirs "$existing_dirs" >"$new_index" + else + emit_index "$current" "$channel" "$version" >"$new_index" + fi + aws_s3 s3 cp "$new_index" "s3://${DIFYCTL_R2_BUCKET}/${pointer_prefix}/index.json" \ + --content-type application/json --cache-control "public, max-age=60, must-revalidate" + + # 5. manifest.json — pointer; written last so it never references missing binaries + manifest="$(mktemp)" + node "${_dir}/release-r2-edge.mjs" manifest --channel "$channel" --version "$version" \ + --commit "${DIFYCTL_COMMIT:-unknown}" --build-date "${DIFYCTL_BUILD_DATE:-unknown}" \ + --base-url "$base_url" --checksums "${DIST_DIR}/difyctl-v${version}-checksums.txt" >"$manifest" + aws_s3 s3 cp "$manifest" "s3://${DIFYCTL_R2_BUCKET}/${pointer_prefix}/manifest.json" \ + --content-type application/json --cache-control "public, max-age=60, must-revalidate" +} + +# emit_index [extra args...] — wrapper so the +# survivor flag can be omitted without tripping `set -u` on empty arrays (bash 3.2). +emit_index() { + local current="$1" channel="$2" version="$3"; shift 3 + node "${_dir}/release-r2-edge.mjs" index --current "$current" --channel "$channel" \ + --version "$version" --commit "${DIFYCTL_COMMIT:-unknown}" --build-date "${DIFYCTL_BUILD_DATE:-unknown}" "$@" +} + +if [ "${RELEASE_PUBLISH_LIB:-0}" != "1" ]; then + [ "$#" -eq 2 ] || { echo "usage: release-r2-publish.sh " >&2; exit 2; } + publish_main "$1" "$2" +fi diff --git a/cli/scripts/release-r2-publish.test.ts b/cli/scripts/release-r2-publish.test.ts new file mode 100644 index 00000000000..0b99622c3da --- /dev/null +++ b/cli/scripts/release-r2-publish.test.ts @@ -0,0 +1,87 @@ +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const SCRIPT = fileURLToPath(new URL('./release-r2-publish.sh', import.meta.url)) + +// Stub `aws` + `curl` + `node` as shell functions that just log action verbs to +// $ORDER_LOG, then run the publish `main` and assert the order of operations. +function runPublish(): { code: number, order: string[], stderr: string } { + const stub = [ + 'ORDER_LOG="$(mktemp)"', + 'aws() {', + ' case "$*" in', + ' *"list-objects-v2"*) echo list-survivors >>"$ORDER_LOG" ;;', + ' *" cp "*"/index.json"*) echo put-index >>"$ORDER_LOG" ;;', + ' *" cp "*"/manifest.json"*) echo put-manifest >>"$ORDER_LOG" ;;', + ' *" sync "*) echo sync-binaries >>"$ORDER_LOG" ;;', + ' *"head-object"*) echo head-verify >>"$ORDER_LOG" ;;', + ' *) : ;;', + ' esac', + '}', + 'curl() { echo "{}"; }', + 'node() {', + ' case "$*" in', + ' *release-naming.mjs*targets*)', + ' printf \'bun-linux-x64\\tlinux-x64\\t0\\nbun-linux-arm64\\tlinux-arm64\\t0\\nbun-darwin-x64\\tdarwin-x64\\t0\\nbun-darwin-arm64\\tdarwin-arm64\\t0\\nbun-windows-x64\\twindows-x64\\t1\\n\' ;;', + ' *release-naming.mjs*\' asset \'*) printf \'difyctl-vX\\n\' ;;', + ' *release-r2-edge.mjs*\' index \'*) echo \'{}\' ;;', + ' *release-r2-edge.mjs*\' manifest \'*) echo \'{}\' ;;', + ' *) : ;;', + ' esac', + '}', + ].join('\n') + const program = [ + stub, + `. "${SCRIPT}"`, + 'publish_main edge 0.1.0-edge.2fd7b82', + 'cat "$ORDER_LOG"', + ].join('\n') + // bash, NOT sh: the script uses BASH_SOURCE + process substitution. + const r = spawnSync('bash', ['-c', program], { + encoding: 'utf8', + env: { + ...process.env, + RELEASE_PUBLISH_LIB: '1', + DIFYCTL_R2_S3_ENDPOINT: 'https://endpoint.example', + DIFYCTL_R2_BUCKET: 'cli-dev', + DIFYCTL_R2_PUBLIC_BASE: 'https://pub.example.r2.dev', + DIST_DIR: '/tmp', + }, + }) + return { code: r.status ?? 1, order: (r.stdout ?? '').trim().split('\n').filter(Boolean), stderr: r.stderr ?? '' } +} + +describe('release-r2-publish order', () => { + it('uploads binaries, verifies, lists survivors, then index, then manifest', () => { + const { code, order } = runPublish() + expect(code).toBe(0) + expect(order.indexOf('sync-binaries')).toBeLessThan(order.indexOf('head-verify')) + expect(order.indexOf('head-verify')).toBeLessThan(order.indexOf('list-survivors')) + expect(order.indexOf('list-survivors')).toBeLessThan(order.indexOf('put-index')) + expect(order.indexOf('put-index')).toBeLessThan(order.indexOf('put-manifest')) + // pointer is never pruned here — deletion is owned by the R2 lifecycle rule + expect(order).not.toContain('prune') + }) + + it('exits non-zero when no targets resolve (head-verify safety gate)', () => { + const stub = [ + 'aws() { :; }', + 'curl() { echo "{}"; }', + 'node() { case "$*" in *release-naming.mjs*targets*) : ;; *) echo "{}" ;; esac; }', + ].join('\n') + const program = [stub, `. "${SCRIPT}"`, 'publish_main edge 0.1.0-edge.2fd7b82'].join('\n') + const r = spawnSync('bash', ['-c', program], { + encoding: 'utf8', + env: { + ...process.env, + RELEASE_PUBLISH_LIB: '1', + DIFYCTL_R2_S3_ENDPOINT: 'https://endpoint.example', + DIFYCTL_R2_BUCKET: 'cli-dev', + DIFYCTL_R2_PUBLIC_BASE: 'https://pub.example.r2.dev', + DIST_DIR: '/tmp', + }, + }) + expect(r.status).not.toBe(0) + }) +}) diff --git a/cli/src/api/app-run.ts b/cli/src/api/app-run.ts index 1cb64057f8e..cbd36de2049 100644 --- a/cli/src/api/app-run.ts +++ b/cli/src/api/app-run.ts @@ -34,6 +34,7 @@ export function buildRunBody(args: RunBodyArgs): Record { export type StreamOptions = { signal?: AbortSignal includeStateSnapshot?: boolean + retryOnRateLimit?: boolean } export class AppRunClient { @@ -59,6 +60,7 @@ export class AppRunClient { headers: { Accept: 'text/event-stream' }, signal: opts.signal, throwOnError: true, + retryOnRateLimit: opts.retryOnRateLimit, }) if (res.body === null) throw new Error('streaming response body missing') diff --git a/cli/src/api/apps.ts b/cli/src/api/apps.ts index 40bb5c80053..ea0e41c252f 100644 --- a/cli/src/api/apps.ts +++ b/cli/src/api/apps.ts @@ -1,4 +1,4 @@ -import type { AppDescribeResponse, AppListResponse } from '@dify/contracts/api/openapi/types.gen' +import type { AppDescribeResponse, AppListResponse, AppMode } from '@dify/contracts/api/openapi/types.gen' import type { OpenApiClient } from '@/http/orpc' import type { HttpClient } from '@/http/types' import { createOpenApiClient } from '@/http/orpc' @@ -7,7 +7,7 @@ export type ListQuery = { readonly workspaceId: string readonly page?: number readonly limit?: number - readonly mode?: string + readonly mode?: AppMode | '' readonly name?: string readonly tag?: string } diff --git a/cli/src/auth/hosts.test.ts b/cli/src/auth/hosts.test.ts index e4083cf0014..112538ccbb9 100644 --- a/cli/src/auth/hosts.test.ts +++ b/cli/src/auth/hosts.test.ts @@ -1,10 +1,7 @@ import type { AccountContext } from './hosts' -import type { Key, Store } from '@/store/store' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { ENV_CONFIG_DIR } from '@/store/dir' +import { useTempConfigDir } from '@test/fixtures/config-dir' +import { MemStore } from '@test/fixtures/mem-store' +import { describe, expect, it } from 'vitest' import { AccountContextSchema, notLoggedInError, Registry, RegistrySchema } from './hosts' describe('RegistrySchema', () => { @@ -53,6 +50,20 @@ describe('RegistrySchema', () => { }) expect(ctx.external_subject?.issuer).toBe('https://issuer') }) + + it('strips a stale available_workspaces field from legacy contexts', () => { + const raw = { + account: { id: 'acct-1', email: 'bob@corp.com', name: 'Bob' }, + workspace: { id: 'ws-1', name: 'Space', role: 'owner' }, + available_workspaces: [ + { id: 'ws-1', name: 'Space', role: 'owner' }, + { id: '00000000-0000-0000-0000-000000000002', name: 'Other', role: 'normal' }, + ], + } as unknown as Record + const ctx = AccountContextSchema.parse(raw) + expect((ctx as Record).available_workspaces).toBeUndefined() + expect(ctx.workspace?.id).toBe('ws-1') + }) }) describe('notLoggedInError', () => { @@ -126,75 +137,44 @@ describe('Registry (pure)', () => { }) describe('Registry.load / Registry.save', () => { - let dir: string - let prev: string | undefined - beforeEach(async () => { - dir = await mkdtemp(join(tmpdir(), 'difyctl-reg-')) - prev = process.env[ENV_CONFIG_DIR] - process.env[ENV_CONFIG_DIR] = dir - }) - afterEach(async () => { - if (prev === undefined) - delete process.env[ENV_CONFIG_DIR] - else process.env[ENV_CONFIG_DIR] = prev - await rm(dir, { recursive: true, force: true }) - }) + useTempConfigDir('difyctl-reg-') - it('returns an empty registry when nothing saved', () => { - const reg = Registry.load() + it('returns an empty registry when nothing saved', async () => { + const reg = await Registry.load() expect(reg.current_host).toBeUndefined() expect(Object.keys(reg.hosts)).toHaveLength(0) }) - it('round-trips a populated registry', () => { + it('round-trips a populated registry', async () => { const reg = Registry.empty('keychain') reg.upsert('cloud.dify.ai', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } }) reg.setHost('cloud.dify.ai') reg.setAccount('a@x') - reg.save() - const loaded = Registry.load() + await reg.save() + const loaded = await Registry.load() expect(loaded?.current_host).toBe('cloud.dify.ai') expect(loaded?.hosts['cloud.dify.ai']?.accounts['a@x']?.account.email).toBe('a@x') }) }) -class MemStore implements Store { - readonly entries = new Map() - get(key: Key): T { return (this.entries.get(key.key) as T | undefined) ?? key.default } - set(key: Key, value: T): void { this.entries.set(key.key, value) } - unset(key: Key): void { this.entries.delete(key.key) } -} - describe('Registry.forget', () => { - let dir: string - let prev: string | undefined - beforeEach(async () => { - dir = await mkdtemp(join(tmpdir(), 'difyctl-forget-')) - prev = process.env[ENV_CONFIG_DIR] - process.env[ENV_CONFIG_DIR] = dir - }) - afterEach(async () => { - if (prev === undefined) - delete process.env[ENV_CONFIG_DIR] - else process.env[ENV_CONFIG_DIR] = prev - await rm(dir, { recursive: true, force: true }) - }) + useTempConfigDir('difyctl-forget-') - it('drops token + active context, keeps siblings, unsets pointers', () => { + it('drops token + active context, keeps siblings, unsets pointers', async () => { const store = new MemStore() const reg = Registry.empty('file') reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } }) reg.upsert('h1', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } }) reg.setHost('h1') reg.setAccount('a@x') - reg.save() - store.set({ key: 'tokens.h1.a@x', default: '' }, 'dfoa_a') + await reg.save() + await store.write('h1', 'a@x', 'dfoa_a') const active = reg.resolveActive()! - reg.forget(active, store) + await reg.forget(active, store) - expect(store.get({ key: 'tokens.h1.a@x', default: '' })).toBe('') - const after = Registry.load() + expect(await store.read('h1', 'a@x')).toBe('') + const after = await Registry.load() expect(after?.hosts.h1?.accounts['a@x']).toBeUndefined() expect(after?.hosts.h1?.accounts['b@x']).toBeDefined() expect(after?.current_host).toBeUndefined() diff --git a/cli/src/auth/hosts.ts b/cli/src/auth/hosts.ts index 34c8c4d782e..29305db951e 100644 --- a/cli/src/auth/hosts.ts +++ b/cli/src/auth/hosts.ts @@ -1,11 +1,14 @@ -import type { Store } from '@/store/store' +import type { StorageMode } from '@/store/store' +import type { TokenStore } from '@/store/token-store' import { z } from 'zod' import { BaseError } from '@/errors/base' import { ErrorCode } from '@/errors/codes' -import { getHostStore, tokenKey } from '@/store/manager' +import { getHostStore } from '@/store/manager' +import { STORAGE_MODES } from '@/store/store' -const StorageModeSchema = z.enum(['keychain', 'file']) -export type StorageMode = z.infer +const StorageModeSchema = z.enum(STORAGE_MODES) + +export type { StorageMode } from '@/store/store' export const AccountSchema = z.object({ id: z.string().optional(), @@ -30,7 +33,6 @@ export type ExternalSubject = z.infer export const AccountContextSchema = z.object({ account: AccountSchema, workspace: WorkspaceSchema.optional(), - available_workspaces: z.array(WorkspaceSchema).optional(), token_id: z.string().optional(), token_expires_at: z.string().optional(), external_subject: ExternalSubjectSchema.optional(), @@ -69,8 +71,8 @@ export class Registry { this.data = data } - static load(): Registry { - const raw = getHostStore().getTyped>() + static async load(): Promise { + const raw = await getHostStore().getTyped>() if (raw === null) return Registry.empty() return new Registry(RegistrySchema.parse(raw)) @@ -163,16 +165,16 @@ export class Registry { // Teardown for "this credential is gone": drop the token, drop the context // (unsets pointers when active), persist. Logout + self-revoke share it. - forget(active: ActiveContext, store: Store): void { + async forget(active: ActiveContext, store: TokenStore): Promise { try { - store.unset(tokenKey(active.host, active.email)) + await store.remove(active.host, active.email) } catch { /* best-effort */ } this.remove(active.host, active.email) - this.save() + await this.save() } - save(): void { - getHostStore().setTyped(RegistrySchema.parse(this.data)) + async save(): Promise { + await getHostStore().setTyped(RegistrySchema.parse(this.data)) } } diff --git a/cli/src/cache/app-info.ts b/cli/src/cache/app-info.ts index f6f360405a3..052de019fee 100644 --- a/cli/src/cache/app-info.ts +++ b/cli/src/cache/app-info.ts @@ -42,17 +42,17 @@ export type AppInfoCacheOptions = { export async function loadAppInfoCache(opts: AppInfoCacheOptions = {}): Promise { const store = opts.store ?? getCache(CACHE_APP_INFO) const ttlMs = opts.ttlMs ?? APP_INFO_TTL_MS - const state: State = { entries: readEntries(store) } + const state: State = { entries: await readEntries(store) } return { get: (host, appId) => state.entries.get(key(host, appId)), set: async (host, appId, meta) => { const record: AppMetaCacheRecord = { meta, fetchedAt: (opts.now ?? (() => new Date()))().toISOString() } state.entries.set(key(host, appId), record) - writeEntries(store, state.entries) + await writeEntries(store, state.entries) }, delete: async (host, appId) => { state.entries.delete(key(host, appId)) - writeEntries(store, state.entries) + await writeEntries(store, state.entries) }, isFresh: (record, now) => { const t = (now ?? new Date()).getTime() - new Date(record.fetchedAt).getTime() @@ -65,11 +65,11 @@ function key(host: string, appId: string): string { return `${host}::${appId}` } -function readEntries(store: Store): Map { +async function readEntries(store: Store): Promise> { const out = new Map() let raw: Record try { - raw = store.get(ENTRIES_KEY) + raw = await store.get(ENTRIES_KEY) } catch { return out @@ -111,8 +111,8 @@ function serialize(record: AppMetaCacheRecord): DiskEntry { } } -function writeEntries(store: Store, entries: Map): void { +async function writeEntries(store: Store, entries: Map): Promise { const out: Record = {} for (const [k, v] of entries) out[k] = serialize(v) - store.set(ENTRIES_KEY, out) + await store.set(ENTRIES_KEY, out) } diff --git a/cli/src/cache/nudge-store.ts b/cli/src/cache/nudge-store.ts index 61846b89dd2..3621b3b41ff 100644 --- a/cli/src/cache/nudge-store.ts +++ b/cli/src/cache/nudge-store.ts @@ -23,7 +23,7 @@ export async function loadNudgeStore(opts: NudgeStoreOptions = {}): Promise new Date()) - const memory = readWarned(store) + const memory = await readWarned(store) return { canWarn: (host, now) => { @@ -39,18 +39,18 @@ export async function loadNudgeStore(opts: NudgeStoreOptions = {}): Promise { +async function readWarned(store: Store): Promise> { const out = new Map() let raw: Record try { - raw = store.get(WARNED_KEY) + raw = await store.get(WARNED_KEY) } catch { return out @@ -63,9 +63,9 @@ function readWarned(store: Store): Map { return out } -function writeWarned(store: Store, state: Map): void { +async function writeWarned(store: Store, state: Map): Promise { const warned: Record = {} for (const [host, t] of state) warned[host] = new Date(t).toISOString() - store.set(WARNED_KEY, warned) + await store.set(WARNED_KEY, warned) } diff --git a/cli/src/commands/_shared/authed-command.ts b/cli/src/commands/_shared/authed-command.ts index 68bd2de018b..8ef0b381e9e 100644 --- a/cli/src/commands/_shared/authed-command.ts +++ b/cli/src/commands/_shared/authed-command.ts @@ -2,7 +2,7 @@ import type { ActiveContext } from '@/auth/hosts' import type { AppInfoCache } from '@/cache/app-info' import type { Command } from '@/framework/command' import type { HttpClient } from '@/http/types' -import type { Store } from '@/store/store' +import type { TokenStore } from '@/store/token-store' import type { IOStreams } from '@/sys/io/streams' import { META_PROBE_TIMEOUT_MS, MetaClient } from '@/api/meta' import { notLoggedInError, Registry } from '@/auth/hosts' @@ -11,7 +11,7 @@ import { loadNudgeStore } from '@/cache/nudge-store' import { getEnv } from '@/env/registry' import { formatErrorForCli } from '@/errors/format' import { createHttpClient } from '@/http/client' -import { getTokenStore, tokenKey } from '@/store/manager' +import { getTokenStore } from '@/store/manager' import { realStreams } from '@/sys/io/streams' import { hostWithScheme, openAPIBase } from '@/util/host' import { versionInfo } from '@/version/info' @@ -21,7 +21,7 @@ import { resolveRetryAttempts } from './global-flags.js' export type AuthedContext = { readonly reg: Registry readonly active: ActiveContext - readonly store: Store + readonly store: TokenStore readonly http: HttpClient readonly host: string readonly io: IOStreams @@ -39,13 +39,13 @@ export async function buildAuthedContext( opts: AuthedContextOptions, ): Promise { const io = realStreams(opts.format ?? '') - const reg = Registry.load() + const reg = await Registry.load() const active = reg.resolveActive() if (active === undefined) fail(cmd, opts, io) - const { store } = getTokenStore() - const bearer = store.get(tokenKey(active.host, active.email)) + const store = getTokenStore(reg.token_storage) + const bearer = await store.read(active.host, active.email) if (bearer === '') fail(cmd, opts, io) diff --git a/cli/src/commands/auth/devices/_shared/devices.test.ts b/cli/src/commands/auth/devices/_shared/devices.test.ts index 28a49010229..fb510ef1af1 100644 --- a/cli/src/commands/auth/devices/_shared/devices.test.ts +++ b/cli/src/commands/auth/devices/_shared/devices.test.ts @@ -2,43 +2,20 @@ import type { SessionListResponse, SessionRow } from '@dify/contracts/api/openap import type { DifyMock } from '@test/fixtures/dify-mock/server' import type { AccountSessionsClient } from '@/api/account-sessions' import type { ActiveContext } from '@/auth/hosts' -import type { Key, Store } from '@/store/store' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { useTempConfigDir } from '@test/fixtures/config-dir' import { startMock } from '@test/fixtures/dify-mock/server' import { testHttpClient } from '@test/fixtures/http-client' +import { MemStore } from '@test/fixtures/mem-store' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Registry } from '@/auth/hosts' -import { ENV_CONFIG_DIR } from '@/store/dir' -import { tokenKey } from '@/store/manager' import { bufferStreams } from '@/sys/io/streams' import { listAllSessions, runDevicesList, runDevicesRevoke } from './devices.js' -class MemStore implements Store { - readonly entries = new Map() - get(key: Key): T { - return (this.entries.get(key.key) as T | undefined) ?? key.default - } - - set(key: Key, value: T): void { - this.entries.set(key.key, value) - } - - unset(key: Key): void { - this.entries.delete(key.key) - } -} - function buildRegistry(host: string, email: string, tokenId: string): { reg: Registry, active: ActiveContext } { const reg = Registry.empty('file') reg.upsert(host, email, { account: { id: 'acct-1', email, name: 'Test Tester' }, workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, - available_workspaces: [ - { id: 'ws-1', name: 'Default', role: 'owner' }, - { id: 'ws-2', name: 'Other', role: 'normal' }, - ], token_id: tokenId, }) reg.setHost(host) @@ -82,29 +59,20 @@ describe('runDevicesList', () => { describe('runDevicesRevoke', () => { let mock: DifyMock - let configDir: string - let prevConfigDir: string | undefined + useTempConfigDir('difyctl-devrevoke-') beforeEach(async () => { mock = await startMock({ scenario: 'happy' }) - configDir = await mkdtemp(join(tmpdir(), 'difyctl-devrevoke-')) - prevConfigDir = process.env[ENV_CONFIG_DIR] - process.env[ENV_CONFIG_DIR] = configDir }) afterEach(async () => { - if (prevConfigDir === undefined) - delete process.env[ENV_CONFIG_DIR] - else - process.env[ENV_CONFIG_DIR] = prevConfigDir await mock.stop() - await rm(configDir, { recursive: true, force: true }) }) it('exact device_label: revokes one + leaves local creds', async () => { const io = bufferStreams() const store = new MemStore() const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1') - store.set(tokenKey(mock.url, 'tester@dify.ai'), 'dfoa_test') - reg.save() + await store.write(mock.url, 'tester@dify.ai', 'dfoa_test') + await reg.save() const http = testHttpClient(mock.url, 'dfoa_test') await runDevicesRevoke({ io, reg, active, store, http, target: 'difyctl on desktop', all: false }) @@ -168,13 +136,13 @@ describe('runDevicesRevoke', () => { const io = bufferStreams() const store = new MemStore() const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1') - store.set(tokenKey(mock.url, 'tester@dify.ai'), 'dfoa_test') - reg.save() + await store.write(mock.url, 'tester@dify.ai', 'dfoa_test') + await reg.save() const http = testHttpClient(mock.url, 'dfoa_test') await runDevicesRevoke({ io, reg, active, store, http, target: 'tok-1', all: false }) expect(store.entries.size).toBe(0) - const saved = Registry.load() + const saved = await Registry.load() expect(saved?.hosts[mock.url]).toBeUndefined() }) diff --git a/cli/src/commands/auth/devices/_shared/devices.ts b/cli/src/commands/auth/devices/_shared/devices.ts index 83d41d8fd1b..15f17e0a3e3 100644 --- a/cli/src/commands/auth/devices/_shared/devices.ts +++ b/cli/src/commands/auth/devices/_shared/devices.ts @@ -1,7 +1,7 @@ import type { SessionRow } from '@dify/contracts/api/openapi/types.gen' import type { ActiveContext, Registry } from '@/auth/hosts' import type { HttpClient } from '@/http/types' -import type { Store } from '@/store/store' +import type { TokenStore } from '@/store/token-store' import type { IOStreams } from '@/sys/io/streams' import { AccountSessionsClient } from '@/api/account-sessions' import { BaseError } from '@/errors/base' @@ -71,7 +71,7 @@ export type DevicesRevokeOptions = { readonly io: IOStreams readonly reg: Registry readonly active: ActiveContext - readonly store: Store + readonly store: TokenStore readonly http: HttpClient readonly target?: string readonly all: boolean @@ -100,7 +100,7 @@ export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise => { /* skip OS open */ } -class MemStore implements Store { - readonly entries = new Map() - get(key: Key): T { - return (this.entries.get(key.key) as T | undefined) ?? key.default - } - - set(key: Key, value: T): void { - this.entries.set(key.key, value) - } - - unset(key: Key): void { - this.entries.delete(key.key) - } -} - describe('runLogin', () => { let mock: DifyMock - let configDir: string - let prevConfigDir: string | undefined + const configDir = useTempConfigDir('difyctl-login-') beforeEach(async () => { mock = await startMock({ scenario: 'happy' }) - configDir = await mkdtemp(join(tmpdir(), 'difyctl-login-')) - prevConfigDir = process.env[ENV_CONFIG_DIR] - process.env[ENV_CONFIG_DIR] = configDir }) afterEach(async () => { - if (prevConfigDir === undefined) - delete process.env[ENV_CONFIG_DIR] - else - process.env[ENV_CONFIG_DIR] = prevConfigDir await mock.stop() - await rm(configDir, { recursive: true, force: true }) }) it('happy: stores bearer + writes hosts.yml + greets account user', async () => { @@ -75,10 +49,9 @@ describe('runLogin', () => { const active = reg.resolveActive() expect(active?.ctx.account.email).toBe('tester@dify.ai') expect(active?.ctx.workspace?.id).toBe('550e8400-e29b-41d4-a716-446655440000') - expect(active?.ctx.available_workspaces).toHaveLength(2) - expect(store.get(tokenKey(active!.host, 'tester@dify.ai'))).toBe('dfoa_test') + expect(await store.read(active!.host, 'tester@dify.ai')).toBe('dfoa_test') - const hostsRaw = await readFile(join(configDir, 'hosts.yml'), 'utf8') + const hostsRaw = await readFile(join(configDir(), 'hosts.yml'), 'utf8') expect(hostsRaw).toContain('current_host:') expect(hostsRaw).toContain('tester@dify.ai') expect(hostsRaw).not.toContain('dfoa_test') @@ -109,7 +82,7 @@ describe('runLogin', () => { expect(active?.ctx.external_subject?.email).toBe('sso@dify.ai') expect(active?.ctx.external_subject?.issuer).toBe('https://issuer.example') expect(active?.ctx.account.email).toBe('') - expect(store.get(tokenKey(active!.host, 'sso@dify.ai'))).toBe('dfoe_test') + expect(await store.read(active!.host, 'sso@dify.ai')).toBe('dfoe_test') expect(io.outBuf()).toContain('external SSO') expect(io.outBuf()).toContain('sso@dify.ai') }) @@ -130,7 +103,7 @@ describe('runLogin', () => { browserOpener: noopBrowser, })).rejects.toThrow(/denied/) expect(store.entries.size).toBe(0) - await expect(readFile(join(configDir, 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/) + await expect(readFile(join(configDir(), 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/) }) it('expired: throws DeviceFlowError', async () => { diff --git a/cli/src/commands/auth/login/login.ts b/cli/src/commands/auth/login/login.ts index fc9562e065a..2c1ba5b95a9 100644 --- a/cli/src/commands/auth/login/login.ts +++ b/cli/src/commands/auth/login/login.ts @@ -1,7 +1,8 @@ import type { Clock } from './device-flow.js' import type { CodeResponse, PollSuccess } from '@/api/oauth-device' -import type { AccountContext, Workspace } from '@/auth/hosts' -import type { StorageMode, Store } from '@/store/store' +import type { AccountContext } from '@/auth/hosts' +import type { StorageMode } from '@/store/store' +import type { TokenStore } from '@/store/token-store' import type { ParseResult } from '@/sys/io/prompt' import type { IOStreams } from '@/sys/io/streams' import type { BrowserEnv, BrowserOpener } from '@/util/browser' @@ -11,7 +12,7 @@ import { Registry } from '@/auth/hosts' import { BaseError, isBaseError } from '@/errors/base' import { ErrorCode } from '@/errors/codes' import { createHttpClient } from '@/http/client' -import { getTokenStore, tokenKey } from '@/store/manager' +import { detectTokenStore } from '@/store/manager' import { colorEnabled, colorScheme } from '@/sys/io/color' import { promptText } from '@/sys/io/prompt' import { startSpinner } from '@/sys/io/spinner' @@ -25,7 +26,7 @@ export type LoginOptions = { readonly noBrowser?: boolean readonly insecure?: boolean readonly deviceLabel?: string - readonly store?: { readonly store: Store, readonly mode: StorageMode } + readonly store?: { readonly store: TokenStore, readonly mode: StorageMode } readonly api?: DeviceFlowApi readonly browserEnv?: BrowserEnv readonly browserOpener?: BrowserOpener @@ -69,18 +70,18 @@ export async function runLogin(opts: LoginOptions): Promise { spinner.stop() } - const storeBundle = opts.store ?? getTokenStore() + const storeBundle = opts.store ?? await detectTokenStore() const display = bareHost(host) const email = accountEmail(success) const ctx = contextFromSuccess(success) - storeBundle.store.set(tokenKey(display, email), success.token) + await storeBundle.store.write(display, email, success.token) - const reg = Registry.load() + const reg = await Registry.load() reg.token_storage = storeBundle.mode reg.activate(display, email, ctx) applyScheme(reg, display, host) - reg.save() + await reg.save() renderLoggedIn(opts.io.out, cs, host, success) return reg @@ -187,9 +188,6 @@ function contextFromSuccess(s: PollSuccess): AccountContext { const def = findDefaultWorkspace(s) if (def !== undefined) ctx.workspace = def - if (s.workspaces !== undefined && s.workspaces.length > 0) { - ctx.available_workspaces = s.workspaces.map(w => ({ id: w.id, name: w.name, role: w.role })) - } return ctx } diff --git a/cli/src/commands/auth/logout/index.ts b/cli/src/commands/auth/logout/index.ts index 5da93932f24..6476b1726e8 100644 --- a/cli/src/commands/auth/logout/index.ts +++ b/cli/src/commands/auth/logout/index.ts @@ -3,7 +3,7 @@ import type { HttpClient } from '@/http/types' import { Registry } from '@/auth/hosts' import { DifyCommand } from '@/commands/_shared/dify-command' import { createHttpClient } from '@/http/client' -import { getTokenStore, tokenKey } from '@/store/manager' +import { getTokenStore } from '@/store/manager' import { runWithSpinner } from '@/sys/io/spinner' import { realStreams } from '@/sys/io/streams' import { hostWithScheme, openAPIBase } from '@/util/host' @@ -21,12 +21,16 @@ export default class Logout extends DifyCommand { async run(argv: string[]): Promise { this.parse(Logout, argv) const io = realStreams() - const reg = Registry.load() + const reg = await Registry.load() const active = reg.resolveActive() let http: HttpClient | undefined if (active !== undefined) { - const bearer = getTokenStore().store.get(tokenKey(active.host, active.email)) + let bearer = '' + try { + bearer = await getTokenStore(reg.token_storage).read(active.host, active.email) + } + catch { /* keyring locked — skip remote revocation, local cleanup still runs */ } if (bearer !== '') { http = createHttpClient({ baseURL: openAPIBase(hostWithScheme(active.host, active.scheme)), bearer, retryAttempts: 0 }) } diff --git a/cli/src/commands/auth/logout/logout.test.ts b/cli/src/commands/auth/logout/logout.test.ts index 9144c2ef6cf..2d1ea109e5c 100644 --- a/cli/src/commands/auth/logout/logout.test.ts +++ b/cli/src/commands/auth/logout/logout.test.ts @@ -1,63 +1,54 @@ -import type { Key, Store } from '@/store/store' -import { mkdtemp, readFile, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' +import { readFile } from 'node:fs/promises' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { useTempConfigDir } from '@test/fixtures/config-dir' +import { MemStore } from '@test/fixtures/mem-store' +import { describe, expect, it } from 'vitest' import { Registry } from '@/auth/hosts' -import { ENV_CONFIG_DIR } from '@/store/dir' import { bufferStreams } from '@/sys/io/streams' import { runLogout } from './logout.js' -class MemStore implements Store { - readonly entries = new Map() - get(key: Key): T { return (this.entries.get(key.key) as T | undefined) ?? key.default } - set(key: Key, value: T): void { this.entries.set(key.key, value) } - unset(key: Key): void { this.entries.delete(key.key) } -} - describe('runLogout', () => { - let dir: string - let prev: string | undefined - beforeEach(async () => { - dir = await mkdtemp(join(tmpdir(), 'difyctl-logout-')) - prev = process.env[ENV_CONFIG_DIR] - process.env[ENV_CONFIG_DIR] = dir - }) - afterEach(async () => { - if (prev === undefined) - delete process.env[ENV_CONFIG_DIR] - else process.env[ENV_CONFIG_DIR] = prev - await rm(dir, { recursive: true, force: true }) - }) + const dir = useTempConfigDir('difyctl-logout-') - function seed(store: MemStore) { + async function seed(store: MemStore) { const reg = Registry.empty('file') reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } }) reg.upsert('h1', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } }) reg.setHost('h1') reg.setAccount('a@x') - reg.save() - store.set({ key: 'tokens.h1.a@x', default: '' }, 'dfoa_a') - store.set({ key: 'tokens.h1.b@x', default: '' }, 'dfoa_b') + await reg.save() + await store.write('h1', 'a@x', 'dfoa_a') + await store.write('h1', 'b@x', 'dfoa_b') } it('removes only the active context, keeps others, unsets pointers, file survives', async () => { const store = new MemStore() - seed(store) - await runLogout({ io: bufferStreams(), reg: Registry.load(), store }) - const after = Registry.load() + await seed(store) + await runLogout({ io: bufferStreams(), reg: await Registry.load(), store }) + const after = await Registry.load() expect(after?.hosts.h1?.accounts['a@x']).toBeUndefined() expect(after?.hosts.h1?.accounts['b@x']).toBeDefined() expect(after?.current_host).toBeUndefined() - expect(store.get({ key: 'tokens.h1.a@x', default: '' })).toBe('') - expect(store.get({ key: 'tokens.h1.b@x', default: '' })).toBe('dfoa_b') - const raw = await readFile(join(dir, 'hosts.yml'), 'utf8') + expect(await store.read('h1', 'a@x')).toBe('') + expect(await store.read('h1', 'b@x')).toBe('dfoa_b') + const raw = await readFile(join(dir(), 'hosts.yml'), 'utf8') expect(raw).toContain('b@x') }) + it('clears local credentials even when the store.read throws (e.g. keyring locked)', async () => { + const store = new MemStore() + await seed(store) + store.read = () => { + throw new Error('keyring locked') + } + await runLogout({ io: bufferStreams(), reg: await Registry.load(), store }) + const after = await Registry.load() + expect(after?.hosts.h1?.accounts['a@x']).toBeUndefined() + }) + it('throws NotLoggedIn when no active context', async () => { - Registry.empty('file').save() - await expect(runLogout({ io: bufferStreams(), reg: Registry.load(), store: new MemStore() })) + await Registry.empty('file').save() + await expect(runLogout({ io: bufferStreams(), reg: await Registry.load(), store: new MemStore() })) .rejects .toThrow(/not logged in/i) }) diff --git a/cli/src/commands/auth/logout/logout.ts b/cli/src/commands/auth/logout/logout.ts index 7ca414f786a..6724a5feef9 100644 --- a/cli/src/commands/auth/logout/logout.ts +++ b/cli/src/commands/auth/logout/logout.ts @@ -1,9 +1,9 @@ import type { Registry } from '@/auth/hosts' import type { HttpClient } from '@/http/types' -import type { Store } from '@/store/store' +import type { TokenStore } from '@/store/token-store' import type { IOStreams } from '@/sys/io/streams' import { AccountSessionsClient } from '@/api/account-sessions' -import { getTokenStore, tokenKey } from '@/store/manager' +import { getTokenStore } from '@/store/manager' import { colorEnabled, colorScheme } from '@/sys/io/color' export type LogoutOptions = { @@ -11,7 +11,7 @@ export type LogoutOptions = { readonly reg: Registry readonly http?: HttpClient /** Optional override for tests; production resolves via `getTokenStore`. */ - readonly store?: Store + readonly store?: TokenStore } const REVOCABLE_PREFIXES = ['dfoa_', 'dfoe_'] as const @@ -21,8 +21,12 @@ export async function runLogout(opts: LogoutOptions): Promise { const reg = opts.reg const active = reg.requireActive() - const store = opts.store ?? getTokenStore().store - const bearer = store.get(tokenKey(active.host, active.email)) + const store = opts.store ?? getTokenStore(reg.token_storage) + let bearer = '' + try { + bearer = await store.read(active.host, active.email) + } + catch { /* keyring locked — skip remote revocation, local cleanup still runs */ } let revokeWarning = '' if (bearer !== '' && revokeAllowed(bearer) && opts.http !== undefined) { @@ -34,7 +38,7 @@ export async function runLogout(opts: LogoutOptions): Promise { } } - reg.forget(active, store) + await reg.forget(active, store) if (revokeWarning !== '') opts.io.err.write(revokeWarning) diff --git a/cli/src/commands/auth/whoami/index.ts b/cli/src/commands/auth/whoami/index.ts index 5f3ce89e1e3..26b0065047b 100644 --- a/cli/src/commands/auth/whoami/index.ts +++ b/cli/src/commands/auth/whoami/index.ts @@ -18,7 +18,7 @@ export default class Whoami extends DifyCommand { async run(argv: string[]): Promise { const { flags } = this.parse(Whoami, argv) - const reg = Registry.load() + const reg = await Registry.load() await runWhoami({ io: realStreams(), reg, json: flags.json }) } } diff --git a/cli/src/commands/config/get/index.ts b/cli/src/commands/config/get/index.ts index 907672c577a..65506d4defa 100644 --- a/cli/src/commands/config/get/index.ts +++ b/cli/src/commands/config/get/index.ts @@ -17,6 +17,6 @@ export default class ConfigGet extends DifyCommand { async run(argv: string[]) { const { args } = this.parse(ConfigGet, argv) - return raw(runConfigGet({ store: getConfigurationStore(), key: args.key })) + return raw(await runConfigGet({ store: getConfigurationStore(), key: args.key })) } } diff --git a/cli/src/commands/config/get/run.test.ts b/cli/src/commands/config/get/run.test.ts index 2246b111afe..482edc78af9 100644 --- a/cli/src/commands/config/get/run.test.ts +++ b/cli/src/commands/config/get/run.test.ts @@ -1,49 +1,31 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { useTempConfigDir } from '@test/fixtures/config-dir' +import { describe, expect, it } from 'vitest' import { isBaseError } from '@/errors/base' import { ErrorCode } from '@/errors/codes' -import { ENV_CONFIG_DIR } from '@/store/dir' import { getConfigurationStore } from '@/store/manager' import { runConfigGet } from './run' describe('runConfigGet', () => { - let dir: string - let prevConfigDir: string | undefined + useTempConfigDir('difyctl-get-') - beforeEach(async () => { - dir = await mkdtemp(join(tmpdir(), 'difyctl-get-')) - prevConfigDir = process.env[ENV_CONFIG_DIR] - process.env[ENV_CONFIG_DIR] = dir - }) - - afterEach(async () => { - if (prevConfigDir === undefined) - delete process.env[ENV_CONFIG_DIR] - else - process.env[ENV_CONFIG_DIR] = prevConfigDir - await rm(dir, { recursive: true, force: true }) - }) - - it('returns set value with trailing newline', () => { - getConfigurationStore().setTyped({ + it('returns set value with trailing newline', async () => { + await getConfigurationStore().setTyped({ schema_version: 1, defaults: { format: 'yaml' }, }) - const out = runConfigGet({ store: getConfigurationStore(), key: 'defaults.format' }) + const out = await runConfigGet({ store: getConfigurationStore(), key: 'defaults.format' }) expect(out).toBe('yaml\n') }) - it('returns empty line when key is unset (matches Go fmt.Fprintln)', () => { - const out = runConfigGet({ store: getConfigurationStore(), key: 'defaults.format' }) + it('returns empty line when key is unset (matches Go fmt.Fprintln)', async () => { + const out = await runConfigGet({ store: getConfigurationStore(), key: 'defaults.format' }) expect(out).toBe('\n') }) - it('throws BaseError(config_invalid_key) on unknown key', () => { + it('throws BaseError(config_invalid_key) on unknown key', async () => { let caught: unknown try { - runConfigGet({ store: getConfigurationStore(), key: 'bogus.key' }) + await runConfigGet({ store: getConfigurationStore(), key: 'bogus.key' }) } catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) @@ -51,12 +33,12 @@ describe('runConfigGet', () => { expect(caught.code).toBe(ErrorCode.ConfigInvalidKey) }) - it('returns numeric limit as string', () => { - getConfigurationStore().setTyped({ + it('returns numeric limit as string', async () => { + await getConfigurationStore().setTyped({ schema_version: 1, defaults: { limit: 75 }, }) - const out = runConfigGet({ store: getConfigurationStore(), key: 'defaults.limit' }) + const out = await runConfigGet({ store: getConfigurationStore(), key: 'defaults.limit' }) expect(out).toBe('75\n') }) }) diff --git a/cli/src/commands/config/get/run.ts b/cli/src/commands/config/get/run.ts index b4713fa84df..b0e7e93b9f5 100644 --- a/cli/src/commands/config/get/run.ts +++ b/cli/src/commands/config/get/run.ts @@ -9,8 +9,8 @@ export type RunConfigGetOptions = { readonly store: YamlStore } -export function runConfigGet(opts: RunConfigGetOptions): string { - const loaded = loadConfig(opts.store) +export async function runConfigGet(opts: RunConfigGetOptions): Promise { + const loaded = await loadConfig(opts.store) const config: ConfigFile = loaded.found ? loaded.config : emptyConfig() return `${getKey(config, opts.key)}\n` } diff --git a/cli/src/commands/config/set/index.ts b/cli/src/commands/config/set/index.ts index 099ab559ca8..805a02e888a 100644 --- a/cli/src/commands/config/set/index.ts +++ b/cli/src/commands/config/set/index.ts @@ -22,6 +22,6 @@ export default class ConfigSet extends DifyCommand { async run(argv: string[]) { const { args } = this.parse(ConfigSet, argv) - return raw(runConfigSet({ store: getConfigurationStore(), key: args.key, value: args.value })) + return raw(await runConfigSet({ store: getConfigurationStore(), key: args.key, value: args.value })) } } diff --git a/cli/src/commands/config/set/run.test.ts b/cli/src/commands/config/set/run.test.ts index 6185dd87801..a0c11bb4f1b 100644 --- a/cli/src/commands/config/set/run.test.ts +++ b/cli/src/commands/config/set/run.test.ts @@ -1,46 +1,28 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { useTempConfigDir } from '@test/fixtures/config-dir' +import { describe, expect, it } from 'vitest' import { loadConfig } from '@/config/config-loader' import { isBaseError } from '@/errors/base' import { ErrorCode, ExitCode } from '@/errors/codes' -import { ENV_CONFIG_DIR } from '@/store/dir' import { getConfigurationStore } from '@/store/manager' import { runConfigSet } from './run' describe('runConfigSet', () => { - let dir: string - let prevConfigDir: string | undefined + useTempConfigDir('difyctl-set-') - beforeEach(async () => { - dir = await mkdtemp(join(tmpdir(), 'difyctl-set-')) - prevConfigDir = process.env[ENV_CONFIG_DIR] - process.env[ENV_CONFIG_DIR] = dir - }) - - afterEach(async () => { - if (prevConfigDir === undefined) - delete process.env[ENV_CONFIG_DIR] - else - process.env[ENV_CONFIG_DIR] = prevConfigDir - await rm(dir, { recursive: true, force: true }) - }) - - it('persists the value and returns "set k = v\\n"', () => { - const out = runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'json' }) + it('persists the value and returns "set k = v\\n"', async () => { + const out = await runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'json' }) expect(out).toBe('set defaults.format = json\n') - const r = loadConfig(getConfigurationStore()) + const r = await loadConfig(getConfigurationStore()) expect(r.found).toBe(true) if (r.found) expect(r.config.defaults.format).toBe('json') }) - it('rejects invalid format value with config_invalid_value', () => { + it('rejects invalid format value with config_invalid_value', async () => { let caught: unknown try { - runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'csv' }) + await runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'csv' }) } catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) @@ -48,10 +30,10 @@ describe('runConfigSet', () => { expect(caught.code).toBe(ErrorCode.ConfigInvalidValue) }) - it('rejects unknown key with config_invalid_key', () => { + it('rejects unknown key with config_invalid_key', async () => { let caught: unknown try { - runConfigSet({ store: getConfigurationStore(), key: 'bogus', value: 'x' }) + await runConfigSet({ store: getConfigurationStore(), key: 'bogus', value: 'x' }) } catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) @@ -59,11 +41,11 @@ describe('runConfigSet', () => { expect(caught.code).toBe(ErrorCode.ConfigInvalidKey) }) - it('preserves prior keys when setting a new one', () => { - runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'yaml' }) - runConfigSet({ store: getConfigurationStore(), key: 'defaults.limit', value: '40' }) + it('preserves prior keys when setting a new one', async () => { + await runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'yaml' }) + await runConfigSet({ store: getConfigurationStore(), key: 'defaults.limit', value: '40' }) - const r = loadConfig(getConfigurationStore()) + const r = await loadConfig(getConfigurationStore()) expect(r.found).toBe(true) if (r.found) { expect(r.config.defaults.format).toBe('yaml') @@ -71,10 +53,10 @@ describe('runConfigSet', () => { } }) - it('exit code for invalid value is Usage (2)', () => { + it('exit code for invalid value is Usage (2)', async () => { let caught: unknown try { - runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'csv' }) + await runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'csv' }) } catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) @@ -82,10 +64,10 @@ describe('runConfigSet', () => { expect(caught.exit()).toBe(ExitCode.Usage) }) - it('exit code for unknown key is Usage (2)', () => { + it('exit code for unknown key is Usage (2)', async () => { let caught: unknown try { - runConfigSet({ store: getConfigurationStore(), key: 'bogus', value: 'x' }) + await runConfigSet({ store: getConfigurationStore(), key: 'bogus', value: 'x' }) } catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) @@ -93,10 +75,10 @@ describe('runConfigSet', () => { expect(caught.exit()).toBe(ExitCode.Usage) }) - it('typed wrap chain: invalid defaults.limit surfaces ConfigInvalidValue (not UsageInvalidFlag)', () => { + it('typed wrap chain: invalid defaults.limit surfaces ConfigInvalidValue (not UsageInvalidFlag)', async () => { let caught: unknown try { - runConfigSet({ store: getConfigurationStore(), key: 'defaults.limit', value: 'abc' }) + await runConfigSet({ store: getConfigurationStore(), key: 'defaults.limit', value: 'abc' }) } catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) diff --git a/cli/src/commands/config/set/run.ts b/cli/src/commands/config/set/run.ts index 2794427e1a8..47789ae9818 100644 --- a/cli/src/commands/config/set/run.ts +++ b/cli/src/commands/config/set/run.ts @@ -11,10 +11,10 @@ export type RunConfigSetOptions = { readonly store: YamlStore } -export function runConfigSet(opts: RunConfigSetOptions): string { - const loaded = loadConfig(opts.store) +export async function runConfigSet(opts: RunConfigSetOptions): Promise { + const loaded = await loadConfig(opts.store) const config: ConfigFile = loaded.found ? loaded.config : emptyConfig() const next = setKey(config, opts.key, opts.value) - saveConfig(opts.store, next) + await saveConfig(opts.store, next) return `set ${opts.key} = ${opts.value}\n` } diff --git a/cli/src/commands/config/unset/index.ts b/cli/src/commands/config/unset/index.ts index 3888aef5000..ec9e0d0758e 100644 --- a/cli/src/commands/config/unset/index.ts +++ b/cli/src/commands/config/unset/index.ts @@ -20,6 +20,6 @@ export default class ConfigUnset extends DifyCommand { async run(argv: string[]) { const { args } = this.parse(ConfigUnset, argv) - return raw(runConfigUnset({ store: getConfigurationStore(), key: args.key })) + return raw(await runConfigUnset({ store: getConfigurationStore(), key: args.key })) } } diff --git a/cli/src/commands/config/unset/run.test.ts b/cli/src/commands/config/unset/run.test.ts index 60444578413..d757b7df213 100644 --- a/cli/src/commands/config/unset/run.test.ts +++ b/cli/src/commands/config/unset/run.test.ts @@ -1,41 +1,23 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { useTempConfigDir } from '@test/fixtures/config-dir' +import { describe, expect, it } from 'vitest' import { loadConfig } from '@/config/config-loader' import { isBaseError } from '@/errors/base' import { ErrorCode } from '@/errors/codes' -import { ENV_CONFIG_DIR } from '@/store/dir' import { getConfigurationStore } from '@/store/manager' import { runConfigUnset } from './run' describe('runConfigUnset', () => { - let dir: string - let prevConfigDir: string | undefined + useTempConfigDir('difyctl-unset-') - beforeEach(async () => { - dir = await mkdtemp(join(tmpdir(), 'difyctl-unset-')) - prevConfigDir = process.env[ENV_CONFIG_DIR] - process.env[ENV_CONFIG_DIR] = dir - }) - - afterEach(async () => { - if (prevConfigDir === undefined) - delete process.env[ENV_CONFIG_DIR] - else - process.env[ENV_CONFIG_DIR] = prevConfigDir - await rm(dir, { recursive: true, force: true }) - }) - - it('clears the requested key, leaves others intact', () => { - getConfigurationStore().setTyped({ + it('clears the requested key, leaves others intact', async () => { + await getConfigurationStore().setTyped({ schema_version: 1, defaults: { format: 'json', limit: 25 }, }) - const out = runConfigUnset({ store: getConfigurationStore(), key: 'defaults.format' }) + const out = await runConfigUnset({ store: getConfigurationStore(), key: 'defaults.format' }) expect(out).toBe('unset defaults.format\n') - const r = loadConfig(getConfigurationStore()) + const r = await loadConfig(getConfigurationStore()) expect(r.found).toBe(true) if (r.found) { expect(r.config.defaults.format).not.toBe('json') @@ -43,19 +25,19 @@ describe('runConfigUnset', () => { } }) - it('is a no-op (writes empty config) when key was already unset', () => { - const out = runConfigUnset({ store: getConfigurationStore(), key: 'defaults.format' }) + it('is a no-op (writes empty config) when key was already unset', async () => { + const out = await runConfigUnset({ store: getConfigurationStore(), key: 'defaults.format' }) expect(out).toBe('unset defaults.format\n') - const r = loadConfig(getConfigurationStore()) + const r = await loadConfig(getConfigurationStore()) expect(r.found).toBe(true) if (r.found) expect(r.config.schema_version).toBe(1) }) - it('rejects unknown key', () => { + it('rejects unknown key', async () => { let caught: unknown try { - runConfigUnset({ store: getConfigurationStore(), key: 'bogus' }) + await runConfigUnset({ store: getConfigurationStore(), key: 'bogus' }) } catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) diff --git a/cli/src/commands/config/unset/run.ts b/cli/src/commands/config/unset/run.ts index 4b34e347880..acb9a473bfc 100644 --- a/cli/src/commands/config/unset/run.ts +++ b/cli/src/commands/config/unset/run.ts @@ -10,10 +10,10 @@ export type RunConfigUnsetOptions = { readonly store: YamlStore } -export function runConfigUnset(opts: RunConfigUnsetOptions): string { - const loaded = loadConfig(opts.store) +export async function runConfigUnset(opts: RunConfigUnsetOptions): Promise { + const loaded = await loadConfig(opts.store) const config: ConfigFile = loaded.found ? loaded.config : emptyConfig() const next = unsetKey(config, opts.key) - saveConfig(opts.store, next) + await saveConfig(opts.store, next) return `unset ${opts.key}\n` } diff --git a/cli/src/commands/config/view/index.ts b/cli/src/commands/config/view/index.ts index b165fea1fcc..d6b43e8336a 100644 --- a/cli/src/commands/config/view/index.ts +++ b/cli/src/commands/config/view/index.ts @@ -18,6 +18,6 @@ export default class ConfigView extends DifyCommand { async run(argv: string[]) { const { flags } = this.parse(ConfigView, argv) - return raw(runConfigView({ store: getConfigurationStore(), json: flags.json })) + return raw(await runConfigView({ store: getConfigurationStore(), json: flags.json })) } } diff --git a/cli/src/commands/config/view/run.test.ts b/cli/src/commands/config/view/run.test.ts index 45e3aff8ca3..22d16975ec0 100644 --- a/cli/src/commands/config/view/run.test.ts +++ b/cli/src/commands/config/view/run.test.ts @@ -1,77 +1,59 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { ENV_CONFIG_DIR } from '@/store/dir' +import { useTempConfigDir } from '@test/fixtures/config-dir' +import { describe, expect, it } from 'vitest' import { getConfigurationStore } from '@/store/manager' import { runConfigView } from './run' describe('runConfigView', () => { - let dir: string - let prevConfigDir: string | undefined + useTempConfigDir('difyctl-view-') - beforeEach(async () => { - dir = await mkdtemp(join(tmpdir(), 'difyctl-view-')) - prevConfigDir = process.env[ENV_CONFIG_DIR] - process.env[ENV_CONFIG_DIR] = dir - }) - - afterEach(async () => { - if (prevConfigDir === undefined) - delete process.env[ENV_CONFIG_DIR] - else - process.env[ENV_CONFIG_DIR] = prevConfigDir - await rm(dir, { recursive: true, force: true }) - }) - - it('text format: empty config returns empty string', () => { - const out = runConfigView({ store: getConfigurationStore() }) + it('text format: empty config returns empty string', async () => { + const out = await runConfigView({ store: getConfigurationStore() }) expect(out).toBe('') }) - it('text format: emits "key = value" lines for set keys only', () => { - getConfigurationStore().setTyped({ + it('text format: emits "key = value" lines for set keys only', async () => { + await getConfigurationStore().setTyped({ schema_version: 1, defaults: { format: 'json', limit: 50 }, state: { current_app: 'app-1' }, }) - const out = runConfigView({ store: getConfigurationStore() }) + const out = await runConfigView({ store: getConfigurationStore() }) expect(out).toBe( 'defaults.format = json\ndefaults.limit = 50\nstate.current_app = app-1\n', ) }) - it('text format: skips unset keys', () => { - getConfigurationStore().setTyped({ + it('text format: skips unset keys', async () => { + await getConfigurationStore().setTyped({ schema_version: 1, defaults: { format: 'yaml' }, }) - const out = runConfigView({ store: getConfigurationStore() }) + const out = await runConfigView({ store: getConfigurationStore() }) expect(out).toBe('defaults.format = yaml\n') expect(out).not.toContain('defaults.limit') expect(out).not.toContain('state.current_app') }) - it('json format: empty config returns "{}\\n"', () => { - const out = runConfigView({ store: getConfigurationStore(), json: true }) + it('json format: empty config returns "{}\\n"', async () => { + const out = await runConfigView({ store: getConfigurationStore(), json: true }) expect(out).toBe('{}\n') }) - it('json format: defaults.limit is numeric, others are strings', () => { - getConfigurationStore().setTyped({ + it('json format: defaults.limit is numeric, others are strings', async () => { + await getConfigurationStore().setTyped({ schema_version: 1, defaults: { format: 'table', limit: 100 }, state: { current_app: 'app-x' }, }) - const out = runConfigView({ store: getConfigurationStore(), json: true }) + const out = await runConfigView({ store: getConfigurationStore(), json: true }) const parsed = JSON.parse(out) as Record expect(parsed['defaults.format']).toBe('table') expect(parsed['defaults.limit']).toBe(100) expect(parsed['state.current_app']).toBe('app-x') }) - it('json format: trailing newline matches Go encoder.Encode', () => { - const out = runConfigView({ store: getConfigurationStore(), json: true }) + it('json format: trailing newline matches Go encoder.Encode', async () => { + const out = await runConfigView({ store: getConfigurationStore(), json: true }) expect(out.endsWith('\n')).toBe(true) }) }) diff --git a/cli/src/commands/config/view/run.ts b/cli/src/commands/config/view/run.ts index 667cb903d50..50dcdcaf1b0 100644 --- a/cli/src/commands/config/view/run.ts +++ b/cli/src/commands/config/view/run.ts @@ -11,8 +11,8 @@ export type RunConfigViewOptions = { type ViewOut = Record -export function runConfigView(opts: RunConfigViewOptions): string { - const loaded = loadConfig(opts.store) +export async function runConfigView(opts: RunConfigViewOptions): Promise { + const loaded = await loadConfig(opts.store) const config: ConfigFile = loaded.found ? loaded.config : emptyConfig() const out = collect(config) if (opts.json) diff --git a/cli/src/commands/create/member/run.test.ts b/cli/src/commands/create/member/run.test.ts index f3fc75a84b5..6c363691dc6 100644 --- a/cli/src/commands/create/member/run.test.ts +++ b/cli/src/commands/create/member/run.test.ts @@ -11,7 +11,6 @@ function active(): ActiveContext { ctx: { account: { id: 'acct-1', email: 'inviter@example.com', name: 'Inviter' }, workspace: { id: '550e8400-e29b-41d4-a716-446655440000', name: 'Default', role: 'owner' }, - available_workspaces: [{ id: '550e8400-e29b-41d4-a716-446655440000', name: 'Default', role: 'owner' }], }, } } diff --git a/cli/src/commands/delete/member/run.test.ts b/cli/src/commands/delete/member/run.test.ts index 3476c2bb6c7..bbc58ff2c3f 100644 --- a/cli/src/commands/delete/member/run.test.ts +++ b/cli/src/commands/delete/member/run.test.ts @@ -11,7 +11,6 @@ function active(): ActiveContext { ctx: { account: { id: 'acct-1', email: 'me@example.com', name: 'Me' }, workspace: { id: '550e8400-e29b-41d4-a716-446655440000', name: 'Default', role: 'owner' }, - available_workspaces: [{ id: '550e8400-e29b-41d4-a716-446655440000', name: 'Default', role: 'owner' }], }, } } diff --git a/cli/src/commands/describe/app/run.test.ts b/cli/src/commands/describe/app/run.test.ts index d99519bbe34..4ed7cedc7a5 100644 --- a/cli/src/commands/describe/app/run.test.ts +++ b/cli/src/commands/describe/app/run.test.ts @@ -19,10 +19,6 @@ function active(): ActiveContext { ctx: { account: { id: 'acct-1', email: 't@d.ai', name: 'T' }, workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, - available_workspaces: [ - { id: 'ws-1', name: 'Default', role: 'owner' }, - { id: 'ws-2', name: 'Other', role: 'normal' }, - ], }, } } @@ -71,7 +67,7 @@ describe('runDescribeApp', () => { }) it('text: agent app shows Agent: true', async () => { - const out = await render({ appId: 'app-4', workspace: 'ws-2' }) + const out = await render({ appId: 'app-4', workspace: '00000000-0000-0000-0000-000000000002' }) expect(out).toContain('Agent:') expect(out).toContain('true') }) diff --git a/cli/src/commands/export/app/run.test.ts b/cli/src/commands/export/app/run.test.ts index a0c2cd80232..d3c1a9a83d9 100644 --- a/cli/src/commands/export/app/run.test.ts +++ b/cli/src/commands/export/app/run.test.ts @@ -9,13 +9,14 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { bufferStreams } from '@/sys/io/streams' import { runExportApp } from './run.js' +const WS_ID = 'aaaaaaaa-0000-0000-0000-000000000001' + const baseActive: ActiveContext = { host: '127.0.0.1', email: 'tester@dify.ai', ctx: { account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' }, - workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, - available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }], + workspace: { id: WS_ID, name: 'Default', role: 'owner' }, }, scheme: 'http', } diff --git a/cli/src/commands/get/app/index.ts b/cli/src/commands/get/app/index.ts index 9b60ab8e956..47594813704 100644 --- a/cli/src/commands/get/app/index.ts +++ b/cli/src/commands/get/app/index.ts @@ -8,6 +8,7 @@ import { runGetApp } from './run' const APP_MODE_VALUES: readonly AppMode[] = [ 'advanced-chat', + 'agent', 'agent-chat', 'channel', 'chat', @@ -56,7 +57,7 @@ export default class GetApp extends DifyCommand { allWorkspaces: flags['all-workspaces'], page: flags.page, limitRaw: flags.limit, - mode: flags.mode, + mode: flags.mode as AppMode | undefined, name: flags.name, tag: flags.tag, format, diff --git a/cli/src/commands/get/app/run.test.ts b/cli/src/commands/get/app/run.test.ts index 9f11e30c488..7c0cc76c009 100644 --- a/cli/src/commands/get/app/run.test.ts +++ b/cli/src/commands/get/app/run.test.ts @@ -13,10 +13,6 @@ const baseActive: ActiveContext = { ctx: { account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' }, workspace: { id: '550e8400-e29b-41d4-a716-446655440000', name: 'Default', role: 'owner' }, - available_workspaces: [ - { id: '550e8400-e29b-41d4-a716-446655440000', name: 'Default', role: 'owner' }, - { id: '550e8400-e29b-41d4-a716-446655440001', name: 'Other', role: 'normal' }, - ], }, scheme: 'http', } diff --git a/cli/src/commands/get/app/run.ts b/cli/src/commands/get/app/run.ts index 940f0ac44e3..102cf066499 100644 --- a/cli/src/commands/get/app/run.ts +++ b/cli/src/commands/get/app/run.ts @@ -17,7 +17,7 @@ export type GetAppOptions = { readonly allWorkspaces?: boolean readonly page?: number readonly limitRaw?: string - readonly mode?: string + readonly mode?: AppMode readonly name?: string readonly tag?: string readonly format?: string @@ -114,14 +114,7 @@ function describeToEnvelope(desc: AppDescribeResponse, wsId: string, wsName: str function workspaceNameForId(active: ActiveContext, id: string): string { if (id === '') return '' - const ctx = active.ctx - if (ctx.workspace?.id === id) - return ctx.workspace.name - for (const w of ctx.available_workspaces ?? []) { - if (w.id === id) - return w.name - } - return '' + return active.ctx.workspace?.id === id ? active.ctx.workspace.name : '' } async function runAllWorkspaces( diff --git a/cli/src/commands/get/member/run.test.ts b/cli/src/commands/get/member/run.test.ts index 1dc2da2a364..6993e6905e1 100644 --- a/cli/src/commands/get/member/run.test.ts +++ b/cli/src/commands/get/member/run.test.ts @@ -12,7 +12,6 @@ function active(): ActiveContext { ctx: { account: { id: 'acct-1', email: 'me@example.com', name: 'Me' }, workspace: { id: '550e8400-e29b-41d4-a716-446655440000', name: 'Default', role: 'owner' }, - available_workspaces: [{ id: '550e8400-e29b-41d4-a716-446655440000', name: 'Default', role: 'owner' }], }, } } diff --git a/cli/src/commands/get/workspace/handlers.test.ts b/cli/src/commands/get/workspace/handlers.test.ts index 8d963632575..bfab5321d30 100644 --- a/cli/src/commands/get/workspace/handlers.test.ts +++ b/cli/src/commands/get/workspace/handlers.test.ts @@ -6,7 +6,7 @@ function env(): WorkspaceListResponse { return { workspaces: [ { id: 'ws-1', name: 'Default', role: 'owner', status: 'normal', current: true }, - { id: 'ws-2', name: 'Other', role: 'normal', status: 'normal', current: false }, + { id: '00000000-0000-0000-0000-000000000002', name: 'Other', role: 'normal', status: 'normal', current: false }, ], } } diff --git a/cli/src/commands/get/workspace/run.test.ts b/cli/src/commands/get/workspace/run.test.ts index 7dd36e935aa..cf5238af3a4 100644 --- a/cli/src/commands/get/workspace/run.test.ts +++ b/cli/src/commands/get/workspace/run.test.ts @@ -13,10 +13,6 @@ const baseActive: ActiveContext = { ctx: { account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' }, workspace: { id: '550e8400-e29b-41d4-a716-446655440000', name: 'Default', role: 'owner' }, - available_workspaces: [ - { id: '550e8400-e29b-41d4-a716-446655440000', name: 'Default', role: 'owner' }, - { id: '550e8400-e29b-41d4-a716-446655440001', name: 'Other', role: 'normal' }, - ], }, scheme: 'http', } diff --git a/cli/src/commands/import/app/run.test.ts b/cli/src/commands/import/app/run.test.ts index 562f2a207da..c37e00c5376 100644 --- a/cli/src/commands/import/app/run.test.ts +++ b/cli/src/commands/import/app/run.test.ts @@ -13,13 +13,14 @@ import { bufferStreams } from '@/sys/io/streams' import { ZERO } from '@/util/uuid.js' import { pluginDependencyLabel, runImportApp } from './run.js' +const WS_ID = 'aaaaaaaa-0000-0000-0000-000000000001' + const baseActive: ActiveContext = { host: '127.0.0.1', email: 'tester@dify.ai', ctx: { account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' }, - workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, - available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }], + workspace: { id: WS_ID, name: 'Default', role: 'owner' }, }, scheme: 'http', } diff --git a/cli/src/commands/import/app/run.ts b/cli/src/commands/import/app/run.ts index ad5bde2420c..8a04c6d2bc5 100644 --- a/cli/src/commands/import/app/run.ts +++ b/cli/src/commands/import/app/run.ts @@ -36,6 +36,12 @@ export type ImportAppResult = { readonly leakedDependencies: readonly PluginDependency[] } +type PluginDependencyLabelInput = { + readonly current_identifier?: string | null + readonly type?: PluginDependency['type'] + readonly value?: unknown +} + export async function runImportApp(opts: ImportAppOptions, deps: ImportAppDeps): Promise { const env = deps.envLookup ?? getEnv const io = deps.io ?? nullStreams() @@ -124,7 +130,7 @@ export async function runImportApp(opts: ImportAppOptions, deps: ImportAppDeps): // `value` is a loosely-typed wire object (Github | Marketplace | Package); narrow it here to // surface a human-readable identifier without depending on which variant the server returned. -export function pluginDependencyLabel(dep: PluginDependency): string { +export function pluginDependencyLabel(dep: PluginDependencyLabelInput): string { const value = dep.value if (typeof value === 'object' && value !== null) { const fields = value as Record diff --git a/cli/src/commands/run/app/_strategies/streaming-structured.ts b/cli/src/commands/run/app/_strategies/streaming-structured.ts index 182aa89d080..c6f02292528 100644 --- a/cli/src/commands/run/app/_strategies/streaming-structured.ts +++ b/cli/src/commands/run/app/_strategies/streaming-structured.ts @@ -56,7 +56,7 @@ export class StreamingStructuredStrategy implements RunStrategy { let resp: Record try { - const events = await ctx.runClient.runStream(opts.appId, body, { signal: ctrl.signal }) + const events = await ctx.runClient.runStream(opts.appId, body, { signal: ctrl.signal, retryOnRateLimit: opts.retryOnRateLimit }) const wrappedEvents = captureTaskId(events, (id) => { taskId = id }) diff --git a/cli/src/commands/run/app/_strategies/streaming-text.ts b/cli/src/commands/run/app/_strategies/streaming-text.ts index 872acc785bc..66b2c2a88ec 100644 --- a/cli/src/commands/run/app/_strategies/streaming-text.ts +++ b/cli/src/commands/run/app/_strategies/streaming-text.ts @@ -28,7 +28,7 @@ export class StreamingTextStrategy implements RunStrategy { handle('SIGINT', cleanup) try { - const events = await ctx.runClient.runStream(opts.appId, body, { signal: ctrl.signal }) + const events = await ctx.runClient.runStream(opts.appId, body, { signal: ctrl.signal, retryOnRateLimit: opts.retryOnRateLimit }) const sp = streamPrinterFor(mode, ctx.think, deps.io.isErrTTY) const dec = new TextDecoder() for await (const ev of events) { diff --git a/cli/src/commands/run/app/index.ts b/cli/src/commands/run/app/index.ts index 16bd30c069f..44ea93c542b 100644 --- a/cli/src/commands/run/app/index.ts +++ b/cli/src/commands/run/app/index.ts @@ -35,6 +35,7 @@ export default class RunApp extends DifyCommand { 'workspace': Flags.string({ description: 'Workspace id (overrides DIFY_WORKSPACE_ID and stored default)' }), 'stream': Flags.boolean({ description: 'Print output live as tokens/events arrive (default: collect and print at end)', default: false }), 'think': Flags.boolean({ description: 'Show model thinking/reasoning when available. Strips ... blocks silently by default; with --think, thinking is printed to stderr.', default: false }), + 'retry-on-limit': Flags.boolean({ description: 'On a 429 rate limit, wait and retry this POST (bounded) instead of failing immediately. Off by default since running an app is not idempotent.', default: false }), 'http-retry': httpRetryFlag, 'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.TEXT], default: '' }), } @@ -56,6 +57,7 @@ export default class RunApp extends DifyCommand { format, stream: flags.stream, think: flags.think, + retryOnRateLimit: flags['retry-on-limit'], }, { active: ctx.active, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache }, ) diff --git a/cli/src/commands/run/app/run.test.ts b/cli/src/commands/run/app/run.test.ts index 92184590d2d..4fff2d02873 100644 --- a/cli/src/commands/run/app/run.test.ts +++ b/cli/src/commands/run/app/run.test.ts @@ -20,10 +20,6 @@ function active(): ActiveContext { ctx: { account: { id: 'acct-1', email: 't@d.ai', name: 'T' }, workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, - available_workspaces: [ - { id: 'ws-1', name: 'Default', role: 'owner' }, - { id: 'ws-2', name: 'Other', role: 'normal' }, - ], }, } } @@ -139,7 +135,7 @@ describe('runApp', () => { const io = bufferStreams() const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runApp( - { appId: 'app-4', workspace: 'ws-2', message: 'do research' }, + { appId: 'app-4', workspace: '00000000-0000-0000-0000-000000000002', message: 'do research' }, { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) expect(io.outBuf()).toContain('do research') @@ -150,7 +146,7 @@ describe('runApp', () => { const io = bufferStreams() const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runApp( - { appId: 'app-4', workspace: 'ws-2', message: 'go', stream: true }, + { appId: 'app-4', workspace: '00000000-0000-0000-0000-000000000002', message: 'go', stream: true }, { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) expect(io.outBuf()).toContain('go') diff --git a/cli/src/commands/run/app/run.ts b/cli/src/commands/run/app/run.ts index ada582b48b3..8eb767c5dbe 100644 --- a/cli/src/commands/run/app/run.ts +++ b/cli/src/commands/run/app/run.ts @@ -27,6 +27,7 @@ export type RunAppOptions = { readonly format?: string readonly stream?: boolean readonly think?: boolean + readonly retryOnRateLimit?: boolean } export type RunAppDeps = { diff --git a/cli/src/commands/set/member/run.test.ts b/cli/src/commands/set/member/run.test.ts index 1afac76c6c7..2e86a2afe09 100644 --- a/cli/src/commands/set/member/run.test.ts +++ b/cli/src/commands/set/member/run.test.ts @@ -11,7 +11,6 @@ function active(): ActiveContext { ctx: { account: { id: 'acct-1', email: 'me@example.com', name: 'Me' }, workspace: { id: '550e8400-e29b-41d4-a716-446655440000', name: 'Default', role: 'owner' }, - available_workspaces: [{ id: '550e8400-e29b-41d4-a716-446655440000', name: 'Default', role: 'owner' }], }, } } diff --git a/cli/src/commands/use/account/use-account.test.ts b/cli/src/commands/use/account/use-account.test.ts index 9fa6d506d1e..d6fa4124a40 100644 --- a/cli/src/commands/use/account/use-account.test.ts +++ b/cli/src/commands/use/account/use-account.test.ts @@ -1,56 +1,34 @@ -import type { Key, Store } from '@/store/store' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { useTempConfigDir } from '@test/fixtures/config-dir' +import { MemStore } from '@test/fixtures/mem-store' +import { beforeEach, describe, expect, it } from 'vitest' import { Registry } from '@/auth/hosts' -import { ENV_CONFIG_DIR } from '@/store/dir' import { bufferStreams } from '@/sys/io/streams' import { runUseAccount } from './use-account' -function memStore(seed: Record): Store { - const m = new Map(Object.entries(seed)) - return { - get(k: Key): T { return (m.get(k.key) as T | undefined) ?? k.default }, - set(k: Key, v: T): void { m.set(k.key, v) }, - unset(k: Key): void { m.delete(k.key) }, - } -} - describe('runUseAccount', () => { - let dir: string - let prev: string | undefined + useTempConfigDir('difyctl-useacct-') beforeEach(async () => { - dir = await mkdtemp(join(tmpdir(), 'difyctl-useacct-')) - prev = process.env[ENV_CONFIG_DIR] - process.env[ENV_CONFIG_DIR] = dir const reg = Registry.empty('file') reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } }) reg.upsert('h1', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } }) reg.setHost('h1') reg.setAccount('a@x') - reg.save() - }) - afterEach(async () => { - if (prev === undefined) - delete process.env[ENV_CONFIG_DIR] - else process.env[ENV_CONFIG_DIR] = prev - await rm(dir, { recursive: true, force: true }) + await reg.save() }) it('switches current_account when email valid + token present', async () => { - await runUseAccount({ io: bufferStreams(), email: 'b@x', store: memStore({ 'tokens.h1.b@x': 'dfoa_b' }) }) - expect(Registry.load().hosts.h1?.current_account).toBe('b@x') + await runUseAccount({ io: bufferStreams(), email: 'b@x', store: new MemStore({ 'h1 b@x': 'dfoa_b' }) }) + expect((await Registry.load()).hosts.h1?.current_account).toBe('b@x') }) it('errors when the account has no stored token', async () => { - await expect(runUseAccount({ io: bufferStreams(), email: 'b@x', store: memStore({}) })) + await expect(runUseAccount({ io: bufferStreams(), email: 'b@x', store: new MemStore({}) })) .rejects .toThrow(/log in|no credential/i) }) it('errors when the email is unknown on the current host', async () => { - await expect(runUseAccount({ io: bufferStreams(), email: 'z@x', store: memStore({ 'tokens.h1.z@x': 'x' }) })) + await expect(runUseAccount({ io: bufferStreams(), email: 'z@x', store: new MemStore({ 'h1 z@x': 'x' }) })) .rejects .toThrow(/unknown account|no account/i) }) @@ -58,6 +36,6 @@ describe('runUseAccount', () => { it('errors in non-TTY when email omitted', async () => { const io = bufferStreams() ;(io as { isErrTTY: boolean }).isErrTTY = false - await expect(runUseAccount({ io, email: undefined, store: memStore({}) })).rejects.toThrow(/--email/i) + await expect(runUseAccount({ io, email: undefined, store: new MemStore({}) })).rejects.toThrow(/--email/i) }) }) diff --git a/cli/src/commands/use/account/use-account.ts b/cli/src/commands/use/account/use-account.ts index 3fdb8d455bd..ca53333e2c1 100644 --- a/cli/src/commands/use/account/use-account.ts +++ b/cli/src/commands/use/account/use-account.ts @@ -1,10 +1,10 @@ import type { HostEntry } from '@/auth/hosts' -import type { Store } from '@/store/store' +import type { TokenStore } from '@/store/token-store' import type { IOStreams } from '@/sys/io/streams' import { notLoggedInError, Registry } from '@/auth/hosts' import { BaseError } from '@/errors/base' import { ErrorCode } from '@/errors/codes' -import { getTokenStore, tokenKey } from '@/store/manager' +import { getTokenStore } from '@/store/manager' import { colorEnabled, colorScheme } from '@/sys/io/color' import { selectFromList } from '@/sys/io/select' @@ -12,7 +12,7 @@ export type UseAccountOptions = { readonly io: IOStreams readonly email: string | undefined /** Optional override for tests; production resolves via `getTokenStore`. */ - readonly store?: Store + readonly store?: TokenStore } type AccountChoice = { email: string, name: string, sso: boolean, active: boolean } @@ -21,7 +21,7 @@ const USE_HOST_HINT = 'run \'difyctl use host\' or \'difyctl auth login\'' export async function runUseAccount(opts: UseAccountOptions): Promise { const cs = colorScheme(colorEnabled(opts.io.isErrTTY)) - const reg = Registry.load() + const reg = await Registry.load() if (reg.current_host === undefined) throw notLoggedInError(USE_HOST_HINT) const host = reg.current_host @@ -38,8 +38,8 @@ export async function runUseAccount(opts: UseAccountOptions): Promise { }) } - const store = opts.store ?? getTokenStore().store - if (store.get(tokenKey(host, target)) === '') { + const store = opts.store ?? getTokenStore(reg.token_storage) + if (await store.read(host, target) === '') { throw new BaseError({ code: ErrorCode.NotLoggedIn, message: `no credential stored for ${target} on ${host}`, @@ -48,7 +48,7 @@ export async function runUseAccount(opts: UseAccountOptions): Promise { } reg.setAccount(target) - reg.save() + await reg.save() opts.io.out.write(`${cs.successIcon()} Active account on ${host} is now ${target}\n`) } diff --git a/cli/src/commands/use/host/use-host.test.ts b/cli/src/commands/use/host/use-host.test.ts index 5796d5b4a00..259347faeb2 100644 --- a/cli/src/commands/use/host/use-host.test.ts +++ b/cli/src/commands/use/host/use-host.test.ts @@ -1,36 +1,23 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { useTempConfigDir } from '@test/fixtures/config-dir' +import { beforeEach, describe, expect, it } from 'vitest' import { Registry } from '@/auth/hosts' -import { ENV_CONFIG_DIR } from '@/store/dir' import { bufferStreams } from '@/sys/io/streams' import { runUseHost } from './use-host' describe('runUseHost', () => { - let dir: string - let prev: string | undefined + useTempConfigDir('difyctl-usehost-') beforeEach(async () => { - dir = await mkdtemp(join(tmpdir(), 'difyctl-usehost-')) - prev = process.env[ENV_CONFIG_DIR] - process.env[ENV_CONFIG_DIR] = dir const reg = Registry.empty('file') reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } }) reg.upsert('h2', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } }) reg.setHost('h1') reg.setAccount('a@x') - reg.save() - }) - afterEach(async () => { - if (prev === undefined) - delete process.env[ENV_CONFIG_DIR] - else process.env[ENV_CONFIG_DIR] = prev - await rm(dir, { recursive: true, force: true }) + await reg.save() }) it('switches current_host when host is valid', async () => { await runUseHost({ io: bufferStreams(), host: 'h2' }) - expect(Registry.load().current_host).toBe('h2') + expect((await Registry.load()).current_host).toBe('h2') }) it('errors when host is unknown, listing valid hosts', async () => { @@ -44,7 +31,7 @@ describe('runUseHost', () => { }) it('errors when no hosts exist', async () => { - Registry.empty('file').save() + await Registry.empty('file').save() await expect(runUseHost({ io: bufferStreams(), host: 'h1' })).rejects.toThrow(/no hosts|not logged in/i) }) }) diff --git a/cli/src/commands/use/host/use-host.ts b/cli/src/commands/use/host/use-host.ts index 21a2c85d72c..fb442001de5 100644 --- a/cli/src/commands/use/host/use-host.ts +++ b/cli/src/commands/use/host/use-host.ts @@ -14,7 +14,7 @@ type HostChoice = { host: string, accounts: number, active: boolean } export async function runUseHost(opts: UseHostOptions): Promise { const cs = colorScheme(colorEnabled(opts.io.isErrTTY)) - const reg = Registry.load() + const reg = await Registry.load() const hosts = Object.keys(reg.hosts) if (hosts.length === 0) throw notLoggedInError() @@ -28,7 +28,7 @@ export async function runUseHost(opts: UseHostOptions): Promise { } reg.setHost(target) - reg.save() + await reg.save() opts.io.out.write(`${cs.successIcon()} Active host is now ${target}\n`) } diff --git a/cli/src/commands/use/workspace/index.ts b/cli/src/commands/use/workspace/index.ts index 805d7654235..9df7c5ffd76 100644 --- a/cli/src/commands/use/workspace/index.ts +++ b/cli/src/commands/use/workspace/index.ts @@ -5,16 +5,17 @@ import { Args } from '@/framework/flags' import { runUseWorkspace } from './use' export default class UseWorkspace extends DifyCommand { - static override description = 'Switch the active workspace on the server and refresh hosts.yml' + static override description = 'Switch the active workspace on the server (omit the id to pick interactively)' static override effect: CommandEffect = 'write' static override examples = [ '<%= config.bin %> use workspace ws-abc123', + '<%= config.bin %> use workspace', ] static override args = { - workspaceId: Args.string({ description: 'workspace id to switch to', required: true }), + workspaceId: Args.string({ description: 'workspace id to switch to (omit to pick interactively)', required: false }), } static override flags = { diff --git a/cli/src/commands/use/workspace/use.test.ts b/cli/src/commands/use/workspace/use.test.ts index 33c629d83d1..7feeb4e0fed 100644 --- a/cli/src/commands/use/workspace/use.test.ts +++ b/cli/src/commands/use/workspace/use.test.ts @@ -4,24 +4,24 @@ import type { } from '@dify/contracts/api/openapi/types.gen' import type { ActiveContext } from '@/auth/hosts' import type { HttpClient } from '@/http/types' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useTempConfigDir } from '@test/fixtures/config-dir' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { Registry } from '@/auth/hosts' -import { ENV_CONFIG_DIR } from '@/store/dir' +import { selectFromList } from '@/sys/io/select' import { bufferStreams } from '@/sys/io/streams' import { runUseWorkspace } from './use.js' +vi.mock('@/sys/io/select', () => ({ + selectFromList: vi.fn(), +})) + +const selectFromListMock = vi.mocked(selectFromList) + function makeRegistry(): Registry { const reg = Registry.empty('file') reg.upsert('cloud.dify.ai', 'tester@dify.ai', { account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Tester' }, workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, - available_workspaces: [ - { id: 'ws-1', name: 'Default', role: 'owner' }, - { id: 'ws-2', name: 'Stale Name', role: 'normal' }, - ], }) reg.setHost('cloud.dify.ai') reg.setAccount('tester@dify.ai') @@ -35,54 +35,49 @@ function makeActive(reg: Registry): ActiveContext { return active } +function makeDetail(over: Partial = {}): WorkspaceDetailResponse { + return { + id: '00000000-0000-0000-0000-000000000002', + name: 'Two', + role: 'owner', + status: 'normal', + current: true, + created_at: '2026-05-18T00:00:00Z', + ...over, + } +} + function fakeClient(opts: { switch?: () => Promise list?: () => Promise }) { return { - switch: vi.fn(opts.switch ?? (() => Promise.resolve({ - id: 'ws-2', - name: 'Switched', - role: 'normal', - status: 'normal', - current: true, - created_at: '2026-05-18T00:00:00Z', - }))), + switch: vi.fn(opts.switch ?? (() => Promise.resolve(makeDetail()))), list: vi.fn(opts.list ?? (() => Promise.resolve({ workspaces: [ - { id: 'ws-1', name: 'Default', role: 'owner', status: 'normal', current: false }, - { id: 'ws-2', name: 'Switched', role: 'normal', status: 'normal', current: true }, + { id: 'ws-1', name: 'Default', role: 'owner', status: 'normal', current: true }, + { id: '00000000-0000-0000-0000-000000000002', name: 'Two', role: 'owner', status: 'normal', current: false }, ], }))), } } describe('runUseWorkspace', () => { - let configDir: string + useTempConfigDir('difyctl-use-workspace-') - let prevConfigDir: string | undefined - beforeEach(async () => { - configDir = await mkdtemp(join(tmpdir(), 'difyctl-use-workspace-')) - prevConfigDir = process.env[ENV_CONFIG_DIR] - process.env[ENV_CONFIG_DIR] = configDir - }) - afterEach(async () => { - if (prevConfigDir === undefined) - delete process.env[ENV_CONFIG_DIR] - else - process.env[ENV_CONFIG_DIR] = prevConfigDir - await rm(configDir, { recursive: true, force: true }) + beforeEach(() => { + selectFromListMock.mockReset() }) - it('happy path: POST /switch → GET /workspaces → write hosts.yml', async () => { + it('arg path: switches directly without listing and persists only the active workspace', async () => { const io = bufferStreams() const reg = makeRegistry() - reg.save() + await reg.save() const active = makeActive(reg) const client = fakeClient({}) const next = await runUseWorkspace( - { workspaceId: 'ws-2' }, + { workspaceId: '00000000-0000-0000-0000-000000000002' }, { reg, active, @@ -92,67 +87,47 @@ describe('runUseWorkspace', () => { }, ) - expect(client.switch).toHaveBeenCalledExactlyOnceWith('ws-2') - expect(client.list).toHaveBeenCalledOnce() + expect(client.switch).toHaveBeenCalledExactlyOnceWith('00000000-0000-0000-0000-000000000002') + expect(client.list).not.toHaveBeenCalled() const activeCtx = next.resolveActive() - expect(activeCtx?.ctx.workspace).toEqual({ id: 'ws-2', name: 'Switched', role: 'normal' }) - expect(activeCtx?.ctx.available_workspaces).toEqual([ - { id: 'ws-1', name: 'Default', role: 'owner' }, - { id: 'ws-2', name: 'Switched', role: 'normal' }, - ]) + expect(activeCtx?.ctx.workspace).toEqual({ id: '00000000-0000-0000-0000-000000000002', name: 'Two', role: 'owner' }) + expect((activeCtx?.ctx as Record | undefined)?.available_workspaces).toBeUndefined() - const reloaded = Registry.load() + const reloaded = await Registry.load() const reloadedActive = reloaded?.resolveActive() - expect(reloadedActive?.ctx.workspace?.id).toBe('ws-2') - expect(reloadedActive?.ctx.workspace?.name).toBe('Switched') + expect(reloadedActive?.ctx.workspace?.id).toBe('00000000-0000-0000-0000-000000000002') + expect(reloadedActive?.ctx.workspace?.name).toBe('Two') + expect((reloadedActive?.ctx as Record | undefined)?.available_workspaces).toBeUndefined() - expect(io.outBuf()).toMatch(/Switched to Switched \(ws-2\)/) + expect(io.outBuf()).toMatch(/Switched to Two \(00000000-0000-0000-0000-000000000002\)/) }) - it('hosts.yml contains no bearer after switch', async () => { + it('no-arg + no-TTY: rejects with usage_missing_arg and never switches', async () => { const io = bufferStreams() + io.isErrTTY = false const reg = makeRegistry() - reg.save() + await reg.save() const active = makeActive(reg) const client = fakeClient({}) - await runUseWorkspace( - { workspaceId: 'ws-2' }, - { reg, active, http: {} as HttpClient, io, workspacesFactory: () => client as never }, - ) + await expect( + runUseWorkspace( + { workspaceId: undefined }, + { reg, active, http: {} as HttpClient, io, workspacesFactory: () => client as never }, + ), + ).rejects.toMatchObject({ code: 'usage_missing_arg' }) - const reloaded = Registry.load() - const raw = JSON.stringify(reloaded) - expect(raw).not.toMatch(/bearer/) + expect(client.switch).not.toHaveBeenCalled() + expect(client.list).not.toHaveBeenCalled() }) - it('refreshes stale workspace name from server', async () => { - // registry has ws-2 named "Stale Name"; server returns "Switched". - // We expect saveRegistry to record the fresh name from the server. + it('switch failure: rejects and leaves the active workspace untouched', async () => { const io = bufferStreams() const reg = makeRegistry() - reg.save() + await reg.save() const active = makeActive(reg) - const client = fakeClient({}) - - await runUseWorkspace( - { workspaceId: 'ws-2' }, - { reg, active, http: {} as HttpClient, io, workspacesFactory: () => client as never }, - ) - - const reloaded = Registry.load() - const reloadedActive = reloaded?.resolveActive() - expect(reloadedActive?.ctx.workspace?.name).toBe('Switched') - expect(reloadedActive?.ctx.available_workspaces?.find(w => w.id === 'ws-2')?.name).toBe('Switched') - }) - - it('does NOT mutate hosts.yml when POST /switch fails', async () => { - const io = bufferStreams() - const reg = makeRegistry() - reg.save() - const active = makeActive(reg) - const before = Registry.load() + const before = await Registry.load() const client = fakeClient({ switch: () => Promise.reject(new Error('forbidden')), @@ -160,85 +135,41 @@ describe('runUseWorkspace', () => { await expect( runUseWorkspace( - { workspaceId: 'ws-2' }, - { - reg, - active, - http: {} as HttpClient, - io, - workspacesFactory: () => client as never, - }, + { workspaceId: '00000000-0000-0000-0000-000000000002' }, + { reg, active, http: {} as HttpClient, io, workspacesFactory: () => client as never }, ), ).rejects.toThrow(/forbidden/) - expect(client.list).not.toHaveBeenCalled() - const after = Registry.load() + const after = await Registry.load() expect(after).toEqual(before) - const afterActive = after?.resolveActive() - expect(afterActive?.ctx.workspace?.id).toBe('ws-1') + expect(after?.resolveActive()?.ctx.workspace?.id).toBe('ws-1') }) - it('does NOT mutate hosts.yml when GET /workspaces fails after switch', async () => { + it('picker path (TTY): lists live workspaces and switches to the selected one', async () => { const io = bufferStreams() + io.isErrTTY = true const reg = makeRegistry() - reg.save() + await reg.save() const active = makeActive(reg) - const before = Registry.load() + const client = fakeClient({}) - const client = fakeClient({ - list: () => Promise.reject(new Error('transient list failure')), - }) + selectFromListMock.mockResolvedValue({ id: '00000000-0000-0000-0000-000000000002', name: 'Two', role: 'owner' }) - await expect( - runUseWorkspace( - { workspaceId: 'ws-2' }, - { - reg, - active, - http: {} as HttpClient, - io, - workspacesFactory: () => client as never, - }, - ), - ).rejects.toThrow(/transient list failure/) + await runUseWorkspace( + { workspaceId: undefined }, + { reg, active, http: {} as HttpClient, io, workspacesFactory: () => client as never }, + ) - const after = Registry.load() - expect(after).toEqual(before) - }) + expect(client.list).toHaveBeenCalledOnce() + expect(selectFromListMock).toHaveBeenCalledOnce() + const passed = selectFromListMock.mock.calls[0]![0] + expect(passed.items).toEqual([ + { id: 'ws-1', name: 'Default', role: 'owner' }, + { id: '00000000-0000-0000-0000-000000000002', name: 'Two', role: 'owner' }, + ]) + expect(client.switch).toHaveBeenCalledExactlyOnceWith('00000000-0000-0000-0000-000000000002') - it('throws when server returns switch= but id is missing from /workspaces list', async () => { - const io = bufferStreams() - const reg = makeRegistry() - reg.save() - const active = makeActive(reg) - - const client = fakeClient({ - switch: () => Promise.resolve({ - id: 'ws-7', - name: 'Ghost', - role: 'normal', - status: 'normal', - current: true, - created_at: null as unknown as string, - }), - list: () => Promise.resolve({ - workspaces: [ - { id: 'ws-1', name: 'Default', role: 'owner', status: 'normal', current: false }, - ], - }), - }) - - await expect( - runUseWorkspace( - { workspaceId: 'ws-7' }, - { - reg, - active, - http: {} as HttpClient, - io, - workspacesFactory: () => client as never, - }, - ), - ).rejects.toThrow(/not visible in \/workspaces/) + const reloadedActive = (await Registry.load())?.resolveActive() + expect(reloadedActive?.ctx.workspace?.id).toBe('00000000-0000-0000-0000-000000000002') }) }) diff --git a/cli/src/commands/use/workspace/use.ts b/cli/src/commands/use/workspace/use.ts index daf0bb7e291..3070a76aa3e 100644 --- a/cli/src/commands/use/workspace/use.ts +++ b/cli/src/commands/use/workspace/use.ts @@ -5,10 +5,11 @@ import { WorkspacesClient } from '@/api/workspaces' import { BaseError } from '@/errors/base' import { ErrorCode } from '@/errors/codes' import { colorEnabled, colorScheme } from '@/sys/io/color' +import { selectFromList } from '@/sys/io/select' import { runWithSpinner } from '@/sys/io/spinner' export type UseWorkspaceOptions = { - readonly workspaceId: string + readonly workspaceId?: string } export type UseWorkspaceDeps = { @@ -22,16 +23,12 @@ export type UseWorkspaceDeps = { /** * Switch the caller's active workspace. * - * Strict ordering: - * 1. POST /workspaces//switch — if this fails (403/404/etc.) we abort - * with no `hosts.yml` mutation, so local state never diverges from the - * server. Any fallback to a pure-local update is explicitly disallowed - * (see workspace-plan.md decision D4). - * 2. GET /workspaces — refresh the membership list so `available_workspaces` - * stays in sync. Failure here also aborts; the server-side current has - * already moved, but the local file is left untouched. A follow-up - * `difyctl get workspace` will reconcile. - * 3. Persist `workspace` + `available_workspaces` atomically via `saveRegistry`. + * With an explicit id we switch directly; with no id we fetch the live + * workspace list and let the caller pick one interactively (TTY only). + * + * The server-side switch is the source of truth: if POST + * `/workspaces//switch` fails we abort before touching `hosts.yml`, so + * local state never diverges from the server. */ export async function runUseWorkspace( opts: UseWorkspaceOptions, @@ -41,32 +38,51 @@ export async function runUseWorkspace( const factory = deps.workspacesFactory ?? ((h: HttpClient) => new WorkspacesClient(h)) const client = factory(deps.http) + const argId = opts.workspaceId?.trim() ?? '' + const id = argId !== '' ? argId : await pickWorkspaceId(client, deps) + const detail = await runWithSpinner( - { io: deps.io, label: `Switching to ${opts.workspaceId}` }, - () => client.switch(opts.workspaceId), + { io: deps.io, label: `Switching to ${id}` }, + () => client.switch(id), ) - const list = await runWithSpinner( - { io: deps.io, label: 'Refreshing workspaces' }, - () => client.list(), - ) - - const matched = list.workspaces.find(w => w.id === detail.id) - if (matched === undefined) { - throw new BaseError({ - code: ErrorCode.Unknown, - message: `server returned switch=${detail.id} but it is not visible in /workspaces`, - hint: 'try again or contact your workspace admin', - }) - } - const nextCtx = { ...deps.active.ctx, - workspace: { id: matched.id, name: matched.name, role: matched.role }, - available_workspaces: list.workspaces.map(w => ({ id: w.id, name: w.name, role: w.role })), + workspace: { id: detail.id, name: detail.name, role: detail.role }, } deps.reg.upsert(deps.active.host, deps.active.email, nextCtx) - deps.reg.save() - deps.io.out.write(`${cs.successIcon()} Switched to ${matched.name} (${matched.id})\n`) + await deps.reg.save() + deps.io.out.write(`${cs.successIcon()} Switched to ${detail.name} (${detail.id})\n`) return deps.reg } + +async function pickWorkspaceId(client: WorkspacesClient, deps: UseWorkspaceDeps): Promise { + if (!deps.io.isErrTTY) { + throw new BaseError({ + code: ErrorCode.UsageMissingArg, + message: 'a workspace id is required (no TTY)', + hint: 'pass the id: \'difyctl use workspace \'', + }) + } + + const list = await runWithSpinner( + { io: deps.io, label: 'Loading workspaces' }, + () => client.list(), + ) + const items = list.workspaces.map(w => ({ id: w.id, name: w.name, role: w.role })) + if (items.length === 0) { + throw new BaseError({ + code: ErrorCode.AccessDenied, + message: 'no workspaces available to switch to', + }) + } + + const activeId = deps.active.ctx.workspace?.id + const picked = await selectFromList({ + io: deps.io, + items, + header: 'Select a workspace', + render: w => `${w.id === activeId ? '* ' : ' '}${w.name} (${w.role})`, + }) + return picked.id +} diff --git a/cli/src/config/config-loader.test.ts b/cli/src/config/config-loader.test.ts index 501010cabd0..83d954db686 100644 --- a/cli/src/config/config-loader.test.ts +++ b/cli/src/config/config-loader.test.ts @@ -1,52 +1,34 @@ import type { YamlStore } from '@/store/store' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { useTempConfigDir } from '@test/fixtures/config-dir' +import { describe, expect, it } from 'vitest' import { isBaseError } from '@/errors/base' import { ErrorCode } from '@/errors/codes' -import { ENV_CONFIG_DIR } from '@/store/dir' import { getConfigurationStore } from '@/store/manager' import { loadConfig } from './config-loader' describe('loadConfig', () => { - let dir: string - let prevConfigDir: string | undefined + useTempConfigDir('difyctl-cfg-') - beforeEach(async () => { - dir = await mkdtemp(join(tmpdir(), 'difyctl-cfg-')) - prevConfigDir = process.env[ENV_CONFIG_DIR] - process.env[ENV_CONFIG_DIR] = dir - }) - - afterEach(async () => { - if (prevConfigDir === undefined) - delete process.env[ENV_CONFIG_DIR] - else - process.env[ENV_CONFIG_DIR] = prevConfigDir - await rm(dir, { recursive: true, force: true }) - }) - - it('returns found:false when config is missing', () => { - const r = loadConfig(getConfigurationStore()) + it('returns found:false when config is missing', async () => { + const r = await loadConfig(getConfigurationStore()) expect(r.found).toBe(false) }) - it('parses a minimal valid config', () => { - getConfigurationStore().setTyped({ schema_version: 1 }) - const r = loadConfig(getConfigurationStore()) + it('parses a minimal valid config', async () => { + await getConfigurationStore().setTyped({ schema_version: 1 }) + const r = await loadConfig(getConfigurationStore()) expect(r.found).toBe(true) if (r.found) expect(r.config.schema_version).toBe(1) }) - it('parses defaults + state', () => { - getConfigurationStore().setTyped({ + it('parses defaults + state', async () => { + await getConfigurationStore().setTyped({ schema_version: 1, defaults: { format: 'json', limit: 100 }, state: { current_app: 'app-1' }, }) - const r = loadConfig(getConfigurationStore()) + const r = await loadConfig(getConfigurationStore()) expect(r.found).toBe(true) if (r.found) { expect(r.config.defaults.format).toBe('json') @@ -55,7 +37,7 @@ describe('loadConfig', () => { } }) - it('throws BaseError(config_schema_unsupported) when the store fails to parse the file', () => { + it('throws BaseError(config_schema_unsupported) when the store fails to parse the file', async () => { // Simulate a corrupt on-disk file via a fake store; loadConfig must wrap // the underlying error as ConfigSchemaUnsupported. const throwingStore = { @@ -63,7 +45,7 @@ describe('loadConfig', () => { } as unknown as YamlStore let caught: unknown try { - loadConfig(throwingStore) + await loadConfig(throwingStore) } catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) @@ -73,11 +55,11 @@ describe('loadConfig', () => { } }) - it('throws BaseError(config_schema_unsupported) when zod validation fails', () => { - getConfigurationStore().setTyped({ defaults: { limit: 9999 } }) + it('throws BaseError(config_schema_unsupported) when zod validation fails', async () => { + await getConfigurationStore().setTyped({ defaults: { limit: 9999 } }) let caught: unknown try { - loadConfig(getConfigurationStore()) + await loadConfig(getConfigurationStore()) } catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) @@ -85,11 +67,11 @@ describe('loadConfig', () => { expect(caught.code).toBe(ErrorCode.ConfigSchemaUnsupported) }) - it('throws BaseError(config_schema_unsupported) when schema_version > 1 (forward-refuse)', () => { - getConfigurationStore().setTyped({ schema_version: 2 }) + it('throws BaseError(config_schema_unsupported) when schema_version > 1 (forward-refuse)', async () => { + await getConfigurationStore().setTyped({ schema_version: 2 }) let caught: unknown try { - loadConfig(getConfigurationStore()) + await loadConfig(getConfigurationStore()) } catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) diff --git a/cli/src/config/config-loader.ts b/cli/src/config/config-loader.ts index cd2d3601057..d8438c2f045 100644 --- a/cli/src/config/config-loader.ts +++ b/cli/src/config/config-loader.ts @@ -8,10 +8,10 @@ export type LoadResult = | { found: false } | { found: true, config: ConfigFile } -export function loadConfig(store: YamlStore): LoadResult { +export async function loadConfig(store: YamlStore): Promise { let raw: Record | null try { - raw = store.getTyped>() + raw = await store.getTyped>() } catch (err) { throw newError( diff --git a/cli/src/errors/base.ts b/cli/src/errors/base.ts index 0ad438a7437..f1f9ca98535 100644 --- a/cli/src/errors/base.ts +++ b/cli/src/errors/base.ts @@ -1,3 +1,4 @@ +import type { ErrorBody } from '@dify/contracts/api/openapi/types.gen' import type { ErrorCodeValue, ExitCodeValue } from './codes' import type { ErrorEnvelope, PrintableError } from './format' import { ErrorCode, exitFor } from './codes' @@ -83,6 +84,7 @@ type HttpClientErrorOptions = BaseErrorOptions & { readonly method?: string readonly url?: string readonly rawResponse?: string + readonly serverError?: ErrorBody } export class HttpClientError extends BaseError { @@ -90,6 +92,7 @@ export class HttpClientError extends BaseError { readonly method?: string readonly url?: string readonly rawResponse?: string + readonly serverError?: ErrorBody constructor(opts: HttpClientErrorOptions) { super(opts) @@ -97,6 +100,7 @@ export class HttpClientError extends BaseError { this.method = opts.method this.url = opts.url this.rawResponse = opts.rawResponse + this.serverError = opts.serverError } override toEnvelope(): ErrorEnvelope { @@ -109,6 +113,8 @@ export class HttpClientError extends BaseError { envelope.error.url = this.url if (this.rawResponse !== undefined) envelope.error.raw_response = this.rawResponse + if (this.serverError !== undefined) + envelope.error.server = this.serverError return envelope } @@ -119,6 +125,7 @@ export class HttpClientError extends BaseError { method: this.method, url: this.url, rawResponse: this.rawResponse, + serverError: this.serverError, } } @@ -145,4 +152,8 @@ export class HttpClientError extends BaseError { } return new HttpClientError({ ...this.snapshot(), rawResponse }) } + + withServerError(serverError: ErrorBody): HttpClientError { + return new HttpClientError({ ...this.snapshot(), serverError }) + } } diff --git a/cli/src/errors/codes.test.ts b/cli/src/errors/codes.test.ts index a29697f57a7..eb76b13a22f 100644 --- a/cli/src/errors/codes.test.ts +++ b/cli/src/errors/codes.test.ts @@ -18,6 +18,7 @@ describe('error codes', () => { expect(ExitCode.Usage).toBe(2) expect(ExitCode.Auth).toBe(4) expect(ExitCode.VersionCompat).toBe(6) + expect(ExitCode.RateLimited).toBe(7) }) it('every code maps to an exit', () => { @@ -46,6 +47,7 @@ describe('error codes', () => { [ErrorCode.Server4xxOther, ExitCode.Generic], [ErrorCode.ClientError, ExitCode.Generic], [ErrorCode.Unknown, ExitCode.Generic], + [ErrorCode.RateLimited, ExitCode.RateLimited], ])('exitFor(%s) -> %d', (code, want) => { expect(exitFor(code)).toBe(want) }) diff --git a/cli/src/errors/codes.ts b/cli/src/errors/codes.ts index e2e69e5cd20..e2b16cb3619 100644 --- a/cli/src/errors/codes.ts +++ b/cli/src/errors/codes.ts @@ -12,11 +12,13 @@ export const ErrorCode = { ConfigInvalidKey: 'config_invalid_key', ConfigInvalidValue: 'config_invalid_value', NetworkConnection: 'network_connection', + RateLimited: 'rate_limited', Server5xx: 'server_5xx', Server4xxOther: 'server_4xx_other', ClientError: 'client_error', Unknown: 'unknown', IllegalArgumentError: 'illegal_argument', + KeyringUnavailable: 'keyring_unavailable', } as const export type ErrorCodeValue = (typeof ErrorCode)[keyof typeof ErrorCode] @@ -27,6 +29,8 @@ export const ExitCode = { Usage: 2, Auth: 4, VersionCompat: 6, + // Distinct from Generic so wrappers can tell "rate limited, retry later" from a hard failure. + RateLimited: 7, } as const export type ExitCodeValue = (typeof ExitCode)[keyof typeof ExitCode] @@ -45,11 +49,13 @@ const CODE_TO_EXIT: Readonly> = { config_invalid_key: ExitCode.Usage, config_invalid_value: ExitCode.Usage, network_connection: ExitCode.Generic, + rate_limited: ExitCode.RateLimited, server_5xx: ExitCode.Generic, server_4xx_other: ExitCode.Generic, client_error: ExitCode.Generic, unknown: ExitCode.Generic, illegal_argument: ExitCode.Usage, + keyring_unavailable: ExitCode.Generic, } export function exitFor(code: string): ExitCodeValue { diff --git a/cli/src/errors/format.test.ts b/cli/src/errors/format.test.ts new file mode 100644 index 00000000000..dd75b53591c --- /dev/null +++ b/cli/src/errors/format.test.ts @@ -0,0 +1,168 @@ +import type { ErrorBody } from '@dify/contracts/api/openapi/types.gen' +import { afterEach, describe, expect, it } from 'vitest' + +import { setVerbose } from '@/framework/context' +import { HttpClientError } from './base' +import { ErrorCode } from './codes' +import { formatErrorForCli } from './format' + +type ValidationErrorOverrides = { + readonly cliHint?: string + readonly serverHint?: string + readonly details?: ErrorBody['details'] +} + +function validationError(overrides: ValidationErrorOverrides = {}): HttpClientError { + const details + = overrides.details + ?? [ + { type: 'int_parsing', loc: ['page'], msg: 'must be >= 1' }, + { type: 'missing', loc: ['inputs', 'query'], msg: 'field required' }, + ] + return new HttpClientError({ + code: ErrorCode.Server4xxOther, + message: 'Request validation failed', + httpStatus: 422, + hint: overrides.cliHint, + serverError: { + code: 'invalid_param', + message: 'Request validation failed', + status: 422, + hint: overrides.serverHint, + details, + }, + }) +} + +afterEach(() => { + setVerbose(false) +}) + +describe('formatErrorForCli — human', () => { + it('prints server code, message, and details without verbose', () => { + const out = formatErrorForCli(validationError({ serverHint: 'check the page parameter' }), { isErrTTY: false }) + + expect(out).toContain('invalid_param: Request validation failed') + expect(out).toContain('- page: must be >= 1 (int_parsing)') + expect(out).toContain('- inputs.query: field required (missing)') + expect(out).toContain('check the page parameter') + expect(out).not.toContain('raw_response') + }) + + it('falls back to cli code when no server code', () => { + const err = new HttpClientError({ code: ErrorCode.Server5xx, message: 'server error (HTTP 502)', httpStatus: 502 }) + + const out = formatErrorForCli(err, { isErrTTY: false }) + + expect(out).toContain('server_5xx: server error (HTTP 502)') + }) + + it('cli hint wins over server hint; server hint fills when cli sent none', () => { + const withBothHints = validationError({ cliHint: 'cli local hint', serverHint: 'check the page parameter', details: [] }) + expect(formatErrorForCli(withBothHints, { isErrTTY: false })).toContain('cli local hint') + expect(formatErrorForCli(withBothHints, { isErrTTY: false })).not.toContain('check the page parameter') + + // no cli hint → server hint shown + const noCliHint = validationError({ serverHint: 'check the page parameter', details: [] }) + expect(formatErrorForCli(noCliHint, { isErrTTY: false })).toContain('check the page parameter') + + // no server hint → cli hint shown + const noServerHint = new HttpClientError({ + code: ErrorCode.AuthExpired, + message: 'session expired', + hint: 'run difyctl auth login', + }) + expect(formatErrorForCli(noServerHint, { isErrTTY: false })).toContain('run difyctl auth login') + }) + + it('omits the loc prefix when a detail has no loc', () => { + const out = formatErrorForCli( + validationError({ details: [{ type: 'invalid', loc: [], msg: 'body required' }] }), + { isErrTTY: false }, + ) + + expect(out).toContain('- body required (invalid)') + expect(out).not.toContain('- : body required') + }) + + it('hints at -v when a raw response exists but is hidden', () => { + const err = new HttpClientError({ + code: ErrorCode.Server4xxOther, + message: 'request failed (HTTP 400)', + httpStatus: 400, + rawResponse: 'not json', + }) + + const out = formatErrorForCli(err, { isErrTTY: false }) + + expect(out).toContain('run again with -v to see the raw server response') + expect(out).not.toContain('raw_response') + }) + + it('no -v hint when the server body parsed', () => { + const err = new HttpClientError({ + code: ErrorCode.Server4xxOther, + message: 'Request validation failed', + httpStatus: 422, + rawResponse: '{"code":"invalid_param","message":"Request validation failed","status":422}', + serverError: { code: 'invalid_param', message: 'Request validation failed', status: 422 }, + }) + + const out = formatErrorForCli(err, { isErrTTY: false }) + + expect(out).not.toContain('run again with -v') + }) + + it('existing hints win over the -v hint', () => { + const err = new HttpClientError({ + code: ErrorCode.Server4xxOther, + message: 'request failed (HTTP 400)', + httpStatus: 400, + hint: 'cli hint', + rawResponse: 'not json', + }) + + const out = formatErrorForCli(err, { isErrTTY: false }) + + expect(out).toContain('cli hint') + expect(out).not.toContain('run again with -v') + }) + + it('shows raw_response instead of the -v hint when verbose', () => { + setVerbose(true) + const err = new HttpClientError({ + code: ErrorCode.Server4xxOther, + message: 'request failed (HTTP 400)', + httpStatus: 400, + rawResponse: 'not json', + }) + + const out = formatErrorForCli(err, { isErrTTY: false }) + + expect(out).toContain('raw_response: not json') + expect(out).not.toContain('run again with -v') + }) + + it('renders request and http_status lines', () => { + const err = new HttpClientError({ + code: ErrorCode.Server5xx, + message: 'upstream boom', + httpStatus: 502, + method: 'GET', + url: 'https://api.dify.ai/v1/me', + }) + const out = formatErrorForCli(err, { isErrTTY: false }) + expect(out).toContain('request: GET https://api.dify.ai/v1/me') + expect(out).toContain('http_status: 502') + }) +}) + +describe('formatErrorForCli — json', () => { + it('envelope nests the whole server error', () => { + const out = JSON.parse(formatErrorForCli(validationError(), { format: 'json' })) + + expect(out.error.server.code).toBe('invalid_param') + expect(out.error.server.details).toHaveLength(2) + expect(out.error.code).toBe('server_4xx_other') + }) +}) diff --git a/cli/src/errors/format.ts b/cli/src/errors/format.ts index b8c3fe6cab7..f32bba3482c 100644 --- a/cli/src/errors/format.ts +++ b/cli/src/errors/format.ts @@ -1,7 +1,10 @@ +import type { ErrorBody } from '@dify/contracts/api/openapi/types.gen' import { isVerbose } from '@/framework/context' import { redactBearer } from '@/http/sanitize' import { colorEnabled, colorScheme } from '@/sys/io/color' +const RAW_RESPONSE_HINT = 'run again with -v to see the raw server response' + export type FormatErrorOptions = { readonly format?: string readonly isErrTTY?: boolean @@ -16,6 +19,7 @@ export type ErrorEnvelope = { method?: string url?: string raw_response?: string + server?: ErrorBody } } @@ -42,12 +46,30 @@ function renderEnvelope(env: ErrorEnvelope): string { return JSON.stringify(env) } +// CLI-authored hint wins: it knows local remediation (e.g. which command to +// run); the server hint fills in when the CLI has nothing for this error. +function resolveHint(e: ErrorEnvelope['error']): string | undefined { + if (e.hint !== undefined) + return e.hint + if (e.server?.hint != null) + return e.server.hint + const rawHiddenAndUnparsed = e.server === undefined && Boolean(e.raw_response) && !isVerbose() + return rawHiddenAndUnparsed ? RAW_RESPONSE_HINT : undefined +} + function renderHuman(env: ErrorEnvelope, isErrTTY: boolean): string { const cs = colorScheme(colorEnabled(isErrTTY)) const e = env.error - const lines: string[] = [`${e.code}: ${e.message}`] - if (e.hint !== undefined) - lines.push(`${cs.magenta('hint:')} ${cs.cyan(e.hint)}`) + const server = e.server + const headerCode = server?.code ?? e.code + const lines: string[] = [`${headerCode}: ${e.message}`] + for (const d of server?.details ?? []) { + const loc = (d.loc ?? []).join('.') + lines.push(` - ${loc ? `${loc}: ` : ''}${d.msg} (${d.type})`) + } + const hint = resolveHint(e) + if (hint !== undefined) + lines.push(`${cs.magenta('hint:')} ${cs.cyan(hint)}`) if (e.method !== undefined && e.url !== undefined) lines.push(`request: ${e.method} ${e.url}`) if (e.http_status !== undefined) diff --git a/cli/src/http/client.test.ts b/cli/src/http/client.test.ts index fbde1ecdcab..ae4448843a4 100644 --- a/cli/src/http/client.test.ts +++ b/cli/src/http/client.test.ts @@ -192,7 +192,7 @@ describe('http client', () => { expect(caught.code).toBe(ErrorCode.Server4xxOther) }) - it('handles 429 via retry status code list', async () => { + it('surfaces a 429 as a rate-limit error (dedicated exit code), no retry when budget is 0', async () => { mock.setScenario('rate-limited') const client = createHttpClient({ baseURL: base(mock.url), @@ -206,8 +206,246 @@ describe('http client', () => { } catch (err) { caught = err } expect(isHttpClientError(caught)).toBe(true) - if (isHttpClientError(caught)) + if (isHttpClientError(caught)) { expect(caught.httpStatus).toBe(429) + expect(caught.code).toBe(ErrorCode.RateLimited) + expect(caught.exit()).toBe(7) + expect(caught.serverError?.code).toBe('too_many_requests') + } + }) + + it('retries an idempotent GET on a throttle 429, then succeeds', async () => { + let calls = 0 + const stub = await startStub((_req, res) => { + calls++ + if (calls === 1) { + res.writeHead(429, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ code: 'too_many_requests', message: 'slow down', status: 429 })) + return + } + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ workspaces: [] })) + }) + const events: { phase: string, status?: number, delayMs?: number }[] = [] + try { + const client = createHttpClient({ + baseURL: base(stub.url), + bearer: 'dfoa_test', + retryAttempts: 3, + timeoutMs: 5_000, + logger: e => events.push({ phase: e.phase, status: e.status, delayMs: e.delayMs }), + }) + const body = await client.get<{ workspaces: unknown[] }>('workspaces') + expect(body.workspaces).toEqual([]) + } + finally { + await stub.stop() + } + expect(calls).toBe(2) + const retry = events.find(e => e.phase === 'retry') + expect(retry?.status).toBe(429) + expect(retry?.delayMs).toBeGreaterThan(0) + }) + + it('does not retry a quota 429 (rate_limit_error) — surfaces immediately', async () => { + let requests = 0 + const stub = await startStub((_req, res) => { + res.writeHead(429, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ code: 'rate_limit_error', message: 'quota exhausted', status: 429 })) + }) + let caught: unknown + try { + const client = createHttpClient({ + baseURL: base(stub.url), + bearer: 'dfoa_test', + retryAttempts: 3, + timeoutMs: 5_000, + logger: (e) => { + if (e.phase === 'request') + requests++ + }, + }) + try { + await client.get('workspaces') + } + catch (err) { caught = err } + } + finally { + await stub.stop() + } + expect(requests).toBe(1) + expect(isHttpClientError(caught)).toBe(true) + if (isHttpClientError(caught)) { + expect(caught.code).toBe(ErrorCode.RateLimited) + expect(caught.serverError?.code).toBe('rate_limit_error') + } + }) + + it('does not retry a POST 429 by default; retries with retry-on-limit', async () => { + let postDefault = 0 + const stubDefault = await startStub((_req, res) => { + postDefault++ + res.writeHead(429, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ code: 'too_many_requests', message: 'slow down', status: 429 })) + }) + try { + const client = createHttpClient({ baseURL: base(stubDefault.url), bearer: 'dfoa_test', retryAttempts: 3, timeoutMs: 5_000 }) + await expect(client.post('apps/app-1/run', { json: { inputs: {} } })).rejects.toBeDefined() + } + finally { + await stubDefault.stop() + } + expect(postDefault).toBe(1) + + let calls = 0 + const stubOptIn = await startStub((_req, res) => { + calls++ + if (calls === 1) { + res.writeHead(429, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ code: 'too_many_requests', message: 'slow down', status: 429 })) + return + } + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ ok: true })) + }) + try { + const client = createHttpClient({ baseURL: base(stubOptIn.url), bearer: 'dfoa_test', retryAttempts: 3, timeoutMs: 5_000 }) + const body = await client.post<{ ok: boolean }>('apps/app-1/run', { json: { inputs: {} }, retryOnRateLimit: true }) + expect(body.ok).toBe(true) + } + finally { + await stubOptIn.stop() + } + expect(calls).toBe(2) + }) + + it('still never retries a POST 5xx even with retry-on-limit (idempotency guard)', async () => { + let requests = 0 + const stub = await startStub((_req, res) => { + requests++ + res.writeHead(503, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ code: 'internal_server_error', message: 'boom', status: 503 })) + }) + try { + const client = createHttpClient({ baseURL: base(stub.url), bearer: 'dfoa_test', retryAttempts: 3, timeoutMs: 5_000 }) + await expect(client.post('apps/app-1/run', { json: { inputs: {} }, retryOnRateLimit: true })).rejects.toBeDefined() + } + finally { + await stub.stop() + } + expect(requests).toBe(1) + }) + + it('surfaces a RateLimited error after exhausting 429 retries on GET', async () => { + let requests = 0 + let retries = 0 + const stub = await startStub((_req, res) => { + res.writeHead(429, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ code: 'too_many_requests', message: 'slow down', status: 429 })) + }) + let caught: unknown + try { + const client = createHttpClient({ + baseURL: base(stub.url), + bearer: 'dfoa_test', + retryAttempts: 2, + timeoutMs: 5_000, + logger: (e) => { + if (e.phase === 'request') + requests++ + else if (e.phase === 'retry') + retries++ + }, + }) + try { + await client.get('workspaces') + } + catch (err) { caught = err } + } + finally { + await stub.stop() + } + expect(requests).toBe(3) + expect(retries).toBe(2) + expect(isHttpClientError(caught)).toBe(true) + if (isHttpClientError(caught)) + expect(caught.code).toBe(ErrorCode.RateLimited) + }) + + it('surfaces a throttle 429 whose Retry-After exceeds the honored cap (no retry)', async () => { + let requests = 0 + const stub = await startStub((_req, res) => { + // 120s advised wait > MAX_HONORED_WAIT_MS (60s) — surface rather than park for minutes. + res.writeHead(429, { 'content-type': 'application/json', 'retry-after': '120' }) + res.end(JSON.stringify({ code: 'too_many_requests', message: 'slow down', status: 429 })) + }) + let caught: unknown + try { + const client = createHttpClient({ + baseURL: base(stub.url), + bearer: 'dfoa_test', + retryAttempts: 3, + timeoutMs: 5_000, + logger: (e) => { + if (e.phase === 'request') + requests++ + }, + }) + try { + await client.get('workspaces') + } + catch (err) { caught = err } + } + finally { + await stub.stop() + } + expect(requests).toBe(1) + expect(isHttpClientError(caught)).toBe(true) + if (isHttpClientError(caught)) + expect(caught.code).toBe(ErrorCode.RateLimited) + }) + + it('stream GET surfaces a 429 (returns the Response, no throw, no retry)', async () => { + let calls = 0 + const stub = await startStub((_req, res) => { + calls++ + res.writeHead(429, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ code: 'too_many_requests', message: 'slow down', status: 429 })) + }) + try { + const client = createHttpClient({ baseURL: base(stub.url), bearer: 'dfoa_test', timeoutMs: 5_000 }) + const res = await client.stream('workspaces') + expect(res.status).toBe(429) + await res.body?.cancel() + } + finally { + await stub.stop() + } + expect(calls).toBe(1) + }) + + it('stream POST retries a throttle 429 when retry-on-limit is set', async () => { + let calls = 0 + const stub = await startStub((_req, res) => { + calls++ + if (calls === 1) { + res.writeHead(429, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ code: 'too_many_requests', message: 'slow down', status: 429 })) + return + } + res.writeHead(200, { 'content-type': 'text/event-stream' }) + res.end('data: {}\n\n') + }) + try { + const client = createHttpClient({ baseURL: base(stub.url), bearer: 'dfoa_test', timeoutMs: 5_000 }) + const res = await client.stream('apps/app-1/run', { method: 'POST', json: {}, retryOnRateLimit: true }) + expect(res.status).toBe(200) + await res.body?.cancel() + } + finally { + await stub.stop() + } + expect(calls).toBe(2) }) it('does not retry POST on 503', async () => { diff --git a/cli/src/http/client.ts b/cli/src/http/client.ts index f099b012259..940aee9152b 100644 --- a/cli/src/http/client.ts +++ b/cli/src/http/client.ts @@ -15,7 +15,8 @@ import { buildBody } from './body.js' import { classifyResponse } from './error-mapper.js' import { classifyTransport, logRequest, logResponse, setBearer, setUserAgent } from './hooks.js' import { proxyDispatcher } from './proxy.js' -import { backoffDelay, shouldRetry } from './retry.js' +import { classifyRateLimit, MAX_HONORED_WAIT_MS, RATE_LIMIT_MAX_ATTEMPTS, rateLimitDelayMs } from './rate-limit.js' +import { backoffDelay, isIdempotentRetryMethod, shouldRetry } from './retry.js' import { redactBearer } from './sanitize.js' import { appendSearchParams, joinURL } from './url.js' @@ -133,6 +134,7 @@ function buildRequest(state: ClientState, path: string, opts: RequestOptions, th timeoutMs: effectiveTimeoutMs, retryAttempts: effectiveRetryAttempts, throwOnError, + retryOnRateLimit: opts.retryOnRateLimit ?? false, } return { request, resolved, effectiveTimeoutMs, userSignal: opts.signal } } @@ -204,6 +206,37 @@ async function execute( const res = ctx.response if (!res.ok) { + // 429 has its own policy. The server self-describes via the ErrorBody `code`: a + // "too_many_requests" throttle waits-and-retries on idempotent methods (or opted-in POSTs) + // honoring Retry-After; quota / unrecognized 429s surface immediately rather than burning + // retries. Surfacing reuses the shared classifyResponse so the body parses to ErrorBody. + if (res.status === 429) { + const decision = await classifyRateLimit(res.clone()) + const canRetry + = decision.retryable + && attempt < effectiveRetryAttempts + && (decision.retryAfterMs === undefined || decision.retryAfterMs <= MAX_HONORED_WAIT_MS) + && (isIdempotentRetryMethod(method) || (method === 'POST' && resolved.retryOnRateLimit)) + if (canRetry) { + const delay = rateLimitDelayMs(decision, attempt + 1) + state.logger?.({ phase: 'retry', method, url: redactBearer(ctx.request.url), status: 429, attempt: attempt + 1, delayMs: delay }) + await res.body?.cancel().catch(() => {}) + if (delay > 0) + await new Promise(resolve => setTimeout(resolve, delay)) + continue + } + + ctx.error = await classifyResponse(ctx.request, res) + await runHooks(state.hooks.onResponseError, ctx) + if (throwOnError) { + const finalErr = ctx.error + if (finalErr instanceof Error && typeof Error.captureStackTrace === 'function') + Error.captureStackTrace(finalErr, execute) + throw finalErr + } + return res + } + if (attempt < effectiveRetryAttempts && shouldRetry(res, ctx)) { state.logger?.({ phase: 'retry', method, url: redactBearer(ctx.request.url), attempt: attempt + 1 }) // Drain the discarded error body so undici can release the socket back to its @@ -259,10 +292,18 @@ export function createHttpClient(opts: ClientOptions): HttpClient { const streamFetch = (path: string, callOpts?: RequestOptions): Promise => { // SSE bodies must not be aborted by a request-level timeout — `0` is the buildRequest // sentinel for "no timeout" and also overrides the client default. + // + // A stream normally never retries (a mid-stream replay would double-send). When the caller + // opts into 429 retry, allow a bounded budget: the 429 admission rejection arrives as a plain + // body before the stream opens, and execute()'s 429 branch is the only path that fires for a + // POST — shouldRetry still rejects POST for transport / 5xx, so nothing else replays. + const retryAttempts = callOpts?.retryOnRateLimit === true + ? (callOpts.retryAttempts ?? RATE_LIMIT_MAX_ATTEMPTS) + : 0 const finalOpts: RequestOptions = { ...callOpts, method: callOpts?.method ?? 'GET', - retryAttempts: 0, + retryAttempts, timeoutMs: 0, } const built = buildRequest(state, path, finalOpts, false) @@ -284,6 +325,7 @@ export function createHttpClient(opts: ClientOptions): HttpClient { timeoutMs: state.defaultTimeoutMs, retryAttempts: state.defaultRetryAttempts, throwOnError: false, + retryOnRateLimit: false, } const userSignal = init?.signal ?? req.signal return execute(state, req, resolved, state.defaultTimeoutMs, userSignal) diff --git a/cli/src/http/error-mapper.test.ts b/cli/src/http/error-mapper.test.ts new file mode 100644 index 00000000000..0a487723352 --- /dev/null +++ b/cli/src/http/error-mapper.test.ts @@ -0,0 +1,89 @@ +import type { HttpClientError } from '@/errors/base' +import { describe, expect, it } from 'vitest' +import { ErrorCode } from '@/errors/codes' +import { classifyResponse } from './error-mapper' + +function res(status: number, body: unknown): Response { + return new Response(typeof body === 'string' ? body : JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +const req = new Request('https://dify.test/openapi/v1/apps') + +function classified(status: number, body: unknown): Promise { + return classifyResponse(req, res(status, body)) +} + +describe('classifyResponse — canonical ErrorBody', () => { + it('attaches the parsed body whole as serverError', async () => { + const body = { + code: 'invalid_param', + message: 'Request validation failed', + status: 422, + hint: 'check the page parameter', + details: [{ type: 'int_parsing', loc: ['page'], msg: 'must be >= 1' }], + } + + const err = await classified(422, body) + + expect(err.serverError).toEqual(body) + expect(err.message).toBe('Request validation failed') + expect(err.code).toBe(ErrorCode.Server4xxOther) + }) + + it('401 classifies by status as AuthExpired with CLI login hint', async () => { + const err = await classified(401, { + code: 'unauthorized', + message: 'session expired or revoked', + status: 401, + }) + + expect(err.code).toBe(ErrorCode.AuthExpired) + expect(err.hint).toBe('run \'difyctl auth login\' to sign in again') + }) + + it('unknown future server code is data, not behavior — status bucket decides', async () => { + const err = await classified(409, { + code: 'some_future_code', + message: 'nope', + status: 409, + }) + + expect(err.code).toBe(ErrorCode.Server4xxOther) + expect(err.serverError?.code).toBe('some_future_code') + }) + + it('429 classifies as RateLimited (dedicated exit code) and keeps the server code', async () => { + const err = await classified(429, { code: 'too_many_requests', message: 'slow down', status: 429 }) + + expect(err.code).toBe(ErrorCode.RateLimited) + expect(err.exit()).toBe(7) + expect(err.serverError?.code).toBe('too_many_requests') + }) + + it('429 with no parseable ErrorBody falls back to a generic rate-limit message', async () => { + const err = await classified(429, 'not json') + + expect(err.code).toBe(ErrorCode.RateLimited) + expect(err.serverError).toBeUndefined() + expect(err.message).toBe('too many requests') + }) +}) + +describe('classifyResponse — non-conforming bodies (no fallback by design)', () => { + it('non-JSON body yields no serverError, classification by status', async () => { + const err = await classified(502, 'bad gateway') + + expect(err.code).toBe(ErrorCode.Server5xx) + expect(err.serverError).toBeUndefined() + }) + + it('RFC 8628 string error field yields no serverError and a generic message', async () => { + const err = await classified(400, { error: 'slow_down' }) + + expect(err.message).toBe('request failed (HTTP 400)') + expect(err.serverError).toBeUndefined() + }) +}) diff --git a/cli/src/http/error-mapper.ts b/cli/src/http/error-mapper.ts index cb0d03c2068..aca1a7e6184 100644 --- a/cli/src/http/error-mapper.ts +++ b/cli/src/http/error-mapper.ts @@ -1,70 +1,95 @@ +import type { ErrorBody } from '@dify/contracts/api/openapi/types.gen' +import type { ErrorCodeValue } from '@/errors/codes' +import { zErrorBody } from '@dify/contracts/api/openapi/zod.gen' import { BaseError, HttpClientError, newError } from '@/errors/base' import { ErrorCode } from '@/errors/codes' import { redactBearer } from './sanitize' -type WireFields = { - code?: string - message?: string - hint?: string +const AUTH_EXPIRED_MESSAGE = 'session expired or revoked' +const AUTH_LOGIN_HINT = 'run \'difyctl auth login\' to sign in again' + +// How one HTTP status bucket classifies: CLI code, message fallback when the +// body is not a canonical ErrorBody, optional CLI hint, raw-body retention. +type StatusClass = { + readonly code: ErrorCodeValue + readonly fallbackMessage: (status: number) => string + readonly hint?: string + readonly includeRaw: boolean } -type WireEnvelope = WireFields & { - error?: WireFields +const AUTH_EXPIRED_CLASS: StatusClass = { + code: ErrorCode.AuthExpired, + fallbackMessage: () => AUTH_EXPIRED_MESSAGE, + hint: AUTH_LOGIN_HINT, + includeRaw: false, } -async function readBody(response: Response): Promise<{ raw: string, parsed?: WireEnvelope }> { +const SERVER_5XX_CLASS: StatusClass = { + code: ErrorCode.Server5xx, + fallbackMessage: status => `server error (HTTP ${status})`, + includeRaw: true, +} + +const SERVER_4XX_CLASS: StatusClass = { + code: ErrorCode.Server4xxOther, + fallbackMessage: status => `request failed (HTTP ${status})`, + includeRaw: true, +} + +// 429 gets a dedicated CLI code (its own exit code) so wrappers can tell a rate limit from a hard +// failure. The serverError.code ("too_many_requests" / "rate_limit_error") still rides along. +const RATE_LIMITED_CLASS: StatusClass = { + code: ErrorCode.RateLimited, + fallbackMessage: () => 'too many requests', + includeRaw: false, +} + +function statusClass(status: number): StatusClass { + if (status === 401) + return AUTH_EXPIRED_CLASS + if (status === 429) + return RATE_LIMITED_CLASS + if (status >= 500) + return SERVER_5XX_CLASS + return SERVER_4XX_CLASS +} + +function parseServerError(raw: string): ErrorBody | undefined { + if (raw === '') + return undefined + let parsed: unknown + try { + parsed = JSON.parse(raw) + } + catch { + return undefined + } + const result = zErrorBody.safeParse(parsed) + return result.success ? result.data : undefined +} + +export async function classifyResponse(request: Request, response: Response): Promise { let raw = '' try { - raw = await response.text() + raw = await response.clone().text() } catch { - return { raw: '' } + // ignore read errors; raw stays '' } - if (raw === '') - return { raw } - try { - return { raw, parsed: JSON.parse(raw) as WireEnvelope } - } - catch { - return { raw } - } -} -export async function classifyResponse(request: Request, response: Response): Promise { - const { parsed, raw } = await readBody(response.clone()) - const wire: WireFields = parsed?.error ?? parsed ?? {} + const serverError = parseServerError(raw) const status = response.status - const url = redactBearer(response.url || request.url) - const method = request.method - - if (status === 401) { - return HttpClientError.from(newError( - ErrorCode.AuthExpired, - wire.message ?? 'session expired or revoked', - )) - .withHint(wire.hint ?? 'run \'difyctl auth login\' to sign in again') - .withHttpStatus(status) - .withRequest(method, url) - } - - if (status >= 500) { - return HttpClientError.from(newError( - ErrorCode.Server5xx, - wire.message ?? `server error (HTTP ${status})`, - )) - .withHttpStatus(status) - .withRequest(method, url) - .withRawResponse(raw) - } - - const err = HttpClientError.from(newError( - ErrorCode.Server4xxOther, - wire.message ?? `request failed (HTTP ${status})`, - )) - .withHttpStatus(status) - .withRequest(method, url) - .withRawResponse(raw) - return wire.hint !== undefined ? err.withHint(wire.hint) : err + const c = statusClass(status) + return new HttpClientError({ + code: c.code, + message: serverError?.message ?? c.fallbackMessage(status), + hint: c.hint, + httpStatus: status, + method: request.method, + url: redactBearer(response.url || request.url), + rawResponse: c.includeRaw && raw !== '' ? raw : undefined, + serverError, + }) } export function classifyTransportError(err: unknown): BaseError { diff --git a/cli/src/http/orpc.test.ts b/cli/src/http/orpc.test.ts index 05e9a8405f0..c99232b975f 100644 --- a/cli/src/http/orpc.test.ts +++ b/cli/src/http/orpc.test.ts @@ -1,4 +1,5 @@ import type { StubServer } from '@test/fixtures/stub-server' +import type { HttpClientError } from '@/errors/base' import { jsonResponder, startStubServer } from '@test/fixtures/stub-server' import { afterEach, describe, expect, it } from 'vitest' import { isHttpClientError } from '@/errors/base' @@ -33,66 +34,49 @@ describe('createOpenApiClient error mapping', () => { await stub?.stop() }) - it('recovers Dify message + hint from a top-level 4xx envelope', async () => { - stub = await startStubServer(cap => jsonResponder(403, { message: 'no access', hint: 'ask an admin' }, cap)) + async function classifiedError(status: number, body: unknown): Promise { + stub = await startStubServer(cap => jsonResponder(status, body, cap)) const orpc = orpcClient(stub.url) - const caught = await catchErr(() => orpc.account.get()) + if (!isHttpClientError(caught)) + throw new Error(`expected HttpClientError, got: ${String(caught)}`) + return caught + } - expect(isHttpClientError(caught)).toBe(true) - if (isHttpClientError(caught)) { - expect(caught.code).toBe(ErrorCode.Server4xxOther) - expect(caught.httpStatus).toBe(403) - expect(caught.message).toBe('no access') - expect(caught.hint).toBe('ask an admin') - // Parity with the transport path: the migrated endpoint's error keeps the request - // method/url and the raw body, so formatted errors still print the `request:` line - // and the raw-response dump (not just message/hint). - expect(caught.method).toBe('GET') - expect(caught.url).toContain('/account') - expect(caught.rawResponse).toContain('no access') - } + it('recovers Dify message from a canonical ErrorBody 4xx response', async () => { + const caught = await classifiedError(403, { code: 'access_denied', message: 'no access', status: 403 }) + + expect(caught.code).toBe(ErrorCode.Server4xxOther) + expect(caught.httpStatus).toBe(403) + expect(caught.message).toBe('no access') + // Parity with the transport path: the migrated endpoint's error keeps the request + // method/url and the raw body, so formatted errors still print the `request:` line + // and the raw-response dump (not just message/hint). + expect(caught.method).toBe('GET') + expect(caught.url).toContain('/account') + expect(caught.rawResponse).toContain('no access') }) - it('recovers from a nested { error: { message, hint } } envelope and keeps the auth code on 401', async () => { - stub = await startStubServer(cap => jsonResponder(401, { error: { message: 'expired', hint: 'relogin' } }, cap)) - const orpc = orpcClient(stub.url) + it('reads server message from canonical ErrorBody on 401 and keeps the auth code', async () => { + const caught = await classifiedError(401, { code: 'unauthorized', message: 'expired', status: 401 }) - const caught = await catchErr(() => orpc.account.get()) - - expect(isHttpClientError(caught)).toBe(true) - if (isHttpClientError(caught)) { - expect(caught.code).toBe(ErrorCode.AuthExpired) - expect(caught.httpStatus).toBe(401) - expect(caught.message).toBe('expired') - expect(caught.hint).toBe('relogin') - } + expect(caught.code).toBe(ErrorCode.AuthExpired) + expect(caught.httpStatus).toBe(401) + expect(caught.message).toBe('expired') }) - it('falls back to the default auth-login hint when the body carries none', async () => { - stub = await startStubServer(cap => jsonResponder(401, { error: 'expired' }, cap)) - const orpc = orpcClient(stub.url) + it('uses CLI default auth-login hint for non-conforming 401 body', async () => { + const caught = await classifiedError(401, { error: 'expired' }) - const caught = await catchErr(() => orpc.account.get()) - - expect(isHttpClientError(caught)).toBe(true) - if (isHttpClientError(caught)) { - expect(caught.code).toBe(ErrorCode.AuthExpired) - expect(caught.hint).toContain('difyctl auth login') - } + expect(caught.code).toBe(ErrorCode.AuthExpired) + expect(caught.hint).toContain('difyctl auth login') }) - it('maps 5xx to Server5xx', async () => { - stub = await startStubServer(cap => jsonResponder(503, { message: 'down for maintenance' }, cap)) - const orpc = orpcClient(stub.url) + it('maps 5xx to Server5xx with message from canonical ErrorBody', async () => { + const caught = await classifiedError(503, { code: 'service_unavailable', message: 'down for maintenance', status: 503 }) - const caught = await catchErr(() => orpc.account.get()) - - expect(isHttpClientError(caught)).toBe(true) - if (isHttpClientError(caught)) { - expect(caught.code).toBe(ErrorCode.Server5xx) - expect(caught.httpStatus).toBe(503) - expect(caught.message).toBe('down for maintenance') - } + expect(caught.code).toBe(ErrorCode.Server5xx) + expect(caught.httpStatus).toBe(503) + expect(caught.message).toBe('down for maintenance') }) }) diff --git a/cli/src/http/rate-limit.test.ts b/cli/src/http/rate-limit.test.ts new file mode 100644 index 00000000000..5b81bb679ae --- /dev/null +++ b/cli/src/http/rate-limit.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { + classifyRateLimit, + MAX_HONORED_WAIT_MS, + parseRetryAfterMs, + RATE_LIMIT_MAX_ATTEMPTS, + rateLimitDelayMs, +} from './rate-limit.js' + +function res429(body: unknown, headers?: Record): Response { + return new Response(typeof body === 'string' ? body : JSON.stringify(body), { + status: 429, + headers: { 'content-type': 'application/json', ...headers }, + }) +} + +function headers(init?: Record): Headers { + return new Headers(init) +} + +describe('classifyRateLimit', () => { + it('throttle (code too_many_requests) is retryable and reads the Retry-After header', async () => { + const d = await classifyRateLimit(res429({ code: 'too_many_requests', status: 429 }, { 'retry-after': '2' })) + expect(d).toEqual({ retryable: true, retryAfterMs: 2000 }) + }) + + it('throttle without Retry-After is retryable with no advised wait', async () => { + const d = await classifyRateLimit(res429({ code: 'too_many_requests', status: 429 })) + expect(d).toEqual({ retryable: true, retryAfterMs: undefined }) + }) + + it('quota (code rate_limit_error) is not retryable', async () => { + const d = await classifyRateLimit(res429({ code: 'rate_limit_error', status: 429 }, { 'retry-after': '5' })) + expect(d.retryable).toBe(false) + expect(d.retryAfterMs).toBeUndefined() + }) + + it.each([ + ['unknown code', { code: 'mystery' }], + ['no code', { message: 'nope' }], + ['non-JSON body', 'not json'], + ])('unrecognized 429 (%s) is conservatively non-retryable', async (_label, body) => { + const d = await classifyRateLimit(res429(body)) + expect(d.retryable).toBe(false) + }) + + it('reads the body off a clone (response stays consumable)', async () => { + const r = res429({ code: 'too_many_requests', status: 429 }) + await classifyRateLimit(r) + await expect(r.text()).resolves.toContain('too_many_requests') + }) +}) + +describe('parseRetryAfterMs', () => { + it('reads integer-seconds Retry-After as ms', () => { + expect(parseRetryAfterMs(headers({ 'retry-after': '3' }))).toBe(3000) + }) + + it('reads an HTTP-date relative to the injected now, clamped at 0', () => { + const now = Date.parse('2026-06-11T00:00:00Z') + expect(parseRetryAfterMs(headers({ 'retry-after': 'Thu, 11 Jun 2026 00:00:05 GMT' }), now)).toBe(5000) + expect(parseRetryAfterMs(headers({ 'retry-after': 'Thu, 11 Jun 2026 00:00:00 GMT' }), now + 10_000)).toBe(0) + }) + + it('returns undefined when absent or unparseable', () => { + expect(parseRetryAfterMs(headers())).toBeUndefined() + expect(parseRetryAfterMs(headers({ 'retry-after': 'soon' }))).toBeUndefined() + }) +}) + +describe('rateLimitDelayMs', () => { + it('returns the advised wait as-is (the caller already declined over-cap waits)', () => { + expect(rateLimitDelayMs({ retryAfterMs: 800 }, 1)).toBe(800) + expect(rateLimitDelayMs({ retryAfterMs: 5000 }, 1)).toBe(5000) + }) + + it('falls back to equal-jitter backoff when no wait is advised (rng pinned)', () => { + // attempt 1 => backoffDelay 300; equal jitter => [150, 300]. + expect(rateLimitDelayMs({}, 1, { rng: () => 0 })).toBe(150) + expect(rateLimitDelayMs({}, 1, { rng: () => 1 })).toBe(300) + }) + + it('returns 0 when there is neither an advised wait nor any backoff (attempt 0)', () => { + expect(rateLimitDelayMs({}, 0, { rng: () => 0.5 })).toBe(0) + }) + + it('exposes sane retry constants', () => { + expect(MAX_HONORED_WAIT_MS).toBe(60_000) + expect(RATE_LIMIT_MAX_ATTEMPTS).toBe(3) + }) +}) diff --git a/cli/src/http/rate-limit.ts b/cli/src/http/rate-limit.ts new file mode 100644 index 00000000000..15115180e69 --- /dev/null +++ b/cli/src/http/rate-limit.ts @@ -0,0 +1,90 @@ +import { backoffDelay } from './retry.js' + +// Stateless handling for the server's 429s: react when one arrives, never predict or store limits. +// The server is self-describing via the unified ErrorBody `code`: +// "too_many_requests" → throttle, waiting helps (retryable) +// "rate_limit_error" → quota, waiting within the window does not (not retryable) +// anything else / unparsable → conservative: not retryable. + +export type RateLimitDecision = { + readonly retryable: boolean + // The advised wait, from the Retry-After header (only meaningful for a retryable throttle). + readonly retryAfterMs?: number +} + +// The longest server-advised wait we'll honor by retrying. If Retry-After is larger, the 429 +// branch surfaces immediately instead of parking the process for minutes (better to let the +// caller decide than to sleep through several capped retries that will just 429 again). +export const MAX_HONORED_WAIT_MS = 60_000 + +export const RATE_LIMIT_MAX_ATTEMPTS = 3 + +function bodyCode(raw: string): string | undefined { + try { + const parsed = JSON.parse(raw) as unknown + if (typeof parsed === 'object' && parsed !== null) { + const code = (parsed as Record).code + return typeof code === 'string' ? code : undefined + } + } + catch { + // not JSON + } + return undefined +} + +// Read a 429 response into a retry decision. Reads the ErrorBody `code` for retryability and +// the Retry-After header for the wait; both off a clone so the body stays consumable downstream. +export async function classifyRateLimit(response: Response): Promise { + let raw = '' + try { + raw = await response.clone().text() + } + catch { + // ignore read errors; raw stays '' + } + const retryable = bodyCode(raw) === 'too_many_requests' + return { retryable, retryAfterMs: retryable ? parseRetryAfterMs(response.headers) : undefined } +} + +// Parse the Retry-After header to ms: integer seconds, or an HTTP-date relative to `now` +// (injectable for deterministic tests). The unified ErrorBody carries no wait field of its own. +export function parseRetryAfterMs(headers: Headers, now: number = Date.now()): number | undefined { + const header = headers.get('retry-after') + if (header === null) { + return undefined + } + const trimmed = header.trim() + if (/^\d+$/.test(trimmed)) { + return Number(trimmed) * 1000 + } + const dateMs = Date.parse(trimmed) + if (!Number.isNaN(dateMs)) { + return Math.max(0, dateMs - now) + } + return undefined +} + +// Equal-jitter backoff around the exponential base: half fixed + half random. Avoids both the +// thundering-herd of a fixed delay and the near-zero spikes of full jitter. +function jitter(baseMs: number, rng: () => number): number { + if (baseMs <= 0) { + return 0 + } + const half = baseMs / 2 + return Math.round(half + rng() * half) +} + +// How long to wait before the next 429 retry: a known server wait takes precedence (the caller +// has already declined to retry waits beyond MAX_HONORED_WAIT_MS), otherwise jittered exponential +// backoff for sources that advise none (e.g. app concurrency). +export function rateLimitDelayMs( + decision: Pick, + attempt: number, + opts: { rng?: () => number } = {}, +): number { + if (decision.retryAfterMs !== undefined) { + return decision.retryAfterMs + } + return jitter(backoffDelay(attempt), opts.rng ?? Math.random) +} diff --git a/cli/src/http/retry.test.ts b/cli/src/http/retry.test.ts index 83e4d4c9965..25d646facf5 100644 --- a/cli/src/http/retry.test.ts +++ b/cli/src/http/retry.test.ts @@ -1,6 +1,6 @@ import type { FetchContext, HttpMethod, ResolvedOptions } from './types.js' import { describe, expect, it } from 'vitest' -import { backoffDelay, shouldRetry } from './retry.js' +import { backoffDelay, isIdempotentRetryMethod, shouldRetry } from './retry.js' function ctxFor(method: HttpMethod): FetchContext { const options: ResolvedOptions = { @@ -10,6 +10,7 @@ function ctxFor(method: HttpMethod): FetchContext { timeoutMs: undefined, retryAttempts: 0, throwOnError: true, + retryOnRateLimit: false, } return { request: new Request('https://x/y', { method }), @@ -30,6 +31,11 @@ describe('shouldRetry', () => { expect(shouldRetry(res, ctxFor('GET'))).toBe(false) }) + it('no longer retries 429 here (it has a dedicated branch in execute())', () => { + const res = new Response(null, { status: 429 }) + expect(shouldRetry(res, ctxFor('GET'))).toBe(false) + }) + it('does not retry POST regardless of status', () => { const res = new Response(null, { status: 503 }) expect(shouldRetry(res, ctxFor('POST'))).toBe(false) @@ -50,6 +56,16 @@ describe('shouldRetry', () => { }) }) +describe('isIdempotentRetryMethod', () => { + it('is true for GET/PUT/DELETE and false for POST/PATCH', () => { + expect(isIdempotentRetryMethod('GET')).toBe(true) + expect(isIdempotentRetryMethod('PUT')).toBe(true) + expect(isIdempotentRetryMethod('DELETE')).toBe(true) + expect(isIdempotentRetryMethod('POST')).toBe(false) + expect(isIdempotentRetryMethod('PATCH')).toBe(false) + }) +}) + describe('backoffDelay', () => { it('returns 0 for attempts <= 0', () => { expect(backoffDelay(0)).toBe(0) diff --git a/cli/src/http/retry.ts b/cli/src/http/retry.ts index 456bf321669..6e663ba74ef 100644 --- a/cli/src/http/retry.ts +++ b/cli/src/http/retry.ts @@ -1,11 +1,19 @@ import type { FetchContext } from './types.js' export const RETRY_METHODS = ['GET', 'PUT', 'DELETE'] as const -export const RETRY_STATUS_CODES = [408, 413, 429, 500, 502, 503, 504] as const +// 429 is intentionally absent — it has a dedicated branch in execute(). shouldRetry covers +// transport errors / 408 / 413 / 5xx only. +export const RETRY_STATUS_CODES = [408, 413, 500, 502, 503, 504] as const const RETRY_METHODS_SET: ReadonlySet = new Set(RETRY_METHODS) const RETRY_STATUS_SET: ReadonlySet = new Set(RETRY_STATUS_CODES) +// GET/PUT/DELETE are idempotent — safe to auto-retry. The 429 branch reuses this to decide which +// methods may wait-and-retry a throttle without risking a double-run. +export function isIdempotentRetryMethod(method: string): boolean { + return RETRY_METHODS_SET.has(method) +} + export function shouldRetry(target: Response | unknown, ctx: FetchContext): boolean { if (!RETRY_METHODS_SET.has(ctx.options.method)) return false diff --git a/cli/src/http/types.ts b/cli/src/http/types.ts index e06bbfc9fa6..d209e97460c 100644 --- a/cli/src/http/types.ts +++ b/cli/src/http/types.ts @@ -7,6 +7,8 @@ export type HttpLogEvent = { readonly status?: number readonly attempt?: number readonly durationMs?: number + // Set on a 429 retry decision so --verbose can explain how long we waited. + readonly delayMs?: number } export type HttpLogger = (event: HttpLogEvent) => void @@ -51,6 +53,8 @@ export type RequestOptions = { readonly retryAttempts?: number readonly signal?: AbortSignal readonly throwOnError?: boolean + // Opt a non-idempotent POST into bounded wait-and-retry on a 429 throttle. + readonly retryOnRateLimit?: boolean } export type ResolvedOptions = { @@ -60,6 +64,7 @@ export type ResolvedOptions = { readonly timeoutMs: number | undefined readonly retryAttempts: number readonly throwOnError: boolean + readonly retryOnRateLimit: boolean } export type ClientOptions = { diff --git a/cli/src/store/config-writer.test.ts b/cli/src/store/config-writer.test.ts index 69a3300b281..2c7a8e2d9ef 100644 --- a/cli/src/store/config-writer.test.ts +++ b/cli/src/store/config-writer.test.ts @@ -1,57 +1,39 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { useTempConfigDir } from '@test/fixtures/config-dir' +import { describe, expect, it } from 'vitest' import { loadConfig } from '@/config/config-loader' import { emptyConfig } from '@/config/schema' import { saveConfig } from './config-writer' -import { ENV_CONFIG_DIR } from './dir' import { getConfigurationStore } from './manager' describe('saveConfig', () => { - let dir: string - let prevConfigDir: string | undefined + useTempConfigDir('difyctl-w-') - beforeEach(async () => { - dir = await mkdtemp(join(tmpdir(), 'difyctl-w-')) - prevConfigDir = process.env[ENV_CONFIG_DIR] - process.env[ENV_CONFIG_DIR] = dir - }) - - afterEach(async () => { - if (prevConfigDir === undefined) - delete process.env[ENV_CONFIG_DIR] - else - process.env[ENV_CONFIG_DIR] = prevConfigDir - await rm(dir, { recursive: true, force: true }) - }) - - it('stamps schema_version=1 even if caller passed 0', () => { - saveConfig(getConfigurationStore(), { ...emptyConfig() }) - const r = loadConfig(getConfigurationStore()) + it('stamps schema_version=1 even if caller passed 0', async () => { + await saveConfig(getConfigurationStore(), { ...emptyConfig() }) + const r = await loadConfig(getConfigurationStore()) expect(r.found).toBe(true) if (r.found) expect(r.config.schema_version).toBe(1) }) - it('overrides a stale schema_version on save', () => { - saveConfig(getConfigurationStore(), { + it('overrides a stale schema_version on save', async () => { + await saveConfig(getConfigurationStore(), { ...emptyConfig(), schema_version: 999 as never, }) - const r = loadConfig(getConfigurationStore()) + const r = await loadConfig(getConfigurationStore()) expect(r.found).toBe(true) if (r.found) expect(r.config.schema_version).toBe(1) }) - it('round-trips defaults + state', () => { - saveConfig(getConfigurationStore(), { + it('round-trips defaults + state', async () => { + await saveConfig(getConfigurationStore(), { schema_version: 1, defaults: { format: 'wide', limit: 75 }, state: { current_app: 'app-xyz' }, }) - const r = loadConfig(getConfigurationStore()) + const r = await loadConfig(getConfigurationStore()) expect(r.found).toBe(true) if (r.found) { expect(r.config.defaults.format).toBe('wide') @@ -60,18 +42,18 @@ describe('saveConfig', () => { } }) - it('overwrites the previous config on resave', () => { - saveConfig(getConfigurationStore(), { + it('overwrites the previous config on resave', async () => { + await saveConfig(getConfigurationStore(), { schema_version: 1, defaults: { format: 'json' }, state: {}, }) - saveConfig(getConfigurationStore(), { + await saveConfig(getConfigurationStore(), { schema_version: 1, defaults: { format: 'table' }, state: { current_app: 'app-2' }, }) - const r = loadConfig(getConfigurationStore()) + const r = await loadConfig(getConfigurationStore()) expect(r.found).toBe(true) if (r.found) { expect(r.config.defaults.format).toBe('table') diff --git a/cli/src/store/config-writer.ts b/cli/src/store/config-writer.ts index 483cabcb0c1..fd86f597148 100644 --- a/cli/src/store/config-writer.ts +++ b/cli/src/store/config-writer.ts @@ -2,7 +2,7 @@ import type { YamlStore } from './store' import type { ConfigFile } from '@/config/schema' import { CURRENT_SCHEMA_VERSION } from '@/config/schema' -export function saveConfig(store: YamlStore, config: ConfigFile): void { +export async function saveConfig(store: YamlStore, config: ConfigFile): Promise { const stamped: ConfigFile = { ...config, schema_version: CURRENT_SCHEMA_VERSION } - store.setTyped(stamped) + await store.setTyped(stamped) } diff --git a/cli/src/store/keychain-token-store.test.ts b/cli/src/store/keychain-token-store.test.ts new file mode 100644 index 00000000000..57f4b099dfb --- /dev/null +++ b/cli/src/store/keychain-token-store.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { BaseError } from '@/errors/base' +import { ErrorCode } from '@/errors/codes' + +type EntryArgs = { service: string, username: string } + +const passwords = new Map() +const constructed: EntryArgs[] = [] +let getPasswordError: Error | null = null +let setPasswordError: Error | null = null + +class FakeEntry { + private readonly key: string + constructor(service: string, username: string) { + constructed.push({ service, username }) + this.key = `${service}::${username}` + } + + setPassword(value: string): void { + if (setPasswordError !== null) + throw setPasswordError + passwords.set(this.key, value) + } + + getPassword(): string | null { + if (getPasswordError !== null) + throw getPasswordError + return passwords.get(this.key) ?? null + } + + deletePassword(): boolean { + if (!passwords.has(this.key)) + return false + passwords.delete(this.key) + return true + } +} + +vi.mock('@napi-rs/keyring', () => ({ + AsyncEntry: FakeEntry, +})) + +const { KeychainTokenStore } = await import('./token-store') + +const SERVICE = 'difyctl-test' + +beforeEach(() => { + passwords.clear() + constructed.length = 0 + getPasswordError = null + setPasswordError = null +}) + +describe('KeychainTokenStore', () => { + it('round-trips a bearer through write/read', async () => { + const store = new KeychainTokenStore(SERVICE) + await store.write('https://cloud.dify.ai', 'a@x.com', 'dfoa_secret') + expect(await store.read('https://cloud.dify.ai', 'a@x.com')).toBe('dfoa_secret') + }) + + it('returns empty string for an absent credential', async () => { + const store = new KeychainTokenStore(SERVICE) + expect(await store.read('https://cloud.dify.ai', 'missing@x.com')).toBe('') + }) + + it('removes a credential, after which read returns empty string', async () => { + const store = new KeychainTokenStore(SERVICE) + await store.write('h', 'e', 'dfoa_secret') + await store.remove('h', 'e') + expect(await store.read('h', 'e')).toBe('') + }) + + it('treats remove of an absent credential as a no-op', async () => { + const store = new KeychainTokenStore(SERVICE) + await expect(store.remove('h', 'absent')).resolves.not.toThrow() + }) + + it('uses the legacy entry name tokens.. (back-compat)', async () => { + const store = new KeychainTokenStore(SERVICE) + await store.write('https://cloud.dify.ai', 'a@x.com', 'dfoa_secret') + expect(constructed).toContainEqual({ + service: SERVICE, + username: 'tokens.https://cloud.dify.ai.a@x.com', + }) + }) + + it('keeps host and email literal — dots, colons, and @ are never split', async () => { + const store = new KeychainTokenStore(SERVICE) + const host = 'https://my.dify.example.com:8443' + const email = 'first.last@sub.example.com' + await store.write(host, email, 'dfoa_literal') + expect(await store.read(host, email)).toBe('dfoa_literal') + expect(constructed).toContainEqual({ + service: SERVICE, + username: `tokens.${host}.${email}`, + }) + }) + + it('returns empty string when the stored value decodes to a non-string', async () => { + const store = new KeychainTokenStore(SERVICE) + passwords.set(`${SERVICE}::tokens.h.e`, '123') + expect(await store.read('h', 'e')).toBe('') + }) + + it('returns empty string when the stored value is not valid JSON', async () => { + const store = new KeychainTokenStore(SERVICE) + passwords.set(`${SERVICE}::tokens.h.e`, 'not-json') + expect(await store.read('h', 'e')).toBe('') + }) + + it('throws KeyringUnavailable (not empty string) when keyring access fails on read', async () => { + getPasswordError = new Error('keyring locked') + const store = new KeychainTokenStore(SERVICE) + let caught: unknown + try { + await store.read('h', 'e') + } + catch (err) { + caught = err + } + expect(caught).toBeInstanceOf(BaseError) + expect((caught as BaseError).code).toBe(ErrorCode.KeyringUnavailable) + }) + + it('throws KeyringUnavailable when keyring access fails on write', async () => { + setPasswordError = new Error('keyring locked') + const store = new KeychainTokenStore(SERVICE) + let caught: unknown + try { + await store.write('h', 'e', 'dfoa_secret') + } + catch (err) { + caught = err + } + expect(caught).toBeInstanceOf(BaseError) + expect((caught as BaseError).code).toBe(ErrorCode.KeyringUnavailable) + }) +}) diff --git a/cli/src/store/keyring-based-store.test.ts b/cli/src/store/keyring-based-store.test.ts index 92adaface94..58502a3bb24 100644 --- a/cli/src/store/keyring-based-store.test.ts +++ b/cli/src/store/keyring-based-store.test.ts @@ -11,17 +11,17 @@ class FakeEntry { this.key = `${service}::${username}` } - setPassword(value: string): void { + async setPassword(value: string): Promise { setPassword(this.key, value) passwords.set(this.key, value) } - getPassword(): string | null { + async getPassword(): Promise { getPassword(this.key) - return passwords.get(this.key) ?? null + return passwords.get(this.key) ?? undefined } - deletePassword(): boolean { + async deletePassword(): Promise { deletePassword(this.key) if (!passwords.has(this.key)) return false @@ -31,7 +31,7 @@ class FakeEntry { } vi.mock('@napi-rs/keyring', () => ({ - Entry: FakeEntry, + AsyncEntry: FakeEntry, })) const { KeyringBasedStore } = await import('./store') @@ -46,64 +46,64 @@ beforeEach(() => { }) describe('KeyringBasedStore', () => { - it('returns default when entry missing', () => { + it('returns default when entry missing', async () => { const s = new KeyringBasedStore(SERVICE) - expect(s.get({ key: 'k', default: 'fallback' })).toBe('fallback') + expect(await s.get({ key: 'k', default: 'fallback' })).toBe('fallback') }) - it('round-trips strings via JSON encoding', () => { + it('round-trips strings via JSON encoding', async () => { const s = new KeyringBasedStore(SERVICE) - s.set({ key: 'k', default: '' }, 'tok-abc') - expect(s.get({ key: 'k', default: '' })).toBe('tok-abc') + await s.set({ key: 'k', default: '' }, 'tok-abc') + expect(await s.get({ key: 'k', default: '' })).toBe('tok-abc') }) - it('isolates entries by key', () => { + it('isolates entries by key', async () => { const s = new KeyringBasedStore(SERVICE) - s.set({ key: 'a', default: '' }, 'A') - s.set({ key: 'b', default: '' }, 'B') - expect(s.get({ key: 'a', default: '' })).toBe('A') - expect(s.get({ key: 'b', default: '' })).toBe('B') + await s.set({ key: 'a', default: '' }, 'A') + await s.set({ key: 'b', default: '' }, 'B') + expect(await s.get({ key: 'a', default: '' })).toBe('A') + expect(await s.get({ key: 'b', default: '' })).toBe('B') }) - it('unset removes the entry', () => { + it('unset removes the entry', async () => { const s = new KeyringBasedStore(SERVICE) - s.set({ key: 'k', default: '' }, 'v') - s.unset({ key: 'k', default: '' }) - expect(s.get({ key: 'k', default: '' })).toBe('') + await s.set({ key: 'k', default: '' }, 'v') + await s.unset({ key: 'k', default: '' }) + expect(await s.get({ key: 'k', default: '' })).toBe('') }) - it('unset is a no-op when entry missing', () => { + it('unset is a no-op when entry missing', async () => { const s = new KeyringBasedStore(SERVICE) - expect(() => s.unset({ key: 'gone', default: '' })).not.toThrow() + await expect(s.unset({ key: 'gone', default: '' })).resolves.not.toThrow() }) - it('swallows getPassword exceptions and returns default', () => { + it('swallows getPassword exceptions and returns default', async () => { const s = new KeyringBasedStore(SERVICE) getPassword.mockImplementationOnce( () => { throw new Error('NoEntry') }, ) - expect(s.get({ key: 'k', default: 'd' })).toBe('d') + expect(await s.get({ key: 'k', default: 'd' })).toBe('d') }) - it('swallows unset exceptions', () => { + it('swallows unset exceptions', async () => { const s = new KeyringBasedStore(SERVICE) deletePassword.mockImplementationOnce( () => { throw new Error('NoEntry') }, ) - expect(() => s.unset({ key: 'k', default: '' })).not.toThrow() + await expect(s.unset({ key: 'k', default: '' })).resolves.not.toThrow() }) - it('lets set propagate exceptions (caller decides fallback)', () => { + it('lets set propagate exceptions (caller decides fallback)', async () => { const s = new KeyringBasedStore(SERVICE) setPassword.mockImplementationOnce( () => { throw new Error('keyring locked') }, ) - expect(() => s.set({ key: 'k', default: '' }, 'v')).toThrow(/keyring locked/) + await expect(s.set({ key: 'k', default: '' }, 'v')).rejects.toThrow(/keyring locked/) }) }) diff --git a/cli/src/store/manager.test.ts b/cli/src/store/manager.test.ts index 1e83c026f24..9da86cda631 100644 --- a/cli/src/store/manager.test.ts +++ b/cli/src/store/manager.test.ts @@ -1,63 +1,62 @@ -import type { Key, Store } from './store' +import type { TokenStore } from './token-store' import { describe, expect, it, vi } from 'vitest' -import { getTokenStore } from './manager' +import { detectTokenStore, getTokenStore } from './manager' -function memStore(label: string): Store & { _label: string } { - const map = new Map() +function memStore(label: string): TokenStore & { _label: string } { + const map = new Map() + const k = (h: string, e: string): string => `${h} ${e}` return { _label: label, - get(key: Key): T { - return (map.get(key.key) as T | undefined) ?? key.default + async read(host: string, email: string): Promise { + return map.get(k(host, email)) ?? '' }, - set(key: Key, value: T): void { - map.set(key.key, value) + async write(host: string, email: string, bearer: string): Promise { + map.set(k(host, email), bearer) }, - unset(key: Key): void { - map.delete(key.key) + async remove(host: string, email: string): Promise { + map.delete(k(host, email)) }, } } -describe('getTokenStore', () => { - it('returns keychain store when probe succeeds', () => { +describe('detectTokenStore', () => { + it('returns keychain store when probe succeeds', async () => { const k = memStore('keyring') const f = memStore('file') - const result = getTokenStore({ + const result = await detectTokenStore({ factory: { keyring: () => k, file: () => f }, }) expect(result.mode).toBe('keychain') expect(result.store).toBe(k) }) - it('falls back to file when keyring set throws', () => { + it('falls back to file when keyring set throws', async () => { const k = memStore('keyring') const f = memStore('file') - k.set = vi.fn( - () => { - throw new Error('locked') - }, - ) - const result = getTokenStore({ + k.write = vi.fn(() => { + throw new Error('locked') + }) as TokenStore['write'] + const result = await detectTokenStore({ factory: { keyring: () => k, file: () => f }, }) expect(result.mode).toBe('file') expect(result.store).toBe(f) }) - it('falls back to file when probe round-trip mismatches', () => { + it('falls back to file when probe round-trip mismatches', async () => { const k = memStore('keyring') const f = memStore('file') - k.get = vi.fn(() => 'something-else') as Store['get'] - const result = getTokenStore({ + k.read = vi.fn(async () => 'something-else') as TokenStore['read'] + const result = await detectTokenStore({ factory: { keyring: () => k, file: () => f }, }) expect(result.mode).toBe('file') expect(result.store).toBe(f) }) - it('falls back to file when keyring constructor throws', () => { + it('falls back to file when keyring constructor throws', async () => { const f = memStore('file') - const result = getTokenStore({ + const result = await detectTokenStore({ factory: { keyring: () => { throw new Error('no backend') }, file: () => f, @@ -67,12 +66,51 @@ describe('getTokenStore', () => { expect(result.store).toBe(f) }) - it('cleans up probe entry after successful probe', () => { + it('cleans up probe entry after successful probe', async () => { const k = memStore('keyring') const f = memStore('file') - getTokenStore({ + await detectTokenStore({ factory: { keyring: () => k, file: () => f }, }) - expect(k.get({ key: '__difyctl_probe__', default: '' })).toBe('') + expect(await k.read('__difyctl_probe__', '__difyctl_probe__')).toBe('') + }) + + it('removes the probe entry even when the probe read throws', async () => { + const k = memStore('keyring') + const f = memStore('file') + const removeSpy = vi.spyOn(k, 'remove') + k.read = vi.fn(() => { + throw new Error('read boom') + }) as TokenStore['read'] + const result = await detectTokenStore({ + factory: { keyring: () => k, file: () => f }, + }) + expect(removeSpy).toHaveBeenCalledWith('__difyctl_probe__', '__difyctl_probe__') + expect(result.mode).toBe('file') + expect(result.store).toBe(f) + }) +}) + +describe('getTokenStore', () => { + it('constructs the keychain backend without probing when mode is keychain', () => { + const k = memStore('keyring') + const f = memStore('file') + k.write = vi.fn(() => { + throw new Error('probe must never run on the read path') + }) as TokenStore['write'] + const store = getTokenStore('keychain', { + factory: { keyring: () => k, file: () => f }, + }) + expect(store).toBe(k) + }) + + it('constructs the file backend when mode is file, never touching the keyring', () => { + const keyringFactory = vi.fn(() => memStore('keyring')) + const f = memStore('file') + const store = getTokenStore('file', { + factory: { keyring: keyringFactory, file: () => f }, + }) + expect(store).toBe(f) + expect(keyringFactory).not.toHaveBeenCalled() }) }) diff --git a/cli/src/store/manager.ts b/cli/src/store/manager.ts index dfd5288e11e..37962681cec 100644 --- a/cli/src/store/manager.ts +++ b/cli/src/store/manager.ts @@ -1,7 +1,9 @@ -import type { Key, StorageMode, Store } from './store' +import type { StorageMode, Store } from './store' +import type { TokenStore } from './token-store' import { join } from 'node:path' import { resolveCacheDir, resolveConfigDir } from './dir' -import { KeyringBasedStore, YamlStore } from './store' +import { YamlStore } from './store' +import { FileTokenStore, KeychainTokenStore } from './token-store' export const CACHE_APP_INFO = 'app-info' export const CACHE_NUDGE = 'nudge' @@ -31,51 +33,52 @@ export function getHostStore(): YamlStore { return getStore(join(resolveConfigDir(), HOSTS_FILE)) } -const PROBE_KEY: Key = { key: '__difyctl_probe__', default: '' } +const PROBE_HOST = '__difyctl_probe__' +const PROBE_EMAIL = '__difyctl_probe__' const PROBE_VALUE = 'probe-v1' export type GetTokenStoreOptions = { readonly factory?: { - readonly keyring?: () => Store - readonly file?: () => Store + readonly keyring?: () => TokenStore + readonly file?: () => TokenStore } } +const TOKEN_STORE_OPENERS: Record TokenStore> = { + file: opts => opts.factory?.file?.() ?? new FileTokenStore(join(resolveConfigDir(), TOKENS_FILE)), + keychain: opts => opts.factory?.keyring?.() ?? new KeychainTokenStore(KEYRING_SERVICE), +} + /** - * Single entry point for the credential store. Probes the OS keyring; if it - * round-trips a value, returns the keychain-backed store. Otherwise falls - * back to the YAML file at `/tokens.yml`. Both implementations - * satisfy the `Store` interface, so callers interact uniformly. - * - * Business logic should always obtain the token store through this factory - * rather than constructing one directly. + * Decide which credential backend to use by probing the OS keyring with a + * write/read/remove round-trip. The probe MUTATES the keyring, so call this + * only where a credential is about to be written anyway (login). */ -export function getTokenStore(opts: GetTokenStoreOptions = {}): { store: Store, mode: StorageMode } { - const fileFactory = opts.factory?.file ?? (() => getStore(join(resolveConfigDir(), TOKENS_FILE))) - const keyringFactory = opts.factory?.keyring ?? (() => new KeyringBasedStore(KEYRING_SERVICE)) +export async function detectTokenStore(opts: GetTokenStoreOptions = {}): Promise<{ store: TokenStore, mode: StorageMode }> { // DIFY_E2E_NO_KEYRING=1 forces file-based storage in E2E tests to avoid // macOS keychain UI prompts blocking child processes spawned by vitest. if (process.env.DIFY_E2E_NO_KEYRING === '1') - return { store: fileFactory(), mode: 'file' } + return { store: TOKEN_STORE_OPENERS.file(opts), mode: 'file' } try { - const k = keyringFactory() - k.set(PROBE_KEY, PROBE_VALUE) - const got = k.get(PROBE_KEY) - k.unset(PROBE_KEY) - if (got !== PROBE_VALUE) - throw new Error('keyring round-trip mismatch') - return { store: k, mode: 'keychain' } - } - catch { - return { store: fileFactory(), mode: 'file' } + const k = TOKEN_STORE_OPENERS.keychain(opts) + await k.write(PROBE_HOST, PROBE_EMAIL, PROBE_VALUE) + let got = '' + try { + got = await k.read(PROBE_HOST, PROBE_EMAIL) + } + finally { + await k.remove(PROBE_HOST, PROBE_EMAIL) + } + if (got === PROBE_VALUE) + return { store: k, mode: 'keychain' } } + catch { /* keyring unavailable → fall through to file */ } + return { store: TOKEN_STORE_OPENERS.file(opts), mode: 'file' } } /** - * Maps an auth identity (host + accountId) to a `Store` key. All token store - * reads/writes in business logic go through this helper so the on-disk / - * keyring layout stays consistent. + * Construct the credential backend the registry already recorded at login. */ -export function tokenKey(host: string, accountId: string): Key { - return { key: `tokens.${host}.${accountId}`, default: '' } +export function getTokenStore(mode: StorageMode, opts: GetTokenStoreOptions = {}): TokenStore { + return TOKEN_STORE_OPENERS[mode](opts) } diff --git a/cli/src/store/store.test.ts b/cli/src/store/store.test.ts index f2694accf82..bc1d9242c7f 100644 --- a/cli/src/store/store.test.ts +++ b/cli/src/store/store.test.ts @@ -108,13 +108,13 @@ describe('FileBasedStore.withLock concurrency', () => { const s1 = new YamlStore(path) const s2 = new YamlStore(path) - s1.lock() + await s1.lock() - expect(() => s2.get({ key: 'key', default: '' })).toThrow(ConcurrentAccessError) + await expect(s2.get({ key: 'key', default: '' })).rejects.toThrow(ConcurrentAccessError) - s1.unlock() + await s1.unlock() - expect(s2.get({ key: 'key', default: '' })).toBe('value') + expect(await s2.get({ key: 'key', default: '' })).toBe('value') }) it('second set throws while first holds the lock, succeeds after release', async () => { @@ -124,14 +124,14 @@ describe('FileBasedStore.withLock concurrency', () => { const s1 = new YamlStore(path) const s2 = new YamlStore(path) - s1.lock() + await s1.lock() - expect(() => s2.set({ key: 'key', default: '' }, 'blocked')).toThrow(ConcurrentAccessError) + await expect(s2.set({ key: 'key', default: '' }, 'blocked')).rejects.toThrow(ConcurrentAccessError) - s1.unlock() + await s1.unlock() - s2.set({ key: 'key', default: '' }, 'written') - expect(s2.get({ key: 'key', default: '' })).toBe('written') + await s2.set({ key: 'key', default: '' }, 'written') + expect(await s2.get({ key: 'key', default: '' })).toBe('written') }) }) @@ -199,9 +199,9 @@ describe('YamlStore persistence', () => { await writeFile(path, 'existing: value\n') const store = new YamlStore(path) - store.load() + await store.load() store.doSet({ key: 'token', default: '' }, 'abc-123') - store.flush() + await store.flush() const raw = readFileSync(path, 'utf8') const store2 = new YamlStore(path) @@ -210,11 +210,11 @@ describe('YamlStore persistence', () => { expect(store2.doGet({ key: 'existing', default: '' })).toBe('value') }) - it('flush writes file when dirty (content changed from undefined)', () => { + it('flush writes file when dirty (content changed from undefined)', async () => { const path = join(dir, 'config.yml') const store = new YamlStore(path) store.setRawContent('key: value\n') - store.flush() + await store.flush() expect(existsSync(path)).toBe(true) expect(readFileSync(path, 'utf8')).toBe('key: value\n') }) @@ -223,10 +223,10 @@ describe('YamlStore persistence', () => { const path = join(dir, 'config.yml') await writeFile(path, 'key: value\n') const store = new YamlStore(path) - store.load() + await store.load() const mtime = statSync(path).mtimeMs store.setRawContent('key: value\n') - store.flush() + await store.flush() expect(statSync(path).mtimeMs).toBe(mtime) }) }) diff --git a/cli/src/store/store.ts b/cli/src/store/store.ts index 85b0cd7391f..758c5a5b8b8 100644 --- a/cli/src/store/store.ts +++ b/cli/src/store/store.ts @@ -1,7 +1,8 @@ +import type { Options as LockOptions } from 'lockfile' import type { Platform } from '@/sys' -import fs from 'node:fs' +import { promises as fsp } from 'node:fs' import { dirname } from 'node:path' -import { Entry } from '@napi-rs/keyring' +import { AsyncEntry } from '@napi-rs/keyring' import yaml from 'js-yaml' import lockfile from 'lockfile' import { pid, resolvePlatform } from '@/sys' @@ -9,6 +10,19 @@ import { BadYamlFormatError, ConcurrentAccessError } from './errors' const FILE_PERM = 0o600 const DIR_PERM = 0o700 +const LOCK_STALE_MS = 30_000 + +function lockAsync(path: string, opts: LockOptions): Promise { + return new Promise((resolve, reject) => { + lockfile.lock(path, opts, err => (err ? reject(err) : resolve())) + }) +} + +function unlockAsync(path: string): Promise { + return new Promise((resolve, reject) => { + lockfile.unlock(path, err => (err ? reject(err) : resolve())) + }) +} export type Key = { default: T @@ -16,12 +30,13 @@ export type Key = { } export type Store = { - get: (key: Key) => T - set: (key: Key, value: T) => void - unset: (key: Key) => void + get: (key: Key) => Promise + set: (key: Key, value: T) => Promise + unset: (key: Key) => Promise } -export type StorageMode = 'keychain' | 'file' +export const STORAGE_MODES = ['keychain', 'file'] as const +export type StorageMode = typeof STORAGE_MODES[number] abstract class FileBasedStore implements Store { filePath: string @@ -34,18 +49,18 @@ abstract class FileBasedStore implements Store { this.platform = resolvePlatform() } - private ensureDir(): void { - fs.mkdirSync(dirname(this.filePath), { recursive: true, mode: DIR_PERM }) + private async ensureDir(): Promise { + await fsp.mkdir(dirname(this.filePath), { recursive: true, mode: DIR_PERM }) } - unlock(): void { - lockfile.unlockSync(`${this.filePath}.lock`) + async unlock(): Promise { + await unlockAsync(`${this.filePath}.lock`) } /** * atomically write raw_content (if any) */ - flush(): void { + async flush(): Promise { // we don't handle A-B-A scenario, // which is not likely to happen in cli if (!this.dirty) { @@ -53,15 +68,15 @@ abstract class FileBasedStore implements Store { } if (this.rawContent !== undefined) { - this.ensureDir() + await this.ensureDir() const tmp = `${this.filePath}.tmp.${pid()}.${Date.now()}` try { - fs.writeFileSync(tmp, this.rawContent, { mode: FILE_PERM }) + await fsp.writeFile(tmp, this.rawContent, { mode: FILE_PERM }) this.platform.atomicReplace(tmp, this.filePath) } catch (err) { try { - fs.unlinkSync(tmp) + await fsp.unlink(tmp) } catch { /* tmp may not exist */ } throw err @@ -71,11 +86,11 @@ abstract class FileBasedStore implements Store { this.dirty = false } - lock(): void { - this.ensureDir() + async lock(): Promise { + await this.ensureDir() try { - lockfile.lockSync(`${this.filePath}.lock`, { - stale: 30_000, + await lockAsync(`${this.filePath}.lock`, { + stale: LOCK_STALE_MS, }) } catch (err) { @@ -87,9 +102,9 @@ abstract class FileBasedStore implements Store { } } - load(): void { + async load(): Promise { try { - this.rawContent = fs.readFileSync(this.filePath, 'utf8') + this.rawContent = await fsp.readFile(this.filePath, 'utf8') this.dirty = false } catch (err) { @@ -109,45 +124,45 @@ abstract class FileBasedStore implements Store { return this.rawContent } - protected withLock(body: () => R): R { - this.lock() + protected async withLock(body: () => R | Promise): Promise { + await this.lock() try { - return body() + return await body() } finally { - this.unlock() + await this.unlock() } } - get(key: Key): T { - return this.withLock(() => { - this.load() + async get(key: Key): Promise { + return this.withLock(async () => { + await this.load() return this.doGet(key) }) } - set(key: Key, value: T) { - this.withLock(() => { - this.load() + async set(key: Key, value: T): Promise { + await this.withLock(async () => { + await this.load() this.doSet(key, value) - this.flush() + await this.flush() }) } - unset(key: Key): void { - this.withLock(() => { - this.load() + async unset(key: Key): Promise { + await this.withLock(async () => { + await this.load() this.doUnset(key) - this.flush() + await this.flush() }) } /** * Remove the underlying file of the store. No-op if file doesn't exist. */ - rm(): void { + async rm(): Promise { try { - fs.unlinkSync(this.filePath) + await fsp.unlink(this.filePath) } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'ENOENT') @@ -177,18 +192,18 @@ export class YamlStore extends FileBasedStore { return (current as T) ?? key.default } - getTyped(): T | null { - return this.withLock(() => { - this.load() + async getTyped(): Promise { + return this.withLock(async () => { + await this.load() return loadYaml(this.getRawContent(), this.filePath) as T }) } - setTyped(data: T): void { - this.withLock(() => { - this.load() + async setTyped(data: T): Promise { + await this.withLock(async () => { + await this.load() this.setRawContent(yaml.dump(data, { lineWidth: -1, noRefs: true })) - this.flush() + await this.flush() }) } @@ -253,9 +268,9 @@ export class KeyringBasedStore implements Store { this.service = service } - get(key: Key): T { + async get(key: Key): Promise { try { - const v = new Entry(this.service, key.key).getPassword() + const v = await new AsyncEntry(this.service, key.key).getPassword() if (v === null || v === undefined || v === '') return key.default return JSON.parse(v) as T @@ -265,13 +280,13 @@ export class KeyringBasedStore implements Store { } } - set(key: Key, value: T): void { - new Entry(this.service, key.key).setPassword(JSON.stringify(value)) + async set(key: Key, value: T): Promise { + await new AsyncEntry(this.service, key.key).setPassword(JSON.stringify(value)) } - unset(key: Key): void { + async unset(key: Key): Promise { try { - new Entry(this.service, key.key).deletePassword() + await new AsyncEntry(this.service, key.key).deletePassword() } catch { /* missing entry is fine */ } } diff --git a/cli/src/store/token-store.test.ts b/cli/src/store/token-store.test.ts new file mode 100644 index 00000000000..5771e1c8412 --- /dev/null +++ b/cli/src/store/token-store.test.ts @@ -0,0 +1,81 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { FileTokenStore } from './token-store' + +describe('FileTokenStore', () => { + let dir: string + let file: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'difyctl-tok-')) + file = join(dir, 'tokens.yml') + }) + afterEach(() => rmSync(dir, { recursive: true, force: true })) + + it('returns empty string for a missing credential', async () => { + const s = new FileTokenStore(file) + expect(await s.read('https://cloud.dify.ai', 'a@x.com')).toBe('') + }) + + it('round-trips a bearer with dots and @ kept literal', async () => { + const s = new FileTokenStore(file) + await s.write('https://cloud.dify.ai', 'a.b@x.com', 'dfoa_secret') + expect(await s.read('https://cloud.dify.ai', 'a.b@x.com')).toBe('dfoa_secret') + }) + + it('keeps multiple accounts under one host and isolates hosts', async () => { + const s = new FileTokenStore(file) + await s.write('https://cloud.dify.ai', 'a@x.com', 'A') + await s.write('https://cloud.dify.ai', 'b@x.com', 'B') + await s.write('https://self.example.com', 'a@x.com', 'C') + expect(await s.read('https://cloud.dify.ai', 'a@x.com')).toBe('A') + expect(await s.read('https://cloud.dify.ai', 'b@x.com')).toBe('B') + expect(await s.read('https://self.example.com', 'a@x.com')).toBe('C') + }) + + it('persists the versioned nested shape on disk', async () => { + const s = new FileTokenStore(file) + await s.write('https://cloud.dify.ai', 'a@x.com', 'A') + const raw = readFileSync(file, 'utf8') + expect(raw).toContain('version: 1') + expect(raw).toContain('https://cloud.dify.ai') + expect(raw).toContain('a@x.com') + }) + + it('reads empty when the document version is an unknown future version', async () => { + writeFileSync(file, 'version: 999\ntokens:\n "h":\n "e": "x"\n') + const s = new FileTokenStore(file) + expect(await s.read('h', 'e')).toBe('') + }) + + it('reads tokens from legacy format (no version field) for transparent migration', async () => { + writeFileSync(file, 'tokens:\n "h":\n "e": "dfoa_legacy"\n') + const s = new FileTokenStore(file) + expect(await s.read('h', 'e')).toBe('dfoa_legacy') + }) + + it('preserves existing tokens and stamps version when writing to a legacy file', async () => { + writeFileSync(file, 'tokens:\n "h":\n "existing@x": "dfoa_existing"\n') + const s = new FileTokenStore(file) + await s.write('h', 'new@x', 'dfoa_new') + expect(await s.read('h', 'existing@x')).toBe('dfoa_existing') + expect(await s.read('h', 'new@x')).toBe('dfoa_new') + expect(readFileSync(file, 'utf8')).toContain('version: 1') + }) + + it('remove deletes the credential and prunes the empty host map', async () => { + const s = new FileTokenStore(file) + await s.write('https://cloud.dify.ai', 'a@x.com', 'A') + await s.remove('https://cloud.dify.ai', 'a@x.com') + expect(await s.read('https://cloud.dify.ai', 'a@x.com')).toBe('') + const raw = readFileSync(file, 'utf8') + expect(raw).not.toContain('cloud.dify.ai') + }) + + it('remove is a no-op for an absent credential', async () => { + const s = new FileTokenStore(file) + await expect(s.remove('h', 'e')).resolves.not.toThrow() + }) +}) diff --git a/cli/src/store/token-store.ts b/cli/src/store/token-store.ts new file mode 100644 index 00000000000..e38f2dd803f --- /dev/null +++ b/cli/src/store/token-store.ts @@ -0,0 +1,130 @@ +import { AsyncEntry } from '@napi-rs/keyring' +import { BaseError } from '@/errors/base' +import { ErrorCode } from '@/errors/codes' +import { YamlStore } from './store' + +/** + * Credential store keyed by an opaque (host, email) pair. + */ +export type TokenStore = { + read: (host: string, email: string) => Promise + write: (host: string, email: string, bearer: string) => Promise + remove: (host: string, email: string) => Promise +} + +const DOC_VERSION = 1 + +export type TokenDoc = { + version?: number + tokens?: Record> +} + +export class FileTokenStore implements TokenStore { + private readonly store: YamlStore + + constructor(filePath: string) { + this.store = new YamlStore(filePath) + } + + async read(host: string, email: string): Promise { + const doc = await this.store.getTyped() + if (doc === null) + return '' + // missing version = legacy pre-v1 format (same data shape); future unknown versions are rejected + if (doc.version !== undefined && doc.version !== DOC_VERSION) + return '' + return doc.tokens?.[host]?.[email] ?? '' + } + + async write(host: string, email: string, bearer: string): Promise { + const doc = await this.load() + const hostMap = doc.tokens[host] ?? {} + hostMap[email] = bearer + doc.tokens[host] = hostMap + await this.store.setTyped(doc) + } + + async remove(host: string, email: string): Promise { + const doc = await this.store.getTyped() + if (doc === null) + return + if (doc.version !== undefined && doc.version !== DOC_VERSION) + return + const tokens = doc.tokens ?? {} + const hostMap = tokens[host] + if (hostMap === undefined || !(email in hostMap)) + return + delete hostMap[email] + if (Object.keys(hostMap).length === 0) + delete tokens[host] + await this.store.setTyped({ version: DOC_VERSION, tokens }) + } + + private async load(): Promise<{ version: number, tokens: Record> }> { + const doc = await this.store.getTyped() + if (doc === null) + return { version: DOC_VERSION, tokens: {} } + if (doc.version !== undefined && doc.version !== DOC_VERSION) + return { version: DOC_VERSION, tokens: {} } + return { version: DOC_VERSION, tokens: (doc.tokens ?? {}) as Record> } + } +} + +/** + * One OS-keyring entry per (host, email). + */ +export class KeychainTokenStore implements TokenStore { + private readonly service: string + + constructor(service: string) { + this.service = service + } + + async read(host: string, email: string): Promise { + let raw: string | null | undefined + try { + raw = await new AsyncEntry(this.service, entryName(host, email)).getPassword() + } + catch (err) { + throw keyringUnavailableError(err) + } + if (raw === null || raw === undefined || raw === '') + return '' + try { + const parsed: unknown = JSON.parse(raw) + return typeof parsed === 'string' ? parsed : '' + } + catch { + return '' + } + } + + async write(host: string, email: string, bearer: string): Promise { + try { + await new AsyncEntry(this.service, entryName(host, email)).setPassword(JSON.stringify(bearer)) + } + catch (err) { + throw keyringUnavailableError(err) + } + } + + async remove(host: string, email: string): Promise { + try { + await new AsyncEntry(this.service, entryName(host, email)).deletePassword() + } + catch { /* missing entry is fine */ } + } +} + +function entryName(host: string, email: string): string { + return `tokens.${host}.${email}` +} + +function keyringUnavailableError(cause: unknown): BaseError { + return new BaseError({ + code: ErrorCode.KeyringUnavailable, + message: 'OS keychain is unreachable', + hint: 'credentials are stored in the system keychain but it could not be accessed; unlock the keychain (or the login session) and retry', + cause, + }) +} diff --git a/cli/src/version/info.ts b/cli/src/version/info.ts index 23b8624b342..e8d38180fca 100644 --- a/cli/src/version/info.ts +++ b/cli/src/version/info.ts @@ -1,7 +1,7 @@ import { arch, platform } from '@/sys/index' import { compatString } from './compat' -export type Channel = 'dev' | 'rc' | 'stable' +export type Channel = 'dev' | 'edge' | 'rc' | 'stable' export type VersionInfo = { version: string diff --git a/cli/src/version/probe.test.ts b/cli/src/version/probe.test.ts index 8b84d24704e..87c7e55838e 100644 --- a/cli/src/version/probe.test.ts +++ b/cli/src/version/probe.test.ts @@ -166,7 +166,7 @@ describe('runVersionProbe', () => { reg.setHost(url.host) reg.setAccount('test@dify.ai') reg.setScheme(url.host, url.protocol.replace(':', '')) - reg.save() + await reg.save() process.env[ENV_CONFIG_DIR] = configDir const report = await runVersionProbe({ skipServer: false }) diff --git a/cli/src/version/probe.ts b/cli/src/version/probe.ts index bfccaa77e47..266910e7c05 100644 --- a/cli/src/version/probe.ts +++ b/cli/src/version/probe.ts @@ -48,7 +48,7 @@ export type RunVersionProbeOptions = { } const defaultLoadActive = async (): Promise => { - return Registry.load().resolveActive() + return (await Registry.load()).resolveActive() } const defaultProbe: MetaProbe = async (endpoint) => { diff --git a/cli/src/workspace/resolver.test.ts b/cli/src/workspace/resolver.test.ts new file mode 100644 index 00000000000..daac192b82e --- /dev/null +++ b/cli/src/workspace/resolver.test.ts @@ -0,0 +1,29 @@ +import type { ActiveContext } from '@/auth/hosts' +import { describe, expect, it } from 'vitest' +import { resolveWorkspaceId } from './resolver' + +function active(workspaceId?: string): ActiveContext { + return { host: 'h', email: 'e', ctx: { account: { id: '', email: 'e', name: '' }, workspace: workspaceId ? { id: workspaceId, name: 'W', role: 'owner' } : undefined } } +} + +const UUID_FLAG = 'aaaaaaaa-0000-0000-0000-000000000001' +const UUID_ENV = 'aaaaaaaa-0000-0000-0000-000000000002' +const UUID_CTX = 'aaaaaaaa-0000-0000-0000-000000000003' + +describe('resolveWorkspaceId', () => { + it('prefers the flag', () => { + expect(resolveWorkspaceId({ flag: UUID_FLAG, env: UUID_ENV, active: active(UUID_CTX) })).toBe(UUID_FLAG) + }) + it('falls back to env over active workspace', () => { + expect(resolveWorkspaceId({ env: UUID_ENV, active: active(UUID_CTX) })).toBe(UUID_ENV) + }) + it('falls back to active workspace when no flag or env', () => { + expect(resolveWorkspaceId({ active: active(UUID_CTX) })).toBe(UUID_CTX) + }) + it('throws when active workspace ID is not a valid UUID', () => { + expect(() => resolveWorkspaceId({ active: active('ws-ctx') })).toThrow(/stored workspace ID/) + }) + it('throws when no workspace is selected (no implicit default)', () => { + expect(() => resolveWorkspaceId({ active: active(undefined) })).toThrow(/no workspace selected/) + }) +}) diff --git a/cli/src/workspace/resolver.ts b/cli/src/workspace/resolver.ts index 40af50b38bb..a5b03d7c613 100644 --- a/cli/src/workspace/resolver.ts +++ b/cli/src/workspace/resolver.ts @@ -25,14 +25,16 @@ export function resolveWorkspaceId(inputs: WorkspaceResolveInputs): string { throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: `DIFY_WORKSPACE_ID value ${JSON.stringify(inputs.env)} is not a valid UUID` }) return inputs.env } - const ctx = inputs.active?.ctx - if (ctx !== undefined) { - if (truthy(ctx.workspace?.id)) - return ctx.workspace.id - if (ctx.available_workspaces !== undefined && ctx.available_workspaces.length > 0 - && truthy(ctx.available_workspaces[0]?.id)) { - return ctx.available_workspaces[0].id + const wsId = inputs.active?.ctx.workspace?.id + if (truthy(wsId)) { + if (!isValidUuid(wsId)) { + throw new BaseError({ + code: ErrorCode.UsageInvalidFlag, + message: `stored workspace ID ${JSON.stringify(wsId)} is not a valid UUID`, + hint: 'run \'difyctl use workspace\' to update your active workspace', + }) } + return wsId } throw new BaseError({ code: ErrorCode.UsageMissingArg, diff --git a/cli/test/e2e/fixtures/apps/echo-chat.yml b/cli/test/e2e/fixtures/apps/echo-chat.yml index 6e40f2723bb..4a7539f3aef 100644 --- a/cli/test/e2e/fixtures/apps/echo-chat.yml +++ b/cli/test/e2e/fixtures/apps/echo-chat.yml @@ -6,12 +6,7 @@ app: mode: advanced-chat name: echo-bot use_icon_as_answer_icon: false -dependencies: - - current_identifier: null - type: marketplace - value: - marketplace_plugin_unique_identifier: langgenius/tongyi:0.1.48@966d88dc40611f067311c1c9839139ebc4b55bff471bc5e736dc3e828bc67b46 - version: null +dependencies: [] kind: app version: 0.6.0 workflow: @@ -68,18 +63,9 @@ workflow: edges: - data: sourceType: start - targetType: llm - id: 1779690795511-llm - source: '1779690795511' - sourceHandle: source - target: llm - targetHandle: target - type: custom - - data: - sourceType: llm targetType: answer - id: llm-answer - source: llm + id: 1779690795511-answer + source: '1779690795511' sourceHandle: source target: answer targetHandle: target @@ -87,7 +73,7 @@ workflow: nodes: - data: selected: false - title: 用户输入 + title: User Input type: start variables: - default: '' @@ -107,66 +93,24 @@ workflow: positionAbsolute: x: 79 y: 282 - selected: true - sourcePosition: right - targetPosition: left - type: custom - width: 242 - - data: - context: - enabled: false - variable_selector: [] - memory: - query_prompt_template: '{{#sys.query#}} - - {{#sys.files#}}' - role_prefix: - assistant: '' - user: '' - window: - enabled: false - size: 10 - model: - completion_params: - temperature: 0.7 - mode: chat - name: qwen3.6-plus - provider: langgenius/tongyi/tongyi - prompt_template: - - id: 9b866a63-3619-4f5c-a46f-0aed04078587 - role: system - text: 'User says: {{{#sys.query#}} Reply exactly: echo:{{#sys.query#}}' - selected: false - title: LLM - type: llm - vision: - enabled: false - height: 88 - id: llm - position: - x: 380 - y: 282 - positionAbsolute: - x: 380 - y: 282 selected: false sourcePosition: right targetPosition: left type: custom width: 242 - data: - answer: '{{#llm.text#}}' + answer: 'echo:{{#sys.query#}}' selected: false - title: 直接回复 + title: Reply type: answer variables: [] height: 103 id: answer position: - x: 680 + x: 380 y: 282 positionAbsolute: - x: 680 + x: 380 y: 282 selected: false sourcePosition: right diff --git a/cli/test/e2e/fixtures/apps/echo-workflow.yml b/cli/test/e2e/fixtures/apps/echo-workflow.yml index 22cb3cd64aa..e29e3b90fa8 100644 --- a/cli/test/e2e/fixtures/apps/echo-workflow.yml +++ b/cli/test/e2e/fixtures/apps/echo-workflow.yml @@ -6,12 +6,7 @@ app: mode: workflow name: basic_auto_test use_icon_as_answer_icon: false -dependencies: - - current_identifier: null - type: marketplace - value: - marketplace_plugin_unique_identifier: langgenius/tongyi:0.1.48@966d88dc40611f067311c1c9839139ebc4b55bff471bc5e736dc3e828bc67b46 - version: null +dependencies: [] kind: app version: 0.6.0 workflow: @@ -70,20 +65,9 @@ workflow: isInIteration: false isInLoop: false sourceType: start - targetType: llm - id: 1779097154262-source-1779097204645-target - source: '1779097154262' - sourceHandle: source - target: '1779097204645' - targetHandle: target - type: custom - zIndex: 0 - - data: - isInLoop: false - sourceType: llm targetType: end - id: 1779097204645-source-1779171097399-target - source: '1779097204645' + id: 1779097154262-source-1779171097399-target + source: '1779097154262' sourceHandle: source target: '1779171097399' targetHandle: target @@ -92,7 +76,7 @@ workflow: nodes: - data: selected: true - title: 用户输入 + title: User Input type: start variables: - default: '' @@ -143,49 +127,15 @@ workflow: targetPosition: left type: custom width: 242 - - data: - context: - enabled: false - variable_selector: [] - model: - completion_params: - temperature: 0.7 - mode: chat - name: qwen3.6-plus - provider: langgenius/tongyi/tongyi - prompt_template: - - id: 1ddb3202-d84c-4faf-afe3-424eedc9049a - role: system - text: 'User says:{{#1779097154262.x#}}. Reply exactly: echo:{{#1779097154262.x#}} - - ' - selected: false - title: LLM - type: llm - vision: - enabled: false - height: 88 - id: '1779097204645' - position: - x: 382 - y: 282 - positionAbsolute: - x: 382 - y: 282 - selected: false - sourcePosition: right - targetPosition: left - type: custom - width: 242 - data: outputs: - value_selector: - - '1779097204645' - - text + - '1779097154262' + - x value_type: string variable: x selected: false - title: 输出 + title: Output type: end height: 88 id: '1779171097399' diff --git a/cli/test/e2e/fixtures/apps/file-chat.yml b/cli/test/e2e/fixtures/apps/file-chat.yml index fe3a2df15d6..c76752f0f26 100644 --- a/cli/test/e2e/fixtures/apps/file-chat.yml +++ b/cli/test/e2e/fixtures/apps/file-chat.yml @@ -6,12 +6,7 @@ app: mode: advanced-chat name: DIFY_E2E_FILE_CHAT use_icon_as_answer_icon: false -dependencies: - - current_identifier: null - type: marketplace - value: - marketplace_plugin_unique_identifier: langgenius/tongyi:0.1.48@966d88dc40611f067311c1c9839139ebc4b55bff471bc5e736dc3e828bc67b46 - version: null +dependencies: [] kind: app version: 0.6.0 workflow: @@ -68,18 +63,9 @@ workflow: edges: - data: sourceType: start - targetType: llm - id: 1780453002656-llm - source: '1780453002656' - sourceHandle: source - target: llm - targetHandle: target - type: custom - - data: - sourceType: llm targetType: answer - id: llm-answer - source: llm + id: 1780453002656-answer + source: '1780453002656' sourceHandle: source target: answer targetHandle: target @@ -87,7 +73,7 @@ workflow: nodes: - data: selected: false - title: 用户输入 + title: User Input type: start variables: - allowed_file_extensions: [] @@ -119,60 +105,18 @@ workflow: type: custom width: 242 - data: - context: - enabled: false - variable_selector: [] - memory: - query_prompt_template: '{{#sys.query#}} - - {{#sys.files#}}' - role_prefix: - assistant: '' - user: '' - window: - enabled: false - size: 10 - model: - completion_params: - temperature: 0.7 - mode: chat - name: qwen3.6-plus - provider: langgenius/tongyi/tongyi - prompt_template: - - id: ebc516ad-be6b-4a78-af32-77f447305b34 - role: system - text: 输出固定内容:""hello + answer: ok selected: false - title: LLM - type: llm - vision: - enabled: false - height: 88 - id: llm - position: - x: 380 - y: 282 - positionAbsolute: - x: 380 - y: 282 - selected: true - sourcePosition: right - targetPosition: left - type: custom - width: 242 - - data: - answer: '{{#llm.text#}}' - selected: false - title: 直接回复 + title: Reply type: answer variables: [] height: 103 id: answer position: - x: 680 + x: 380 y: 282 positionAbsolute: - x: 680 + x: 380 y: 282 selected: false sourcePosition: right diff --git a/cli/test/e2e/fixtures/apps/file-upload.yml b/cli/test/e2e/fixtures/apps/file-upload.yml index 52f6d623a33..1730c51b7fe 100644 --- a/cli/test/e2e/fixtures/apps/file-upload.yml +++ b/cli/test/e2e/fixtures/apps/file-upload.yml @@ -6,12 +6,7 @@ app: mode: workflow name: file_auto_test use_icon_as_answer_icon: false -dependencies: - - current_identifier: null - type: marketplace - value: - marketplace_plugin_unique_identifier: langgenius/tongyi:0.1.48@966d88dc40611f067311c1c9839139ebc4b55bff471bc5e736dc3e828bc67b46 - version: null +dependencies: [] kind: app version: 0.6.0 workflow: @@ -70,21 +65,9 @@ workflow: isInIteration: false isInLoop: false sourceType: start - targetType: llm - id: 1779693724732-source-1779693759949-target - source: '1779693724732' - sourceHandle: source - target: '1779693759949' - targetHandle: target - type: custom - zIndex: 0 - - data: - isInIteration: false - isInLoop: false - sourceType: llm targetType: end - id: 1779693759949-source-1779693765299-target - source: '1779693759949' + id: 1779693724732-source-1779693765299-target + source: '1779693724732' sourceHandle: source target: '1779693765299' targetHandle: target @@ -93,7 +76,7 @@ workflow: nodes: - data: selected: true - title: 用户输入 + title: User Input type: start variables: - allowed_file_extensions: [] @@ -140,46 +123,9 @@ workflow: type: custom width: 242 - data: - context: - enabled: false - variable_selector: [] - model: - completion_params: - temperature: 0.7 - mode: chat - name: qwen3.6-plus - provider: langgenius/tongyi/tongyi - prompt_template: - - id: bb929f8f-5fa9-415b-91c3-c30228488dcf - role: system - text: 直接输出内容:hello + outputs: [] selected: false - title: LLM - type: llm - vision: - enabled: false - height: 88 - id: '1779693759949' - position: - x: 382 - y: 282 - positionAbsolute: - x: 382 - y: 282 - selected: false - sourcePosition: right - targetPosition: left - type: custom - width: 242 - - data: - outputs: - - value_selector: - - '1779693759949' - - text - value_type: string - variable: x - selected: false - title: 输出 + title: Output type: end height: 88 id: '1779693765299' diff --git a/cli/test/e2e/fixtures/apps/ws2-workflow.yml b/cli/test/e2e/fixtures/apps/ws2-workflow.yml index d8ddac836ce..92961a9900f 100644 --- a/cli/test/e2e/fixtures/apps/ws2-workflow.yml +++ b/cli/test/e2e/fixtures/apps/ws2-workflow.yml @@ -6,12 +6,7 @@ app: mode: workflow name: auto_test_workspace2 use_icon_as_answer_icon: false -dependencies: - - current_identifier: null - type: marketplace - value: - marketplace_plugin_unique_identifier: langgenius/tongyi:0.1.48@966d88dc40611f067311c1c9839139ebc4b55bff471bc5e736dc3e828bc67b46 - version: null +dependencies: [] kind: app version: 0.6.0 workflow: @@ -70,21 +65,9 @@ workflow: isInIteration: false isInLoop: false sourceType: start - targetType: llm - id: 1780305524693-source-1780305526186-target - source: '1780305524693' - sourceHandle: source - target: '1780305526186' - targetHandle: target - type: custom - zIndex: 0 - - data: - isInIteration: false - isInLoop: false - sourceType: llm targetType: end - id: 1780305526186-source-1780305600095-target - source: '1780305526186' + id: 1780305524693-source-1780305600095-target + source: '1780305524693' sourceHandle: source target: '1780305600095' targetHandle: target @@ -93,7 +76,7 @@ workflow: nodes: - data: selected: false - title: 用户输入 + title: User Input type: start variables: [] height: 73 @@ -109,45 +92,9 @@ workflow: type: custom width: 242 - data: - context: - enabled: false - variable_selector: [] - model: - completion_params: - temperature: 0.7 - mode: chat - name: qwen3.6-plus - provider: langgenius/tongyi/tongyi - prompt_template: - - id: cd753cdd-d950-44bf-99ad-7cb19f42d5b6 - role: system - text: 输出内容:hello + outputs: [] selected: false - title: LLM - type: llm - vision: - enabled: false - height: 88 - id: '1780305526186' - position: - x: 382 - y: 282 - positionAbsolute: - x: 382 - y: 282 - sourcePosition: right - targetPosition: left - type: custom - width: 242 - - data: - outputs: - - value_selector: - - '1780305526186' - - text - value_type: string - variable: x - selected: false - title: 输出 + title: Output type: end height: 88 id: '1780305600095' @@ -157,6 +104,7 @@ workflow: positionAbsolute: x: 684 y: 282 + selected: false sourcePosition: right targetPosition: left type: custom diff --git a/cli/test/e2e/helpers/cli.ts b/cli/test/e2e/helpers/cli.ts index 9e52348c0fe..18c6a213f34 100644 --- a/cli/test/e2e/helpers/cli.ts +++ b/cli/test/e2e/helpers/cli.ts @@ -8,11 +8,13 @@ * withTempConfig) to prevent session state leaking between tests. */ +import type { TokenDoc } from '@/store/token-store' import { Buffer } from 'node:buffer' import { execSync, spawn } from 'node:child_process' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' +import yaml from 'js-yaml' /** Path to the dev entry point — no build required. */ export const BIN = resolve(__dirname, '../../../bin/dev.js') @@ -208,13 +210,11 @@ function splitHost(host: string): { bare: string, scheme: string } { } async function writeFileToken(configDir: string, host: string, email: string, bearer: string): Promise { - const dotParts = `tokens.${host}.${email}`.split('.') - let yaml = '' - for (let i = 0; i < dotParts.length - 1; i++) { - yaml += `${' '.repeat(i) + dotParts[i]}:\n` + const doc: TokenDoc = { + version: 1, + tokens: { [host]: { [email]: bearer } }, } - yaml += `${' '.repeat(dotParts.length - 1) + (dotParts[dotParts.length - 1] ?? '')}: "${bearer}"\n` - await writeFile(join(configDir, 'tokens.yml'), yaml, { mode: 0o600 }) + await writeFile(join(configDir, 'tokens.yml'), yaml.dump(doc, { lineWidth: -1, noRefs: true }), { mode: 0o600 }) } /** @@ -289,11 +289,8 @@ export async function injectAuth(configDir: string, opts: AuthInjectionOptions): new Entry('difyctl', account).setPassword(JSON.stringify(opts.bearer)) } else { - // Fall back to tokens.yml. - // YamlStore.doGet splits the key on '.' and traverses the nested object, - // so "tokens.localhost.user@dify.ai" splits into 4 parts: - // tokens -> localhost -> user@dify -> ai - // The YAML must mirror that exact nesting. + // Fall back to tokens.yml — FileTokenStore uses getTyped() + // which expects flat tokens[host][email] with version: 1. await writeFileToken(configDir, bare, email, opts.bearer) } } diff --git a/cli/test/e2e/setup/env.ts b/cli/test/e2e/setup/env.ts index 8312935700c..3f71b514426 100644 --- a/cli/test/e2e/setup/env.ts +++ b/cli/test/e2e/setup/env.ts @@ -187,8 +187,13 @@ export function isE2ELocalMode(): boolean { /** * Resolve the E2E environment, merging capabilities (from global-setup) on top * of the optional env-var overrides. Capabilities always take priority. + * + * `caps` may be undefined in local mode (DIFY_E2E_MODE=local), where + * global-setup returns early without calling project.provide(). */ -export function resolveEnv(caps: E2ECapabilities): E2EEnv { +export function resolveEnv(caps: E2ECapabilities | undefined): E2EEnv { + if (!caps) + return loadE2EEnv() const env = loadE2EEnv() return { ...env, diff --git a/cli/test/e2e/suites/agent/agent-skill-workflow.e2e.ts b/cli/test/e2e/suites/agent/agent-skill-workflow.e2e.ts index 5298314ab15..a1dea13e66a 100644 --- a/cli/test/e2e/suites/agent/agent-skill-workflow.e2e.ts +++ b/cli/test/e2e/suites/agent/agent-skill-workflow.e2e.ts @@ -349,9 +349,11 @@ describe('E2E / agent skill — describe app -o json (auth required)', () => { assertPipeFriendlyJson(r) }) - itWithAuth('[P0] nonexistent app → exit 1 + JSON error envelope', async () => { + itWithAuth('[P0] invalid (non-UUID) app id → exit 2 + usage error envelope', async () => { + // 'app-id-nonexistent-e2e-xyz' is not a valid UUID; describe app rejects it + // client-side via isValidUuid() with usage_invalid_flag (exit 2). const r = await fx.r(['describe', 'app', 'app-id-nonexistent-e2e-xyz', '-o', 'json']) - expect(r.exitCode).toBe(1) + expect(r.exitCode).toBe(2) assertErrorEnvelope(r) }) }) diff --git a/cli/test/e2e/suites/discovery/describe-app.e2e.ts b/cli/test/e2e/suites/discovery/describe-app.e2e.ts index 5b63a63f110..75cfe226370 100644 --- a/cli/test/e2e/suites/discovery/describe-app.e2e.ts +++ b/cli/test/e2e/suites/discovery/describe-app.e2e.ts @@ -131,11 +131,12 @@ describe('E2E / difyctl describe app', () => { // ── Not found ───────────────────────────────────────────────────────────── - it('[P0] non-existent app returns exit code 1 with not-found error (3.83)', async () => { - // Spec 3.83: describe non-existent app → stderr contains not-found, exit code 1. + it('[P0] invalid (non-UUID) app id returns usage error (exit code 2)', async () => { + // NONEXISTENT_ID is not a valid UUID, so the CLI rejects it client-side via + // isValidUuid() before making any network request → usage_invalid_flag (exit 2). const result = await fx.r(['describe', 'app', NONEXISTENT_ID]) - expect(result.exitCode, 'non-existent app should exit with code 1').toBe(1) - expect(result.stderr).toMatch(/not.?found|404|does not exist|server_5xx/i) + expect(result.exitCode, 'invalid UUID should exit with code 2').toBe(2) + expect(result.stderr).toMatch(/uuid|valid|usage_invalid_flag/i) }) it('[P1] non-existent app in JSON mode outputs JSON error envelope', async () => { diff --git a/cli/test/e2e/suites/error-handling/error-messages.e2e.ts b/cli/test/e2e/suites/error-handling/error-messages.e2e.ts index d5ff63d87e1..30a5d213b7d 100644 --- a/cli/test/e2e/suites/error-handling/error-messages.e2e.ts +++ b/cli/test/e2e/suites/error-handling/error-messages.e2e.ts @@ -141,7 +141,7 @@ describe('E2E / error message standards (spec 5.3)', () => { // Spec 5.70: submitting a value of the wrong type must fail. // The workflow app (workflowAppId) expects x as a string; passing a JSON // number causes the server to reject the request. - // In v1.0 the server returns HTTP 500 for type validation failures. + // After the @accepts/@returns contract unification, the server returns HTTP 422 for request schema failures. const result = await fx.r([ 'run', 'app', @@ -156,6 +156,65 @@ describe('E2E / error message standards (spec 5.3)', () => { expect(result.stderr.trim().length).toBeGreaterThan(0) }) + // ── 5.70a/b/c P4 sanitization — 422 error body is clean (no leaks) ──────── + + it('[P0] 5.70a validation failure message is a plain string, not double-encoded JSON', async () => { + // After the @accepts contract fix, the server aborts with + // abort(422, message="Request validation failed", errors=[...]) + // The CLI wraps this into its envelope. The message field must be a plain + // human-readable string — NOT a JSON-serialised string that itself contains + // pydantic error details (which was the double-encoding bug in P4). + const result = await fx.r([ + 'run', + 'app', + E.workflowAppId, + '--inputs', + JSON.stringify({ x: 'hello', num: 'not-a-number', enum_var: 'A', paragraph: 'ok' }), + '-o', + 'json', + ]) + assertNonZeroExit(result) + const envelope = assertErrorEnvelope(result) + // message must be a plain string, not a JSON string (no double encoding) + expect(typeof envelope.error.message).toBe('string') + expect(() => JSON.parse(envelope.error.message)).toThrow() + }) + + it('[P1] 5.70b validation error response does not leak pydantic version URL', async () => { + // Before the P4 fix, exc.json() included a "url" field pointing to + // https://errors.pydantic.dev//... — exposing the server's pydantic + // version. The sanitised response must not contain this URL. + const result = await fx.r([ + 'run', + 'app', + E.workflowAppId, + '--inputs', + JSON.stringify({ x: 'hello', num: 'not-a-number', enum_var: 'A', paragraph: 'ok' }), + '-o', + 'json', + ]) + assertNonZeroExit(result) + expect(result.stderr).not.toMatch(/errors\.pydantic\.dev|pydantic\.dev\//) + }) + + it('[P1] 5.70c validation error response does not echo back user input', async () => { + // Before the P4 fix, exc.json() included the user's original "input" value + // inside the error details. The sanitised response must not repeat the + // submitted value so that sensitive payloads are not reflected to callers. + const sentValue = 'not-a-number-sentinel-12345' + const result = await fx.r([ + 'run', + 'app', + E.workflowAppId, + '--inputs', + JSON.stringify({ x: 'hello', num: sentValue, enum_var: 'A', paragraph: 'ok' }), + '-o', + 'json', + ]) + assertNonZeroExit(result) + expect(result.stderr).not.toContain(sentValue) + }) + // ── 5.76 Failed command + -o yaml → stderr is still JSON envelope ──────── it('[P1] 5.76 failed command with -o yaml still outputs a JSON error envelope on stderr', async () => { diff --git a/cli/test/e2e/suites/run/run-app-basic.e2e.ts b/cli/test/e2e/suites/run/run-app-basic.e2e.ts index 7c395186d6a..1e3acdd6668 100644 --- a/cli/test/e2e/suites/run/run-app-basic.e2e.ts +++ b/cli/test/e2e/suites/run/run-app-basic.e2e.ts @@ -8,7 +8,7 @@ * * Staging app prerequisites (specified via DIFY_E2E_* env vars): * echo-chat — mode=chat, query variable, outputs "echo: {query}" - * echo-workflow — mode=workflow, x variable (required), outputs "echo: {x}" + * echo-workflow — mode=workflow, x variable (required), outputs x directly (no echo prefix) */ import type { AuthFixture } from '../../helpers/cli.js' @@ -263,7 +263,7 @@ describe('E2E / difyctl run app', () => { JSON.stringify({ x: 'hello', num: 42, enum_var: 'A', paragraph: 'short text' }), ]) assertExitCode(happyResult, 0) - assertStdoutContains(happyResult, 'echo:hello') + assertStdoutContains(happyResult, 'hello') // workflow outputs x directly; echo: prefix removed (no sandbox on server) // ── 4.1.17: number field receives a string value ───────────────────── const typedResult = await fx.r([ @@ -289,6 +289,26 @@ describe('E2E / difyctl run app', () => { }) }) + it('[P1] validation failure returns http_status 422 in JSON error envelope', async () => { + // After the @accepts/@returns server contract unification, input schema + // validation failures consistently return HTTP 422 (not 400 or 500). + // This verifies the CLI propagates the unified status code. + const result = await fx.r([ + 'run', + 'app', + E.workflowAppId, + '--inputs', + JSON.stringify({ x: 'hello', num: 'not-a-number', enum_var: 'A', paragraph: 'ok' }), + '-o', + 'json', + ]) + expect(result.exitCode).not.toBe(0) + const envelope = JSON.parse(result.stderr.trim()) as { + error: { code: string, message: string, http_status?: number } + } + expect(envelope.error.http_status, 'validation failure must return http_status 422').toBe(422) + }) + // ========================================================================= // Error scenarios // ========================================================================= diff --git a/cli/test/e2e/suites/run/run-app-conversation.e2e.ts b/cli/test/e2e/suites/run/run-app-conversation.e2e.ts index 5dc3162066c..ec69c9ced84 100644 --- a/cli/test/e2e/suites/run/run-app-conversation.e2e.ts +++ b/cli/test/e2e/suites/run/run-app-conversation.e2e.ts @@ -118,7 +118,7 @@ describe('E2E / difyctl run app --conversation', () => { 'invalid-conv-id-xyz-not-exist', ]) assertExitCode(result, 1) - expect(result.stderr).toMatch(/not.?found|conversation|404/i) + expect(result.stderr).toMatch(/not.?found|conversation|404|422|validation/i) }) // ── Combined flags ────────────────────────────────────────────────────── diff --git a/cli/test/e2e/suites/run/run-app-streaming.e2e.ts b/cli/test/e2e/suites/run/run-app-streaming.e2e.ts index 168ee0d7e77..a8713404c41 100644 --- a/cli/test/e2e/suites/run/run-app-streaming.e2e.ts +++ b/cli/test/e2e/suites/run/run-app-streaming.e2e.ts @@ -277,7 +277,7 @@ describe('E2E / difyctl run app --stream (specialisation)', () => { '--stream', ]) expect(result.exitCode, 'wrong-type input should cause non-zero exit').not.toBe(0) - expect(result.stderr).toMatch(/validation|invalid|type|400|server_5xx|must be/i) + expect(result.stderr).toMatch(/validation|invalid|type|422|must be/i) }) // ── Non-existent app with positional query (4.2.16) ──────────────────── diff --git a/cli/test/fixtures/config-dir.ts b/cli/test/fixtures/config-dir.ts new file mode 100644 index 00000000000..fa32563236d --- /dev/null +++ b/cli/test/fixtures/config-dir.ts @@ -0,0 +1,24 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach } from 'vitest' +import { ENV_CONFIG_DIR } from '../../src/store/dir.js' + +// Points ENV_CONFIG_DIR at a fresh temp dir per test and restores it after. +// Call inside a describe block; returns a getter because the dir changes per test. +export function useTempConfigDir(prefix: string): () => string { + let dir = '' + let prev: string | undefined + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), prefix)) + prev = process.env[ENV_CONFIG_DIR] + process.env[ENV_CONFIG_DIR] = dir + }) + afterEach(async () => { + if (prev === undefined) + delete process.env[ENV_CONFIG_DIR] + else process.env[ENV_CONFIG_DIR] = prev + await rm(dir, { recursive: true, force: true }) + }) + return () => dir +} diff --git a/cli/test/fixtures/dify-mock/server.ts b/cli/test/fixtures/dify-mock/server.ts index afc135e5327..96edc96f9ba 100644 --- a/cli/test/fixtures/dify-mock/server.ts +++ b/cli/test/fixtures/dify-mock/server.ts @@ -150,8 +150,9 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { app.use('*', async (c, next) => { const scenario = getScenario() if (scenario === 'rate-limited') { + // Unified ErrorBody — per-token throttle (retryable); Retry-After advises the wait. return c.json( - { error: { code: 'rate_limited', message: 'too many requests' } }, + { code: 'too_many_requests', message: 'Too many requests for this API token.', status: 429 }, { status: 429, headers: { 'retry-after': '1' } }, ) } diff --git a/cli/test/fixtures/mem-store.ts b/cli/test/fixtures/mem-store.ts new file mode 100644 index 00000000000..879477b7497 --- /dev/null +++ b/cli/test/fixtures/mem-store.ts @@ -0,0 +1,27 @@ +import type { TokenStore } from '../../src/store/token-store.js' + +// In-memory TokenStore for tests; mirrors keyring semantics (empty string = missing). +export class MemStore implements TokenStore { + readonly entries: Map + + // Seed keys use the composite " " form, e.g. { 'h1 a@x': 'dfoa_a' }. + constructor(seed: Record = {}) { + this.entries = new Map(Object.entries(seed)) + } + + private k(host: string, email: string): string { + return `${host} ${email}` + } + + async read(host: string, email: string): Promise { + return this.entries.get(this.k(host, email)) ?? '' + } + + async write(host: string, email: string, bearer: string): Promise { + this.entries.set(this.k(host, email), bearer) + } + + async remove(host: string, email: string): Promise { + this.entries.delete(this.k(host, email)) + } +} diff --git a/dify-agent/Makefile b/dify-agent/Makefile index a9b258fa5f9..0decb13ae3d 100644 --- a/dify-agent/Makefile +++ b/dify-agent/Makefile @@ -33,7 +33,7 @@ typecheck: @uv --directory "$(PROJECT_DIR)" run --project . basedpyright --level error src examples tests test: - @uv --directory "$(PROJECT_DIR)" run --project . python -m pytest tests + @uv --directory "$(PROJECT_DIR)" run --project . --extra server python -m pytest tests update-examples: @uv --directory "$(PROJECT_DIR)" run --project . python -m pytest --update-examples tests/docs/test_examples.py diff --git a/dify-agent/docs/dify-agent/concepts/run-lifecycle/index.md b/dify-agent/docs/dify-agent/concepts/run-lifecycle/index.md index dce524e5b37..bbc9bd2fc31 100644 --- a/dify-agent/docs/dify-agent/concepts/run-lifecycle/index.md +++ b/dify-agent/docs/dify-agent/concepts/run-lifecycle/index.md @@ -45,14 +45,15 @@ current `agent run` has ended; the outer `workflow run` is what should be paused The caller should handle this flow as follows: -1. Read the current `agent run` result and detect the HITL (human-in-the-loop) - requirement. +1. Read the current `agent run` result and detect `deferred_tool_call` on the + terminal `run_succeeded` event. 2. Enter workflow HITL handling and pause graphon. 3. Wait for the human input to be completed. -4. When resuming the workflow, insert the human tool response into the same Agent - session's history layer. -5. Start a second `agent run` on the same Agent node and reuse the same history - session. +4. When resuming the workflow, start a second `agent run` on the same Agent node + with the previous `session_snapshot`, matching composition, and + `deferred_tool_results` keyed by the original tool call id. +5. Keep the history layer active so Dify Agent can match the result to the + pending tool call stored in the previous run's message history. In other words, a human tool does not mean “pause this agent run until it is resumed.” It means “this agent run ended with a result that requires human diff --git a/dify-agent/docs/dify-agent/guide/index.md b/dify-agent/docs/dify-agent/guide/index.md index c3662478dbc..27ec96ab349 100644 --- a/dify-agent/docs/dify-agent/guide/index.md +++ b/dify-agent/docs/dify-agent/guide/index.md @@ -136,8 +136,10 @@ Successful runs emit `run_started`, zero or more `pydantic_ai_event`, and `run_succeeded`. Failed runs end with `run_failed`. Event envelopes retain `id`, `run_id`, `type`, `data`, and `created_at`; `data` is typed per event type, including Pydantic AI's `AgentStreamEvent` payload for `pydantic_ai_event` and a -terminal `run_succeeded.data` object containing JSON-safe `output` plus a -`CompositorSessionSnapshot` for resumption. +terminal `run_succeeded.data` object containing a `CompositorSessionSnapshot` for +resumption. A successful run has exactly one active result branch: JSON-safe +`output` for final answers, or `deferred_tool_call` when a layer such as +`dify.ask_human` ends the current agent run with an external deferred tool call. ## Examples diff --git a/dify-agent/docs/dify-agent/user-manual/ask-human-layer/index.md b/dify-agent/docs/dify-agent/user-manual/ask-human-layer/index.md new file mode 100644 index 00000000000..404819398bc --- /dev/null +++ b/dify-agent/docs/dify-agent/user-manual/ask-human-layer/index.md @@ -0,0 +1,271 @@ +# Ask human layer + +The ask human layer exposes one model-visible tool that lets an agent end the +current run with a structured request for human input. This page is for Dify +Agent clients that build `CreateRunRequest` payloads and then interpret terminal +run events. + +The layer type id is `dify.ask_human`. It does not deliver forms, choose +recipients, enforce authorization, or wait inside the agent run. It only gives +the model a safe way to ask for human input and returns that request as a +deferred tool call. + +## Layer contract + +| Property | Value | +| --- | --- | +| Type id | `dify.ask_human` | +| Common layer name | `ask_human` | +| Config DTO | `DifyAskHumanLayerConfig` | +| Model-visible tool | `ask_human` by default, configurable with `tool_name` | +| Tool kind | pydantic-ai `external` deferred tool | +| Terminal event | `run_succeeded` | +| Terminal payload branch | `run_succeeded.data.deferred_tool_call` | + +The agent run does not enter a paused status. When the model calls the ask-human +tool, the current run succeeds with a `deferred_tool_call` instead of normal +`output`. The client is responsible for turning that deferred call into its own +human-facing workflow, collecting a result, and starting another run with +`deferred_tool_results`. + +## Basic usage + +Add the ask human layer to the same composition as the prompt, history, LLM, and +optional structured-output layers: + +```python {test="skip" lint="skip"} +from agenton_collections.layers.plain import PromptLayerConfig +from agenton_collections.layers.pydantic_ai import PYDANTIC_AI_HISTORY_LAYER_TYPE_ID +from dify_agent.layers.ask_human import DIFY_ASK_HUMAN_LAYER_TYPE_ID, DifyAskHumanLayerConfig +from dify_agent.layers.dify_plugin import DifyPluginLLMLayerConfig +from dify_agent.protocol import DIFY_AGENT_HISTORY_LAYER_ID, DIFY_AGENT_MODEL_LAYER_ID +from dify_agent.protocol.schemas import CreateRunRequest, RunComposition, RunLayerSpec + + +request = CreateRunRequest( + composition=RunComposition( + layers=[ + RunLayerSpec( + name="prompt", + type="plain.prompt", + config=PromptLayerConfig( + prefix="You can ask a human only when the missing decision is required to continue.", + user="Review the deployment plan and proceed only after getting the required approval.", + ), + ), + RunLayerSpec( + name=DIFY_AGENT_HISTORY_LAYER_ID, + type=PYDANTIC_AI_HISTORY_LAYER_TYPE_ID, + ), + RunLayerSpec( + name="ask_human", + type=DIFY_ASK_HUMAN_LAYER_TYPE_ID, + config=DifyAskHumanLayerConfig( + max_fields=4, + max_actions=2, + allowed_field_types=["paragraph", "select"], + allow_file_fields=False, + ), + ), + RunLayerSpec( + name=DIFY_AGENT_MODEL_LAYER_ID, + type="dify.plugin.llm", + config=DifyPluginLLMLayerConfig( + plugin_id="langgenius/openai", + model_provider="openai", + model="gpt-5.2", + credentials={"openai_api_key": ""}, + ), + ), + ] + ) +) +``` + +Include a [history layer](../history-layer/index.md) whenever you expect to +resume after a human answer. The pending tool call is stored in pydantic-ai +message history, so the resumed run needs both the returned `session_snapshot` +and the same logical composition with the history layer still present. + +## Config fields + +`DifyAskHumanLayerConfig` controls the model-facing tool identity and guardrails. +It intentionally does not contain delivery settings. + +| Field | Type | Default | Meaning | +| --- | --- | --- | --- | +| `enabled` | `bool` | `True` | When false, the layer exposes neither the tool nor the prompt guidance. | +| `tool_name` | `str` | `"ask_human"` | Model-visible tool name. Must be a valid identifier. | +| `tool_description` | `str \| None` | default description | Optional model-visible tool description. | +| `max_fields` | `int` | `8` | Maximum number of fields the model may request. Use `0` for action-only requests. | +| `max_actions` | `int` | `4` | Maximum number of human actions the model may request. | +| `allowed_field_types` | `list["paragraph" \| "select" \| "file" \| "file-list"]` | `["paragraph", "select"]` | Field types accepted by runtime validation. | +| `allow_file_fields` | `bool` | `False` | File field types are rejected unless this is true and the type is listed in `allowed_field_types`. | +| `max_markdown_chars` | `int` | `8000` | Maximum length for the optional `markdown` body. | +| `max_question_chars` | `int` | `1000` | Maximum length for the required `question`. | +| `max_field_label_chars` | `int` | `120` | Maximum label length for each field. | +| `max_action_label_chars` | `int` | `80` | Maximum label length for each action. | + +Configured limits are also capped by server hard limits. If a config exceeds a +hard cap, request validation fails before the run can execute. + +The layer converts these limits into a prompt hint automatically. Clients do not +need to write a separate system prompt listing the limits, although they may add +business-specific guidance such as when human input is appropriate. + +## What the model can request + +When enabled, the layer exposes an external deferred tool whose argument shape is +`AskHumanToolArgs`: + +| Field | Type | Meaning | +| --- | --- | --- | +| `title` | `str \| None` | Optional short title for the human request. | +| `question` | `str` | Required question/instruction for the human. | +| `markdown` | `str \| None` | Optional longer Markdown body. Treat it as untrusted user-visible content. | +| `fields` | `list[AskHumanField]` | Optional structured fields for the human to fill. | +| `actions` | `list[AskHumanAction]` | Optional action buttons. If omitted, Dify Agent normalizes to a single primary `Submit` action. | +| `urgency` | `"normal" \| "high"` | Hint for downstream systems; it is not a delivery policy. | + +Supported field variants: + +- `paragraph`: free-text input. +- `select`: single-choice input with unique option values. +- `file`: single-file input, only when file fields are allowed. +- `file-list`: multi-file input, only when file fields are allowed. + +Tool arguments are validated again after the model calls the tool. Invalid calls +produce a model retry before a terminal success is emitted. + +## Handling a deferred human request + +Stream or poll run events as usual. A successful final answer has +`event.data.output`. A successful human request has `event.data.deferred_tool_call`. +Exactly one branch is set. + +```python {test="skip" lint="skip"} +deferred_call = None +snapshot = None + +async for event in client.stream_events(run_id): + if event.type != "run_succeeded": + continue + snapshot = event.data.session_snapshot + if event.data.deferred_tool_call is not None: + deferred_call = event.data.deferred_tool_call + else: + final_output = event.data.output + break + +if deferred_call is not None: + # Render your own human-facing form, enqueue notification, pause an outer + # workflow, or store the request for later. Dify Agent does not do that part. + print(deferred_call.tool_call_id, deferred_call.args) +``` + +A typical deferred payload looks like this: + +```json +{ + "tool_call_id": "call_01H...", + "tool_name": "ask_human", + "args": { + "title": "Deployment approval", + "question": "Can we deploy version 2026.06.10 to production now?", + "fields": [ + { + "type": "paragraph", + "name": "comment", + "label": "Approval comment", + "required": false + } + ], + "actions": [ + {"id": "approve", "label": "Approve", "style": "primary"}, + {"id": "reject", "label": "Reject", "style": "destructive"} + ], + "urgency": "normal" + }, + "metadata": { + "layer_type": "dify.ask_human", + "tool_name": "ask_human", + "schema_version": 1 + } +} +``` + +The `args` object is model-generated content. Validate and sanitize it before +rendering it to end users. + +## Resume with a human result + +After your client collects a human answer, create a new run with: + +- the previous `session_snapshot`; +- a matching composition that still includes the history and ask-human layers; +- `deferred_tool_results.calls[tool_call_id]` containing the human result. + +```python {test="skip" lint="skip"} +from dify_agent.layers.ask_human import AskHumanToolResult +from dify_agent.protocol import DeferredToolResultsPayload + + +human_result = AskHumanToolResult( + status="submitted", + action={"id": "approve", "label": "Approve"}, + values={"comment": "Approved for the planned window."}, + message="The human approved the deployment.", +) + +resume_request = CreateRunRequest( + composition=composition_with_same_layer_names_and_order, + session_snapshot=snapshot, + deferred_tool_results=DeferredToolResultsPayload( + calls={deferred_call.tool_call_id: human_result.model_dump(mode="json")}, + ), +) +``` + +Dify Agent passes the supplied result back to pydantic-ai as the return value of +the original external tool call, then the model continues. The resumed run may +produce a final `output`, or it may produce another `deferred_tool_call` if the +agent needs another human turn. + +Timeouts and unavailable humans should also be sent as tool results instead of +being treated as agent-run failures: + +```json +{ + "status": "timeout", + "action": {"id": "__timeout", "label": "Timeout"}, + "values": {}, + "message": "The human did not respond before the workflow timeout." +} +``` + +## Client responsibilities + +The ask human layer deliberately leaves product decisions to the caller. Clients +must decide how to: + +- persist the deferred call and correlate it with a human-facing task; +- render and sanitize the requested fields/actions; +- choose recipients, channels, and timeout policy; +- authorize who may answer; +- transform the human submission into `AskHumanToolResult`; +- resume with the returned `session_snapshot` and matching composition. + +Do not put recipient emails, workspace member ids, public URLs, auth tokens, or +timeout policy in the tool arguments. The model-facing request is untrusted and +should not control delivery or authorization. + +## Troubleshooting + +| Symptom | What to check | +| --- | --- | +| Run fails with `Deferred tool results require a 'history' layer` | Add the `history` layer and resume with the prior snapshot. | +| Run fails with `pending tool call can be resumed` | Keep the history layer active for the initial deferred run. | +| Run fails with `exactly one deferred call` | The MVP supports one ask-human call per run. Ask the model to ask one question at a time. | +| Run fails with `tool name must be ...` | Use the configured `tool_name`; do not rename it only in downstream form code. | +| File fields are rejected | Set `allow_file_fields=True` and include `file` or `file-list` in `allowed_field_types`. | +| `run_succeeded.data.output` is absent | Check `run_succeeded.data.deferred_tool_call`; this is a human-request success, not a failed run. | diff --git a/dify-agent/mkdocs.yml b/dify-agent/mkdocs.yml index 579cffe536a..3993b3618e5 100644 --- a/dify-agent/mkdocs.yml +++ b/dify-agent/mkdocs.yml @@ -21,6 +21,7 @@ nav: - Prompt Layer: dify-agent/user-manual/prompt-layer/index.md - Execution Context Layer: dify-agent/user-manual/execution-context-layer/index.md - Shell Layer: dify-agent/user-manual/shell-layer/index.md + - Ask Human Layer: dify-agent/user-manual/ask-human-layer/index.md - Plugin LLM Layer: dify-agent/user-manual/plugin-llm-layer/index.md - Plugin Tool Layer: dify-agent/user-manual/plugin-tool-layer/index.md - History Layer: dify-agent/user-manual/history-layer/index.md diff --git a/dify-agent/pyproject.toml b/dify-agent/pyproject.toml index e274d9144ff..915114d2338 100644 --- a/dify-agent/pyproject.toml +++ b/dify-agent/pyproject.toml @@ -17,13 +17,10 @@ dify-agent = "dify_agent.agent_stub.cli.main:main" dify-agent-stub-server = "dify_agent.agent_stub.server.cli:main" [project.optional-dependencies] -grpc = [ - "grpclib[protobuf]>=0.4.9,<0.5.0", - "protobuf>=6.33.5,<7.0.0", -] +grpc = ["grpclib[protobuf]>=0.4.9,<0.5.0", "protobuf>=6.33.5,<7.0.0"] server = [ "fastapi==0.136.0", - "graphon==0.2.2", + "graphon==0.5.1", "jsonschema>=4.23.0,<5.0.0", "jwcrypto>=1.5.6,<2", "pydantic-ai-slim[anthropic,google,openai]>=1.85.1,<2.0.0", @@ -35,22 +32,14 @@ server = [ [tool.setuptools.packages.find] where = ["src"] -include = [ - "agenton*", - "agenton_collections*", - "dify_agent*", -] +include = ["agenton*", "agenton_collections*", "dify_agent*"] [tool.pyright] include = ["src", "examples", "tests"] venvPath = "." venv = ".venv" pythonVersion = "3.12" -extraPaths = [ - "src", - "examples/agenton", - "examples/dify_agent", -] +extraPaths = ["src", "examples/agenton", "examples/dify_agent"] [tool.pytest.ini_options] testpaths = ["tests"] @@ -59,12 +48,7 @@ python_files = ["test_*.py", "*_test.py"] [tool.ruff] line-length = 120 target-version = "py312" -include = [ - "src/**/*.py", - "examples/**/*.py", - "tests/**/*.py", - "docs/**/*.py", -] +include = ["src/**/*.py", "examples/**/*.py", "tests/**/*.py", "docs/**/*.py"] [dependency-groups] dev = [ diff --git a/dify-agent/src/dify_agent/client/__init__.py b/dify-agent/src/dify_agent/client/__init__.py index ff0027b2912..d7bd4204ddd 100644 --- a/dify-agent/src/dify_agent/client/__init__.py +++ b/dify-agent/src/dify_agent/client/__init__.py @@ -1,4 +1,4 @@ -"""Unified sync and async Python client for the Dify Agent run API.""" +"""Unified sync and async Python client for the Dify Agent HTTP API.""" from ._client import ( Client, diff --git a/dify-agent/src/dify_agent/client/_client.py b/dify-agent/src/dify_agent/client/_client.py index 8de3af7c391..0ab8dc0dcf3 100644 --- a/dify-agent/src/dify_agent/client/_client.py +++ b/dify-agent/src/dify_agent/client/_client.py @@ -1,13 +1,12 @@ -"""HTTPX-based client for Dify Agent runs. +"""HTTPX-based client for the Dify Agent HTTP API. -The client uses the public DTOs from ``dify_agent.protocol.schemas`` for all -normal request and response parsing. It intentionally does not retry -``POST /runs`` because create-run is not idempotent, and create helpers require a -``CreateRunRequest`` instance rather than accepting raw payload dicts. SSE -streams are the only operation with reconnect logic: transient stream, connect, -or read failures, stream timeouts, and HTTP 5xx stream responses reconnect with -the latest observed event id, while HTTP 4xx responses, DTO validation failures, -and malformed SSE frames fail immediately. +The client uses the public DTOs from ``dify_agent.protocol`` for request and +response parsing across both run-management and sandbox-file endpoints. It +intentionally does not retry non-idempotent ``POST`` requests such as +``/runs``. SSE streams are the only operation with reconnect logic: transient +stream, connect, or read failures, stream timeouts, and HTTP 5xx stream +responses reconnect with the latest observed event id, while HTTP 4xx +responses, DTO validation failures, and malformed SSE frames fail immediately. """ from __future__ import annotations @@ -26,7 +25,7 @@ import httpx from pydantic import BaseModel, ValidationError from pydantic_ai.messages import FunctionToolResultEvent -from dify_agent.protocol.schemas import ( +from dify_agent.protocol import ( CancelRunRequest, CancelRunResponse, CreateRunRequest, @@ -35,6 +34,13 @@ from dify_agent.protocol.schemas import ( RunEvent, RunEventsResponse, RunStatusResponse, + SandboxListRequest, + SandboxListResponse, + SandboxLocator, + SandboxReadRequest, + SandboxReadResponse, + SandboxUploadRequest, + SandboxUploadResponse, ) _ResponseModelT = TypeVar("_ResponseModelT", bound=BaseModel) @@ -60,7 +66,7 @@ class DifyAgentHTTPError(DifyAgentClientError): class DifyAgentNotFoundError(DifyAgentHTTPError): - """Raised when the server returns ``404`` for a run resource.""" + """Raised when the server returns ``404`` for a requested Dify Agent resource.""" class DifyAgentValidationError(DifyAgentHTTPError): @@ -201,13 +207,15 @@ def _normalize_run_event_payload_for_local_pydantic_ai(payload: Any) -> Any: class Client: - """Unified synchronous and asynchronous client for Dify Agent runs. + """Unified synchronous and asynchronous client for the Dify Agent HTTP API. The instance is intentionally small and stateful: it stores base URL, default headers, timeout settings, optional external HTTPX clients, and lazy-owned - clients for whichever sync/async side is used. External clients are never - closed by this wrapper. Owned sync clients close via ``close_sync`` or the - sync context manager; owned async clients close via ``aclose`` or the async + clients for whichever sync/async side is used. It is the shared transport + boundary for both run-management endpoints (create/status/events/cancel) and + sandbox-file endpoints (list/read/upload). External clients are never closed + by this wrapper. Owned sync clients close via ``close_sync`` or the sync + context manager; owned async clients close via ``aclose`` or the async context manager. """ @@ -419,6 +427,62 @@ class Client: raise DifyAgentClientError(f"get_events_sync request failed: {exc}") from exc return _parse_model_response(response, RunEventsResponse) + async def list_sandbox_files(self, locator: SandboxLocator, path: str) -> SandboxListResponse: + """List a sandbox directory through ``POST /sandbox/files/list``.""" + request_model = _build_request_model(SandboxListRequest, locator=locator, path=path) + response = await self._post_async_json("list_sandbox_files", "/sandbox/files/list", request_model) + return _parse_model_response(response, SandboxListResponse) + + def list_sandbox_files_sync(self, locator: SandboxLocator, path: str) -> SandboxListResponse: + """Synchronous variant of ``list_sandbox_files``.""" + request_model = _build_request_model(SandboxListRequest, locator=locator, path=path) + response = self._post_sync_json("list_sandbox_files_sync", "/sandbox/files/list", request_model) + return _parse_model_response(response, SandboxListResponse) + + async def read_sandbox_file( + self, + locator: SandboxLocator, + path: str, + max_bytes: int = 262144, + ) -> SandboxReadResponse: + """Read a sandbox file preview through ``POST /sandbox/files/read``.""" + request_model = _build_request_model( + SandboxReadRequest, + locator=locator, + path=path, + max_bytes=max_bytes, + ) + response = await self._post_async_json("read_sandbox_file", "/sandbox/files/read", request_model) + return _parse_model_response(response, SandboxReadResponse) + + def read_sandbox_file_sync( + self, + locator: SandboxLocator, + path: str, + max_bytes: int = 262144, + ) -> SandboxReadResponse: + """Synchronous variant of ``read_sandbox_file``.""" + request_model = _build_request_model( + SandboxReadRequest, + locator=locator, + path=path, + max_bytes=max_bytes, + ) + response = self._post_sync_json("read_sandbox_file_sync", "/sandbox/files/read", request_model) + return _parse_model_response(response, SandboxReadResponse) + + async def upload_sandbox_file(self, locator: SandboxLocator, path: str) -> SandboxUploadResponse: + """Upload a sandbox file mapping through ``POST /sandbox/files/upload``.""" + request_model = _build_request_model(SandboxUploadRequest, locator=locator, path=path) + response = await self._post_async_json("upload_sandbox_file", "/sandbox/files/upload", request_model) + return _parse_model_response(response, SandboxUploadResponse) + + def upload_sandbox_file_sync(self, locator: SandboxLocator, path: str) -> SandboxUploadResponse: + """Synchronous variant of ``upload_sandbox_file``.""" + request_model = _build_request_model(SandboxUploadRequest, locator=locator, path=path) + response = self._post_sync_json("upload_sandbox_file_sync", "/sandbox/files/upload", request_model) + return _parse_model_response(response, SandboxUploadResponse) + async def stream_events( self, run_id: str, @@ -631,6 +695,32 @@ class Client: headers.update(extra) return headers + async def _post_async_json(self, operation: str, path: str, request_model: BaseModel) -> httpx.Response: + try: + return await self._get_async_http_client().post( + self._url(path), + content=request_model.model_dump_json(), + headers=self._merged_headers({"Content-Type": "application/json"}), + timeout=self._timeout, + ) + except httpx.TimeoutException as exc: + raise DifyAgentTimeoutError(f"{operation} timed out") from exc + except httpx.RequestError as exc: + raise DifyAgentClientError(f"{operation} request failed: {exc}") from exc + + def _post_sync_json(self, operation: str, path: str, request_model: BaseModel) -> httpx.Response: + try: + return self._get_sync_http_client().post( + self._url(path), + content=request_model.model_dump_json(), + headers=self._merged_headers({"Content-Type": "application/json"}), + timeout=self._timeout, + ) + except httpx.TimeoutException as exc: + raise DifyAgentTimeoutError(f"{operation} timed out") from exc + except httpx.RequestError as exc: + raise DifyAgentClientError(f"{operation} request failed: {exc}") from exc + def _validate_create_run_request(request: CreateRunRequest) -> CreateRunRequest: """Reject raw payloads so create-run uses the public request DTO boundary.""" @@ -639,6 +729,16 @@ def _validate_create_run_request(request: CreateRunRequest) -> CreateRunRequest: raise DifyAgentValidationError(detail="request must be a CreateRunRequest") +def _build_request_model[_RequestModelT: BaseModel]( + model_type: type[_RequestModelT], /, **payload: object +) -> _RequestModelT: + """Validate one request DTO built from method parameters.""" + try: + return model_type.model_validate(payload) + except ValidationError as exc: + raise DifyAgentValidationError(detail=exc.errors(include_url=False)) from exc + + def _parse_model_response(response: httpx.Response, model_type: type[_ResponseModelT]) -> _ResponseModelT: """Map HTTP errors and parse a Pydantic response DTO.""" _raise_for_status(response) diff --git a/dify-agent/src/dify_agent/layers/ask_human/__init__.py b/dify-agent/src/dify_agent/layers/ask_human/__init__.py new file mode 100644 index 00000000000..0138d7daf62 --- /dev/null +++ b/dify-agent/src/dify_agent/layers/ask_human/__init__.py @@ -0,0 +1,48 @@ +"""Client-safe exports for Dify ask-human layer DTOs and schema types. + +The runtime layer implementation lives in ``layer.py`` and imports server-side + execution helpers. Keep this package root import-safe for client code that only + needs to build run requests or understand deferred payload shapes. +""" + +from dify_agent.layers.ask_human.configs import ( + DEFAULT_ASK_HUMAN_TOOL_DESCRIPTION, + DIFY_ASK_HUMAN_LAYER_TYPE_ID, + DifyAskHumanLayerConfig, +) +from dify_agent.layers.ask_human.schema import ( + AskHumanAction, + AskHumanActionStyle, + AskHumanField, + AskHumanFieldType, + AskHumanFileField, + AskHumanFileListField, + AskHumanParagraphField, + AskHumanResultStatus, + AskHumanSelectField, + AskHumanSelectOption, + AskHumanSelectedAction, + AskHumanToolArgs, + AskHumanToolResult, + AskHumanUrgency, +) + +__all__ = [ + "AskHumanAction", + "AskHumanActionStyle", + "AskHumanField", + "AskHumanFieldType", + "AskHumanFileField", + "AskHumanFileListField", + "AskHumanParagraphField", + "AskHumanResultStatus", + "AskHumanSelectField", + "AskHumanSelectOption", + "AskHumanSelectedAction", + "AskHumanToolArgs", + "AskHumanToolResult", + "AskHumanUrgency", + "DEFAULT_ASK_HUMAN_TOOL_DESCRIPTION", + "DIFY_ASK_HUMAN_LAYER_TYPE_ID", + "DifyAskHumanLayerConfig", +] diff --git a/dify-agent/src/dify_agent/layers/ask_human/configs.py b/dify-agent/src/dify_agent/layers/ask_human/configs.py new file mode 100644 index 00000000000..432d0aad56c --- /dev/null +++ b/dify-agent/src/dify_agent/layers/ask_human/configs.py @@ -0,0 +1,136 @@ +"""Client-safe DTOs for the Dify ask-human layer. + +The public config controls only stable model-facing tool identity and guardrails. +Delivery, recipient selection, timeout policy, and other operational behavior are +intentionally out of scope for this layer and must stay outside the model-facing +tool contract. Setting ``enabled=False`` disables both ask-human tool exposure +and the prompt guidance that tells the model about these limits. Caller-provided +limits are additionally capped by small server hard limits so one composition +cannot widen the public deferred-tool surface arbitrarily. File field variants +are part of the schema vocabulary for forward compatibility, but they remain +invalid unless ``allow_file_fields=True`` and the allowed field-type list also +permits them. +""" + +from __future__ import annotations + +import re +from typing import ClassVar, Final + +from pydantic import ConfigDict, Field, field_validator, model_validator + +from agenton.layers import LayerConfig +from dify_agent.layers.ask_human.schema import AskHumanFieldType + + +DIFY_ASK_HUMAN_LAYER_TYPE_ID: Final[str] = "dify.ask_human" +DEFAULT_ASK_HUMAN_TOOL_DESCRIPTION: Final[str] = ( + "Ask a human for missing information or a decision that is required to continue. " + "Use this only when the answer cannot be inferred from the conversation, available tools, or current context. " + "Provide concise instructions, structured fields, and clear actions for the human." +) + +_TOOL_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +_HARD_MAX_FIELDS = 16 +_HARD_MAX_ACTIONS = 8 +_HARD_MAX_MARKDOWN_CHARS = 20_000 +_HARD_MAX_QUESTION_CHARS = 4_000 +_HARD_MAX_FIELD_LABEL_CHARS = 200 +_HARD_MAX_ACTION_LABEL_CHARS = 120 +_FILE_FIELD_TYPES: Final[frozenset[AskHumanFieldType]] = frozenset({"file", "file-list"}) + + +class DifyAskHumanLayerConfig(LayerConfig): + """Public config for the optional ask-human deferred tool layer. + + This DTO describes the exact model-facing guardrail surface that the runtime + will both validate and surface back to the model through prompt guidance. + ``enabled=False`` means callers keep the layer in composition data without + exposing either the tool or its instructions for that run. Numeric limits are + caller-configurable only within the server's hard caps, and file field types + are rejected unless callers opt in with ``allow_file_fields=True``. + """ + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + enabled: bool = True + tool_name: str = "ask_human" + tool_description: str | None = None + max_fields: int = Field(default=8, ge=0) + max_actions: int = Field(default=4, ge=1) + allowed_field_types: list[AskHumanFieldType] = Field(default_factory=lambda: ["paragraph", "select"]) + allow_file_fields: bool = False + max_markdown_chars: int = Field(default=8_000, ge=0) + max_question_chars: int = Field(default=1_000, ge=1) + max_field_label_chars: int = Field(default=120, ge=1) + max_action_label_chars: int = Field(default=80, ge=1) + + @property + def effective_tool_description(self) -> str: + """Return the configured description or the proposal default text.""" + return self.tool_description or DEFAULT_ASK_HUMAN_TOOL_DESCRIPTION + + @field_validator("tool_name") + @classmethod + def _validate_tool_name(cls, value: str) -> str: + if not _TOOL_NAME_PATTERN.fullmatch(value): + raise ValueError("tool_name must be a valid tool identifier") + return value + + @field_validator("tool_description") + @classmethod + def _normalize_tool_description(cls, value: str | None) -> str | None: + if value is None: + return None + stripped = value.strip() + return stripped or None + + @field_validator("allowed_field_types") + @classmethod + def _validate_allowed_field_types(cls, value: list[AskHumanFieldType]) -> list[AskHumanFieldType]: + if len(set(value)) != len(value): + raise ValueError("allowed_field_types must not contain duplicates") + return value + + @field_validator( + "max_fields", + "max_actions", + "max_markdown_chars", + "max_question_chars", + "max_field_label_chars", + "max_action_label_chars", + mode="after", + ) + @classmethod + def _validate_hard_limits(cls, value: int, info: object) -> int: + field_name = getattr(info, "field_name", "value") + hard_limits = { + "max_fields": _HARD_MAX_FIELDS, + "max_actions": _HARD_MAX_ACTIONS, + "max_markdown_chars": _HARD_MAX_MARKDOWN_CHARS, + "max_question_chars": _HARD_MAX_QUESTION_CHARS, + "max_field_label_chars": _HARD_MAX_FIELD_LABEL_CHARS, + "max_action_label_chars": _HARD_MAX_ACTION_LABEL_CHARS, + } + hard_limit = hard_limits[field_name] + if value > hard_limit: + raise ValueError(f"{field_name} must be <= {hard_limit}") + return value + + @model_validator(mode="after") + def _validate_file_field_policy(self) -> DifyAskHumanLayerConfig: + if not self.allow_file_fields: + forbidden = [field_type for field_type in self.allowed_field_types if field_type in _FILE_FIELD_TYPES] + if forbidden: + joined = ", ".join(forbidden) + raise ValueError( + f"allowed_field_types cannot include file field types when allow_file_fields is false: {joined}" + ) + return self + + +__all__ = [ + "DEFAULT_ASK_HUMAN_TOOL_DESCRIPTION", + "DIFY_ASK_HUMAN_LAYER_TYPE_ID", + "DifyAskHumanLayerConfig", +] diff --git a/dify-agent/src/dify_agent/layers/ask_human/layer.py b/dify-agent/src/dify_agent/layers/ask_human/layer.py new file mode 100644 index 00000000000..a94de9af72e --- /dev/null +++ b/dify-agent/src/dify_agent/layers/ask_human/layer.py @@ -0,0 +1,275 @@ +"""Runtime ask-human layer built on pydantic-ai external deferred tools. + +The layer contributes one optional external tool plus one prompt hint. The tool +never executes Python during the initial run; instead the model emits an +external deferred tool call that Dify Agent returns through ``run_succeeded`` as +``deferred_tool_call``. Guardrails are enforced in two places: + +* prompt/tool-definition guidance nudges the model toward valid requests, and +* runtime validation normalizes default actions and rejects out-of-policy calls. + +The layer stays product-neutral: downstream systems decide delivery, recipients, +timeouts, and authorization for the human request. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, ClassVar, cast + +from pydantic import JsonValue, ValidationError +from pydantic_ai import Tool +from pydantic_ai.exceptions import ModelRetry +from pydantic_ai.tools import DeferredToolRequests, RunContext, ToolDefinition +from typing_extensions import Self, override + +from agenton.layers import EmptyRuntimeState, NoLayerDeps, PydanticAILayer, PydanticAIPrompt, PydanticAITool +from dify_agent.layers.ask_human.configs import DIFY_ASK_HUMAN_LAYER_TYPE_ID, DifyAskHumanLayerConfig +from dify_agent.layers.ask_human.schema import ( + AskHumanAction, + AskHumanField, + AskHumanToolArgs, +) +from dify_agent.protocol.schemas import DeferredToolCallPayload, RunComposition + + +_ASK_HUMAN_DEFERRED_SCHEMA_VERSION = 1 +_DEFAULT_SUBMIT_ACTION = AskHumanAction(id="submit", label="Submit", style="primary") + + +@dataclass(slots=True) +class DifyAskHumanLayer(PydanticAILayer[NoLayerDeps, object, DifyAskHumanLayerConfig, EmptyRuntimeState]): + """State-free pydantic-ai layer that exposes the ask-human deferred tool.""" + + type_id: ClassVar[str | None] = DIFY_ASK_HUMAN_LAYER_TYPE_ID + + config: DifyAskHumanLayerConfig + + @classmethod + @override + def from_config(cls, config: DifyAskHumanLayerConfig) -> Self: + """Create the layer from validated public config.""" + return cls(config=DifyAskHumanLayerConfig.model_validate(config)) + + @property + @override + def prefix_prompts(self) -> list[PydanticAIPrompt[object]]: + if not self.config.enabled: + return [] + return [self.build_prompt_hint] + + @property + @override + def tools(self) -> list[PydanticAITool[object]]: + if not self.config.enabled: + return [] + return [ + Tool( + self._never_executed_tool, + takes_ctx=True, + name=self.config.tool_name, + description=self.config.effective_tool_description, + prepare=self._prepare_tool_definition, + args_validator=self._validate_tool_args, + sequential=True, + ) + ] + + def build_prompt_hint(self) -> str: + """Return the model-facing instruction text for ask-human guardrails.""" + allowed_field_types = ", ".join(self.config.allowed_field_types) if self.config.allowed_field_types else "none" + file_field_status = "enabled" if self.config.allow_file_fields else "disabled" + if self.config.max_fields == 0: + field_count_hint = "Do not add any fields." + else: + field_count_hint = f"Use at most {self.config.max_fields} field(s)." + return ( + f"You may call the external tool '{self.config.tool_name}' only when human input is required to continue. " + "Do not ask a human for information that can be inferred from the conversation, current context, or other tools.\n\n" + f"Ask-human guardrails:\n" + f"- Allowed field types: {allowed_field_types}.\n" + f"- File upload fields are {file_field_status}.\n" + f"- {field_count_hint}\n" + f"- Use at most {self.config.max_actions} action(s).\n" + f"- Keep 'question' under {self.config.max_question_chars} characters.\n" + f"- Keep 'markdown' under {self.config.max_markdown_chars} characters.\n" + f"- Keep each field label under {self.config.max_field_label_chars} characters.\n" + f"- Keep each action label under {self.config.max_action_label_chars} characters.\n" + "- If you omit actions, the system will add one primary action: Submit.\n" + "Prefer concise, structured requests that stay comfortably within these limits." + ) + + def build_deferred_tool_call_payload(self, requests: DeferredToolRequests) -> DeferredToolCallPayload: + """Validate and normalize the single supported deferred ask-human call.""" + if requests.approvals: + raise ValueError("ask_human does not support approval requests; use external deferred calls only") + + call_count = len(requests.calls) + if call_count != 1: + raise ValueError(f"ask_human supports exactly one deferred call per run in this version; got {call_count}.") + + call = requests.calls[0] + if call.tool_name != self.config.tool_name: + raise ValueError(f"ask_human deferred tool name must be '{self.config.tool_name}', got '{call.tool_name}'.") + + args = self._validate_and_normalize_tool_args( + title=None, + question="", + markdown=None, + fields=[], + actions=[], + urgency="normal", + raw_args=call.args, + ) + return DeferredToolCallPayload( + tool_call_id=call.tool_call_id, + tool_name=call.tool_name, + args=cast(JsonValue, args.model_dump(mode="json")), + metadata={ + "layer_type": self.type_id, + "tool_name": self.config.tool_name, + "schema_version": _ASK_HUMAN_DEFERRED_SCHEMA_VERSION, + }, + ) + + def _prepare_tool_definition(self, _ctx: RunContext[object], tool_def: ToolDefinition) -> ToolDefinition: + """Convert the ask-human tool into a pydantic-ai external deferred tool.""" + del tool_def + return ToolDefinition( + name=self.config.tool_name, + description=self.config.effective_tool_description, + parameters_json_schema=cast(dict[str, Any], AskHumanToolArgs.model_json_schema()), + strict=False, + sequential=True, + kind="external", + ) + + async def _never_executed_tool( + self, + _ctx: RunContext[object], + *, + title: str | None = None, + question: str, + markdown: str | None = None, + fields: list[AskHumanField] | None = None, + actions: list[AskHumanAction] | None = None, + urgency: str = "normal", + ) -> str: + del title, question, markdown, fields, actions, urgency + raise RuntimeError("ask_human is an external deferred tool and should not execute during the initial run") + + def _validate_tool_args( + self, + _ctx: RunContext[object], + *, + title: str | None = None, + question: str, + markdown: str | None = None, + fields: list[AskHumanField] | None = None, + actions: list[AskHumanAction] | None = None, + urgency: str = "normal", + ) -> None: + try: + _ = self._validate_and_normalize_tool_args( + title=title, + question=question, + markdown=markdown, + fields=fields or [], + actions=actions or [], + urgency=urgency, + ) + except (ValidationError, ValueError) as exc: + raise ModelRetry(str(exc)) from exc + + def _validate_and_normalize_tool_args( + self, + *, + title: str | None, + question: str, + markdown: str | None, + fields: list[AskHumanField], + actions: list[AskHumanAction], + urgency: str, + raw_args: str | dict[str, Any] | None = None, + ) -> AskHumanToolArgs: + if raw_args is not None: + args = _validate_tool_args_payload(raw_args) + else: + args = AskHumanToolArgs( + title=title, + question=question, + markdown=markdown, + fields=fields, + actions=actions, + urgency=cast(Any, urgency), + ) + + if len(args.fields) > self.config.max_fields: + raise ValueError(f"ask_human fields must contain at most {self.config.max_fields} item(s)") + + normalized_actions = list(args.actions) + if not normalized_actions: + normalized_actions = [_DEFAULT_SUBMIT_ACTION.model_copy()] + if len(normalized_actions) > self.config.max_actions: + raise ValueError(f"ask_human actions must contain at most {self.config.max_actions} item(s)") + + if len(args.question) > self.config.max_question_chars: + raise ValueError(f"ask_human question must be <= {self.config.max_question_chars} characters") + if args.markdown is not None and len(args.markdown) > self.config.max_markdown_chars: + raise ValueError(f"ask_human markdown must be <= {self.config.max_markdown_chars} characters") + + allowed_field_types = set(self.config.allowed_field_types) + for field in args.fields: + if field.type not in allowed_field_types: + raise ValueError(f"ask_human field type '{field.type}' is not allowed by this layer config") + if len(field.label) > self.config.max_field_label_chars: + raise ValueError( + f"ask_human field label '{field.label}' must be <= {self.config.max_field_label_chars} characters" + ) + if not self.config.allow_file_fields and field.type in {"file", "file-list"}: + raise ValueError("ask_human file fields are disabled by this layer config") + + for action in normalized_actions: + if len(action.label) > self.config.max_action_label_chars: + raise ValueError( + f"ask_human action label '{action.label}' must be <= {self.config.max_action_label_chars} characters" + ) + + return args.model_copy(update={"actions": normalized_actions}) + + +def validate_ask_human_layer_composition(composition: RunComposition) -> None: + """Reject unsupported public ask-human layer graph shapes.""" + ask_human_layers = [layer.name for layer in composition.layers if layer.type == DIFY_ASK_HUMAN_LAYER_TYPE_ID] + if len(ask_human_layers) > 1: + names = ", ".join(ask_human_layers) + raise ValueError(f"Only one '{DIFY_ASK_HUMAN_LAYER_TYPE_ID}' layer is supported. Found layers: {names}.") + + +def get_ask_human_layer(run: Any) -> DifyAskHumanLayer | None: + """Return the active ask-human layer when one is present and enabled.""" + matched: list[DifyAskHumanLayer] = [] + for slot in run.slots.values(): + layer = slot.layer + if isinstance(layer, DifyAskHumanLayer): + matched.append(layer) + if not matched: + return None + if len(matched) > 1: + raise ValueError(f"Only one '{DIFY_ASK_HUMAN_LAYER_TYPE_ID}' layer is supported per run.") + + layer = matched[0] + return layer if layer.config.enabled else None + + +def _validate_tool_args_payload(raw_args: str | dict[str, Any]) -> AskHumanToolArgs: + if isinstance(raw_args, str): + return AskHumanToolArgs.model_validate_json(raw_args or "{}") + return AskHumanToolArgs.model_validate(raw_args or {}) + + +__all__ = [ + "DifyAskHumanLayer", + "get_ask_human_layer", + "validate_ask_human_layer_composition", +] diff --git a/dify-agent/src/dify_agent/layers/ask_human/schema.py b/dify-agent/src/dify_agent/layers/ask_human/schema.py new file mode 100644 index 00000000000..59fb9d3fc70 --- /dev/null +++ b/dify-agent/src/dify_agent/layers/ask_human/schema.py @@ -0,0 +1,234 @@ +"""Product-neutral schemas for the Dify ask-human deferred tool contract. + +These models describe the model-facing tool arguments and the later human result +payload expected by resumed runs. Config-specific guardrails such as maximum +counts, allowed field types, or per-install length limits are enforced by +``dify_agent.layers.ask_human.layer`` so this module stays import-safe for +client code that only needs the stable wire/schema shapes. +""" + +from __future__ import annotations + +import re +from typing import Annotated, ClassVar, Literal + +from pydantic import BaseModel, ConfigDict, Field, JsonValue, ValidationInfo, field_validator, model_validator + + +_IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + +type AskHumanFieldType = Literal["paragraph", "select", "file", "file-list"] +type AskHumanActionStyle = Literal["default", "primary", "destructive"] +type AskHumanUrgency = Literal["normal", "high"] +type AskHumanResultStatus = Literal["submitted", "timeout", "cancelled", "unavailable"] + + +def is_valid_identifier(value: str) -> bool: + """Return whether ``value`` matches the stable ask-human identifier rules.""" + return bool(_IDENTIFIER_PATTERN.fullmatch(value)) + + +def _require_non_blank(value: str, *, label: str) -> str: + if not value.strip(): + raise ValueError(f"{label} must not be blank") + return value + + +class AskHumanSelectOption(BaseModel): + """One selectable option for an ask-human select field.""" + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + value: str = Field(min_length=1) + label: str = Field(min_length=1) + + @field_validator("value", "label") + @classmethod + def _validate_non_blank(cls, value: str, info: ValidationInfo) -> str: + return _require_non_blank(value, label=f"select option {info.field_name}") + + +class AskHumanFieldBase(BaseModel): + """Shared field properties for ask-human form fields.""" + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + name: str = Field(min_length=1) + label: str = Field(min_length=1) + required: bool = False + + @field_validator("name") + @classmethod + def _validate_name(cls, value: str) -> str: + if not is_valid_identifier(value): + raise ValueError("field name must be a valid identifier") + return value + + @field_validator("label") + @classmethod + def _validate_label(cls, value: str) -> str: + return _require_non_blank(value, label="field label") + + +class AskHumanParagraphField(AskHumanFieldBase): + """Free-text paragraph field.""" + + type: Literal["paragraph"] = "paragraph" + placeholder: str | None = None + default: str | None = None + + +class AskHumanSelectField(AskHumanFieldBase): + """Single-choice select field.""" + + type: Literal["select"] = "select" + options: list[AskHumanSelectOption] = Field(default_factory=list) + default: str | None = None + + @model_validator(mode="after") + def _validate_options(self) -> AskHumanSelectField: + if not self.options: + raise ValueError("select fields must define at least one option") + + seen_values: set[str] = set() + for option in self.options: + if option.value in seen_values: + raise ValueError(f"select field '{self.name}' contains duplicate option value '{option.value}'") + seen_values.add(option.value) + + if self.default is not None and self.default not in seen_values: + raise ValueError(f"select field '{self.name}' default must match one of its option values") + return self + + +class AskHumanFileField(AskHumanFieldBase): + """Single-file upload field.""" + + type: Literal["file"] = "file" + + +class AskHumanFileListField(AskHumanFieldBase): + """Multi-file upload field.""" + + type: Literal["file-list"] = "file-list" + max_files: int | None = Field(default=None, ge=1) + + +type AskHumanField = Annotated[ + AskHumanParagraphField | AskHumanSelectField | AskHumanFileField | AskHumanFileListField, + Field(discriminator="type"), +] + + +class AskHumanAction(BaseModel): + """One human-visible action rendered with an ask-human request.""" + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + id: str = Field(min_length=1) + label: str = Field(min_length=1) + style: AskHumanActionStyle = "default" + + @field_validator("id") + @classmethod + def _validate_id(cls, value: str) -> str: + if not is_valid_identifier(value): + raise ValueError("action id must be a valid identifier") + return value + + @field_validator("label") + @classmethod + def _validate_label(cls, value: str) -> str: + return _require_non_blank(value, label="action label") + + +class AskHumanSelectedAction(BaseModel): + """Action metadata returned with a human-submitted result.""" + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + id: str = Field(min_length=1) + label: str = Field(min_length=1) + + @field_validator("id") + @classmethod + def _validate_id(cls, value: str) -> str: + if not is_valid_identifier(value): + raise ValueError("selected action id must be a valid identifier") + return value + + @field_validator("label") + @classmethod + def _validate_label(cls, value: str) -> str: + return _require_non_blank(value, label="selected action label") + + +class AskHumanToolArgs(BaseModel): + """Arguments accepted by the ask-human external deferred tool.""" + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + title: str | None = None + question: str = Field(min_length=1) + markdown: str | None = None + fields: list[AskHumanField] = Field(default_factory=list) + actions: list[AskHumanAction] = Field(default_factory=list) + urgency: AskHumanUrgency = "normal" + + @field_validator("question") + @classmethod + def _validate_question(cls, value: str) -> str: + return _require_non_blank(value, label="question") + + @field_validator("title") + @classmethod + def _validate_title(cls, value: str | None) -> str | None: + if value is None: + return None + return _require_non_blank(value, label="title") + + @model_validator(mode="after") + def _validate_unique_ids(self) -> AskHumanToolArgs: + field_names: set[str] = set() + for field in self.fields: + if field.name in field_names: + raise ValueError(f"field name '{field.name}' must be unique") + field_names.add(field.name) + + action_ids: set[str] = set() + for action in self.actions: + if action.id in action_ids: + raise ValueError(f"action id '{action.id}' must be unique") + action_ids.add(action.id) + return self + + +class AskHumanToolResult(BaseModel): + """Expected value shape for a later deferred ask-human tool result.""" + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + status: AskHumanResultStatus + action: AskHumanSelectedAction | None = None + values: dict[str, JsonValue] = Field(default_factory=dict) + message: str | None = None + rendered_content: str | None = None + + +__all__ = [ + "AskHumanAction", + "AskHumanActionStyle", + "AskHumanField", + "AskHumanFieldType", + "AskHumanFileField", + "AskHumanFileListField", + "AskHumanParagraphField", + "AskHumanResultStatus", + "AskHumanSelectField", + "AskHumanSelectOption", + "AskHumanSelectedAction", + "AskHumanToolArgs", + "AskHumanToolResult", + "AskHumanUrgency", + "is_valid_identifier", +] diff --git a/dify-agent/src/dify_agent/layers/drive/__init__.py b/dify-agent/src/dify_agent/layers/drive/__init__.py new file mode 100644 index 00000000000..38ef6e66664 --- /dev/null +++ b/dify-agent/src/dify_agent/layers/drive/__init__.py @@ -0,0 +1,19 @@ +"""Client-safe exports for the Dify drive declaration layer DTOs. + +The layer implementation lives in the sibling ``layer`` module. Keep this +package root import-safe for client code that only builds run requests. +""" + +from dify_agent.layers.drive.configs import ( + DIFY_DRIVE_LAYER_TYPE_ID, + DifyDriveFileConfig, + DifyDriveLayerConfig, + DifyDriveSkillConfig, +) + +__all__ = [ + "DIFY_DRIVE_LAYER_TYPE_ID", + "DifyDriveFileConfig", + "DifyDriveLayerConfig", + "DifyDriveSkillConfig", +] diff --git a/dify-agent/src/dify_agent/layers/drive/configs.py b/dify-agent/src/dify_agent/layers/drive/configs.py new file mode 100644 index 00000000000..d07dbcb7cfb --- /dev/null +++ b/dify-agent/src/dify_agent/layers/drive/configs.py @@ -0,0 +1,67 @@ +"""Client-safe DTOs for the Dify drive declaration layer. + +The drive layer is a config-only manifest of the Skills & Files an agent has +in its drive. It is an index, never the content: each entry carries only a +display name, a model-facing description, and the drive key needed to fetch +the real bytes through the back proxy (``GET /inner/api/drive// +manifest`` → internal download URL). Inlining SKILL.md bodies here would break +the PRD's dynamic-loading principle and bloat every run request. + +The API backend catalogs and writes this config; the Agent backend consumes it +(ENG-387: pull via back proxy, lazy-load SKILL.md, materialize files). +""" + +from typing import Final + +from pydantic import BaseModel, ConfigDict, Field + +from agenton.layers import LayerConfig + + +DIFY_DRIVE_LAYER_TYPE_ID: Final[str] = "dify.drive" + + +class DifyDriveSkillConfig(BaseModel): + """Runtime declaration of one standardized skill — an index, not content.""" + + model_config = ConfigDict(extra="forbid") + + name: str + # The model judges from this description whether the skill is worth loading. + description: str + # "/SKILL.md" — the canonical entry document in the drive. + skill_md_key: str + # "/.DIFY-SKILL-FULL.zip" — full archive for restoring the complete skill. + archive_key: str | None = None + + +class DifyDriveFileConfig(BaseModel): + """Runtime declaration of one plain drive file.""" + + model_config = ConfigDict(extra="forbid") + + name: str + # "files/" — the drive key of the file value. + key: str + size: int | None = None + mime_type: str | None = None + + +class DifyDriveLayerConfig(LayerConfig): + """Config-only declaration layer: API writes the catalog, the agent pulls + the listed entries through the back proxy using ``drive_ref``.""" + + # "agent-" — storage addressing, deliberately explicit instead of + # derived from execution context so a shared (non-agent-bound) drive stays + # possible later. + drive_ref: str + skills: list[DifyDriveSkillConfig] = Field(default_factory=list) + files: list[DifyDriveFileConfig] = Field(default_factory=list) + + +__all__ = [ + "DIFY_DRIVE_LAYER_TYPE_ID", + "DifyDriveFileConfig", + "DifyDriveLayerConfig", + "DifyDriveSkillConfig", +] diff --git a/dify-agent/src/dify_agent/layers/drive/layer.py b/dify-agent/src/dify_agent/layers/drive/layer.py new file mode 100644 index 00000000000..3d5efb23d40 --- /dev/null +++ b/dify-agent/src/dify_agent/layers/drive/layer.py @@ -0,0 +1,34 @@ +"""Inert Dify drive declaration layer. + +Registering this layer makes ``dify.drive`` a known composition type id so a +run that carries the declaration never fails as "unknown layer type", even +before the consumption work (ENG-387) lands. It deliberately contributes no +prompt and no tools: a model that can see skill names but cannot read SKILL.md +would only hallucinate. The skills prompt (including the "pull SKILL.md via +drive" guidance) ships together with the consumption implementation. +""" + +from dataclasses import dataclass +from typing import ClassVar + +from typing_extensions import Self, override + +from agenton.layers import EmptyRuntimeState, NoLayerDeps, PlainLayer +from dify_agent.layers.drive.configs import DIFY_DRIVE_LAYER_TYPE_ID, DifyDriveLayerConfig + + +@dataclass(slots=True) +class DifyDriveLayer(PlainLayer[NoLayerDeps, DifyDriveLayerConfig, EmptyRuntimeState]): + """Config-only carrier of the drive Skills & Files manifest.""" + + type_id: ClassVar[str] = DIFY_DRIVE_LAYER_TYPE_ID + + config: DifyDriveLayerConfig + + @classmethod + @override + def from_config(cls, config: DifyDriveLayerConfig) -> Self: + return cls(config=config) + + +__all__ = ["DifyDriveLayer"] diff --git a/dify-agent/src/dify_agent/layers/shell/configs.py b/dify-agent/src/dify_agent/layers/shell/configs.py index b2255bb9fd5..fafab6a3bd9 100644 --- a/dify-agent/src/dify_agent/layers/shell/configs.py +++ b/dify-agent/src/dify_agent/layers/shell/configs.py @@ -18,20 +18,6 @@ DIFY_SHELL_LAYER_TYPE_ID: Final[str] = "dify.shell" _ENV_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") -class DifyShellCliToolConfig(BaseModel): - """One CLI tool declaration that can bootstrap itself in the sandbox.""" - - model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") - - name: str | None = Field(default=None, max_length=255) - install_commands: list[str] = Field(default_factory=list) - - @field_validator("install_commands") - @classmethod - def _reject_blank_install_commands(cls, value: list[str]) -> list[str]: - return [command for command in (item.strip() for item in value) if command] - - class DifyShellEnvVarConfig(BaseModel): """One shell environment variable exported for every sandbox command.""" @@ -64,6 +50,22 @@ class DifyShellSecretRefConfig(BaseModel): return value +class DifyShellCliToolConfig(BaseModel): + """One CLI tool declaration that can bootstrap itself in the sandbox.""" + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + name: str | None = Field(default=None, max_length=255) + install_commands: list[str] = Field(default_factory=list) + env: list[DifyShellEnvVarConfig] = Field(default_factory=list) + secret_refs: list[DifyShellSecretRefConfig] = Field(default_factory=list) + + @field_validator("install_commands") + @classmethod + def _reject_blank_install_commands(cls, value: list[str]) -> list[str]: + return [command for command in (item.strip() for item in value) if command] + + class DifyShellSandboxConfig(BaseModel): """Sandbox provider selection persisted in Agent Soul.""" diff --git a/dify-agent/src/dify_agent/layers/shell/layer.py b/dify-agent/src/dify_agent/layers/shell/layer.py index b51174d972d..52824e20050 100644 --- a/dify-agent/src/dify_agent/layers/shell/layer.py +++ b/dify-agent/src/dify_agent/layers/shell/layer.py @@ -19,12 +19,13 @@ Agenton still exits ``resource_context()`` but never transitions the layer to ``ACTIVE``. In that failed-enter path, normal suspend/delete hooks do not run, so the enter hook itself must perform best-effort business compensation before re-raising the failure. Agent Stub env injection uses shellctl's native per-run -``env`` argument only for user-visible ``shell.run``. +``env`` argument for user-visible ``shell.run`` and for trusted server-owned +fixed scripts executed through ``run_remote_script()``. """ from __future__ import annotations -from collections.abc import AsyncGenerator, Callable, Mapping, Sequence +from collections.abc import AsyncGenerator, Callable, Sequence from contextlib import asynccontextmanager import json import logging @@ -60,6 +61,7 @@ _SESSION_TIME_HEX_MASK = 0xFFFFF _SESSION_RANDOM_HEX_LENGTH = 2 _SESSION_ID_ATTEMPT_LIMIT = 256 _SESSION_ID_PATTERN = re.compile(r"^[0-9a-f]{7}$") +_REMOTE_COMMAND_MAX_OUTPUT_WINDOWS = 64 _SHELL_LAYER_PREFIX_PROMPT = """You have access to a shell layer. It provides four tools: 1. shell_run @@ -179,7 +181,7 @@ class ShellctlClientProtocol(Protocol): script: str, *, cwd: str | None = None, - env: Mapping[str, str] | None = None, + env: dict[str, str] | None = None, timeout: float = DEFAULT_TIMEOUT_SECONDS, ) -> JobResult: ... @@ -275,6 +277,20 @@ class DifyShellRuntimeState(BaseModel): return self +@dataclass(frozen=True, slots=True) +class RemoteCommandResult: + """Completed remote sandbox command returned to server-owned callers.""" + + job_id: str + status: str + done: bool + exit_code: int | None + output: str + offset: int + truncated: bool + output_path: str + + @dataclass(slots=True) class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerConfig, DifyShellRuntimeState]): """Shell tool layer backed by a live shellctl client while active. @@ -500,6 +516,35 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC except (RuntimeError, ValueError, ShellctlClientError) as exc: return _tool_error(str(exc), job_id=job_id) + async def run_remote_script( + self, + script: str, + *, + timeout: float = DEFAULT_TIMEOUT_SECONDS, + inject_agent_stub_env: bool = False, + ) -> RemoteCommandResult: + """Run one trusted server-side script inside the sandbox workspace. + + The sandbox file service uses this boundary for fixed list/read/upload + helpers. The layer owns output paging, transient shellctl job cleanup, + and optional Agent Stub env injection. + + Unlike model-visible ``shell.run``, this server-owned boundary does not + source ``.dify/env.sh``. That file is user-controlled shell config, so + sourcing it here would let sandbox code clobber trusted Agent Stub env + values before ``dify-agent file upload`` executes. + """ + env = None + if inject_agent_stub_env: + env = self._build_user_shell_run_env() + if env is None: + raise RuntimeError("Agent Stub environment injection is not available for this shell session.") + return await self._run_remote_job_to_completion( + script, + timeout=timeout, + env=env, + ) + async def _allocate_workspace(self) -> tuple[str, str]: """Allocate a unique ``~/workspace/`` directory by mkdir collision checks.""" for _attempt in range(_SESSION_ID_ATTEMPT_LIMIT): @@ -564,6 +609,44 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC self._track_job_result(result) return _job_result_observation(result) + async def _run_remote_job_to_completion( + self, + script: str, + *, + timeout: float, + env: dict[str, str] | None, + ) -> RemoteCommandResult: + """Run a workspace-scoped script to completion and delete its job state.""" + client = self._require_client() + job_id: str | None = None + try: + result = await client.run(script, cwd=self._require_workspace_cwd(), env=env, timeout=timeout) + job_id = result.job_id + self._track_job_result(result) + output_parts = [result.output] + truncated = result.truncated + windows = 1 + while (result.truncated or not result.done) and windows < _REMOTE_COMMAND_MAX_OUTPUT_WINDOWS: + result = await client.wait(result.job_id, offset=self._tracked_offset(result.job_id), timeout=timeout) + self._track_job_result(result) + output_parts.append(result.output) + truncated = truncated or result.truncated + windows += 1 + return RemoteCommandResult( + job_id=result.job_id, + status=result.status.value, + done=result.done, + exit_code=result.exit_code, + output="".join(output_parts), + offset=result.offset, + truncated=truncated, + output_path=result.output_path, + ) + finally: + if job_id is not None: + await self._delete_job_best_effort(job_id) + self._forget_tracked_job(job_id) + def _require_client(self) -> ShellctlClientProtocol: """Return the live client or reject tool/lifecycle use without one.""" if self._shellctl_client is None: @@ -635,31 +718,44 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC async def _delete_tracked_jobs_best_effort(self, job_ids: Sequence[str]) -> None: """Force-delete tracked shellctl jobs, ignoring already-missing ones.""" - client = self._require_client() for job_id in _deduplicate_preserving_order(job_ids): - try: - _ = await client.delete(job_id, force=True) - except ShellctlClientError as exc: - if exc.code == "job_not_found": - continue - logger.warning( - "Failed to delete shellctl job %s for session %s: %s", - job_id, - self.runtime_state.session_id, - exc, - ) - except RuntimeError as exc: - logger.warning( - "Failed to delete shellctl job %s for session %s: %s", - job_id, - self.runtime_state.session_id, - exc, - ) + await self._delete_job_best_effort(job_id) def _clear_tracked_jobs(self) -> None: self.runtime_state.job_offsets = {} self.runtime_state.job_ids = [] + async def _delete_job_best_effort(self, job_id: str) -> None: + client = self._require_client() + try: + _ = await client.delete(job_id, force=True) + except ShellctlClientError as exc: + if exc.code == "job_not_found": + return + logger.warning( + "Failed to delete shellctl job %s for session %s: %s", + job_id, + self.runtime_state.session_id, + exc, + ) + except RuntimeError as exc: + logger.warning( + "Failed to delete shellctl job %s for session %s: %s", + job_id, + self.runtime_state.session_id, + exc, + ) + + def _forget_tracked_job(self, job_id: str) -> None: + if job_id not in self.runtime_state.job_ids and job_id not in self.runtime_state.job_offsets: + return + job_offsets = dict(self.runtime_state.job_offsets) + _ = job_offsets.pop(job_id, None) + self.runtime_state.job_offsets = job_offsets + self.runtime_state.job_ids = [ + tracked_job_id for tracked_job_id in self.runtime_state.job_ids if tracked_job_id != job_id + ] + def _build_user_shell_run_env(self) -> dict[str, str] | None: """Build per-command Agent Stub env only for user-visible ``shell.run``.""" execution_context_layer = self.deps.execution_context @@ -730,6 +826,10 @@ def _workspace_cwd(session_id: str) -> str: def _workspace_bootstrap_script(config: DifyShellLayerConfig) -> str: """Return the workspace bootstrap script for env + CLI tool declarations.""" + has_bootstrap = bool(config.env or config.secret_refs or config.cli_tools or config.sandbox is not None) + if not has_bootstrap: + return "" + lines: list[str] = [ "set -eu", 'mkdir -p ".dify"', @@ -741,6 +841,11 @@ def _workspace_bootstrap_script(config: DifyShellLayerConfig) -> str: # Secret refs are resolved outside this public DTO. Preserve the env var # name without inventing a value so host-provided env can flow through. lines.append(f'export {secret_ref.name}="${{{secret_ref.name}:-}}"') + for tool in config.cli_tools: + for env_var in tool.env: + lines.append(f"export {env_var.name}={_shquote(env_var.value)}") + for secret_ref in tool.secret_refs: + lines.append(f'export {secret_ref.name}="${{{secret_ref.name}:-}}"') if config.sandbox is not None: if config.sandbox.provider: lines.append(f"export DIFY_SANDBOX_PROVIDER={_shquote(config.sandbox.provider)}") @@ -751,12 +856,13 @@ def _workspace_bootstrap_script(config: DifyShellLayerConfig) -> str: [ "DIFY_ENV_EOF", 'chmod 600 ".dify/env.sh"', + '. ".dify/env.sh"', ] ) for tool in config.cli_tools: for command in tool.install_commands: lines.append(command) - return "\n".join(lines) if len(lines) > 5 or config.cli_tools else "" + return "\n".join(lines) def _wrap_user_script(script: str) -> str: @@ -824,6 +930,7 @@ __all__ = [ "DifyShellLayerDeps", "DifyShellLayer", "DifyShellRuntimeState", + "RemoteCommandResult", "ShellctlClientFactory", "ShellctlClientProtocol", "create_shellctl_client_factory", diff --git a/dify-agent/src/dify_agent/protocol/__init__.py b/dify-agent/src/dify_agent/protocol/__init__.py index c31800e3bf9..46dc9c0ea10 100644 --- a/dify-agent/src/dify_agent/protocol/__init__.py +++ b/dify-agent/src/dify_agent/protocol/__init__.py @@ -14,6 +14,8 @@ from .schemas import ( CancelRunResponse, CreateRunRequest, CreateRunResponse, + DeferredToolCallPayload, + DeferredToolResultsPayload, EmptyRunEventData, LayerExitSignals, PydanticAIStreamRunEvent, @@ -25,8 +27,6 @@ from .schemas import ( RunEventsResponse, RunFailedEvent, RunFailedEventData, - RunPausedEvent, - RunPausedEventData, RunPurpose, RunLayerSpec, RunStartedEvent, @@ -37,6 +37,21 @@ from .schemas import ( normalize_composition, utc_now, ) +from .sandbox import ( + RuntimeLayerSpec, + SandboxFileEntry, + SandboxListRequest, + SandboxListResponse, + SandboxLocator, + SandboxReadRequest, + SandboxReadResponse, + SandboxUploadRequest, + SandboxUploadResponse, + SandboxUploadedFile, + build_sandbox_locator_from_layer_specs, + build_sandbox_locator_from_run_request, + extract_runtime_layer_specs, +) __all__ = [ "BaseRunEvent", @@ -44,6 +59,8 @@ __all__ = [ "CancelRunResponse", "CreateRunRequest", "CreateRunResponse", + "DeferredToolCallPayload", + "DeferredToolResultsPayload", "DIFY_AGENT_HISTORY_LAYER_ID", "DIFY_AGENT_MODEL_LAYER_ID", "DIFY_AGENT_OUTPUT_LAYER_ID", @@ -59,8 +76,6 @@ __all__ = [ "RunEventsResponse", "RunFailedEvent", "RunFailedEventData", - "RunPausedEvent", - "RunPausedEventData", "RunPurpose", "RunLayerSpec", "RunStartedEvent", @@ -68,6 +83,19 @@ __all__ = [ "RunStatusResponse", "RunSucceededEvent", "RunSucceededEventData", + "RuntimeLayerSpec", + "SandboxFileEntry", + "SandboxListRequest", + "SandboxListResponse", + "SandboxLocator", + "SandboxReadRequest", + "SandboxReadResponse", + "SandboxUploadRequest", + "SandboxUploadResponse", + "SandboxUploadedFile", + "build_sandbox_locator_from_layer_specs", + "build_sandbox_locator_from_run_request", + "extract_runtime_layer_specs", "normalize_composition", "utc_now", ] diff --git a/dify-agent/src/dify_agent/protocol/sandbox.py b/dify-agent/src/dify_agent/protocol/sandbox.py new file mode 100644 index 00000000000..3d7b9aad129 --- /dev/null +++ b/dify-agent/src/dify_agent/protocol/sandbox.py @@ -0,0 +1,246 @@ +"""Public sandbox DTOs shared by the API and Dify Agent backends. + +The sandbox file APIs must rebuild only the minimum runtime needed to re-enter a +prior shell session: ``dify.execution_context`` for Dify-owned identity and +``dify.shell`` for the sandbox workspace itself. ``SandboxLocator`` therefore +contains a safe composition subset plus the matching filtered session snapshot. +Credential-bearing plugin layers are intentionally excluded from persisted +runtime specs and from sandbox locators. +""" + +from __future__ import annotations + +from typing import ClassVar, Literal, cast + +from agenton.compositor import CompositorSessionSnapshot +from agenton.compositor.schemas import LayerSessionSnapshot +from dify_agent.layers.dify_plugin import DIFY_PLUGIN_LLM_LAYER_TYPE_ID, DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID +from pydantic import BaseModel, ConfigDict, Field, JsonValue + +from .schemas import CreateRunRequest, RunComposition, RunLayerSpec + +_SENSITIVE_LAYER_TYPES = frozenset({DIFY_PLUGIN_LLM_LAYER_TYPE_ID, DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID}) + + +class RuntimeLayerSpec(BaseModel): + """Persistable non-sensitive layer spec derived from a run composition. + + API-side runtime-session rows store these specs so later cleanup or sandbox + requests can rebuild the minimal layer graph without persisting model or + tool credentials. + """ + + name: str + type: str + deps: dict[str, str] = Field(default_factory=dict) + metadata: dict[str, JsonValue] = Field(default_factory=dict) + config: JsonValue = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class SandboxLocator(BaseModel): + """Safe subset of one prior run request needed to re-enter a sandbox shell.""" + + composition: RunComposition + session_snapshot: CompositorSessionSnapshot + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class SandboxFileEntry(BaseModel): + """One directory entry returned by ``/sandbox/files/list``.""" + + name: str + type: Literal["file", "dir", "symlink", "other"] + size: int | None = None + mtime: int | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class SandboxListRequest(BaseModel): + """Request body for listing a sandbox directory.""" + + locator: SandboxLocator + path: str = "." + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class SandboxListResponse(BaseModel): + """Structured sandbox directory listing.""" + + path: str + entries: list[SandboxFileEntry] + truncated: bool + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class SandboxReadRequest(BaseModel): + """Request body for reading a sandbox file preview.""" + + locator: SandboxLocator + path: str + max_bytes: int = 262144 + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class SandboxReadResponse(BaseModel): + """Text preview returned by ``/sandbox/files/read``.""" + + path: str + size: int | None = None + truncated: bool + binary: bool + text: str | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class SandboxUploadedFile(BaseModel): + """Canonical ToolFile mapping returned after sandbox upload.""" + + transfer_method: Literal["tool_file"] = "tool_file" + reference: str + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class SandboxUploadRequest(BaseModel): + """Request body for uploading one sandbox file through the Agent Stub.""" + + locator: SandboxLocator + path: str + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class SandboxUploadResponse(BaseModel): + """Result returned after sandbox upload creates a ToolFile mapping.""" + + path: str + file: SandboxUploadedFile + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +def extract_runtime_layer_specs(composition: RunComposition) -> list[RuntimeLayerSpec]: + """Project a run composition into the persistable non-sensitive layer list.""" + specs: list[RuntimeLayerSpec] = [] + for layer in composition.layers: + if layer.type in _SENSITIVE_LAYER_TYPES: + continue + config_value: JsonValue = None + if isinstance(layer.config, BaseModel): + config_value = layer.config.model_dump(mode="json", warnings=False) + else: + config_value = cast(JsonValue, layer.config) + specs.append( + RuntimeLayerSpec( + name=layer.name, + type=layer.type, + deps=dict(layer.deps), + metadata=dict(layer.metadata), + config=config_value, + ) + ) + return specs + + +def build_sandbox_locator_from_run_request(request: CreateRunRequest) -> SandboxLocator: + """Build a safe sandbox locator from a full create-run request. + + Raises: + ValueError: if the request has no resumable session snapshot or lacks the + execution-context/shell layers needed for sandbox access. + """ + if request.session_snapshot is None: + raise ValueError("Sandbox locator requires a non-empty session_snapshot.") + return build_sandbox_locator_from_layer_specs( + layer_specs=extract_runtime_layer_specs(request.composition), + session_snapshot=request.session_snapshot, + ) + + +def build_sandbox_locator_from_layer_specs( + *, + layer_specs: list[RuntimeLayerSpec], + session_snapshot: CompositorSessionSnapshot, +) -> SandboxLocator: + """Build a sandbox locator from persisted runtime specs plus a saved snapshot.""" + if not layer_specs: + raise ValueError("Sandbox locator requires persisted runtime layer specs.") + + for spec in layer_specs: + if spec.type in _SENSITIVE_LAYER_TYPES: + raise ValueError(f"Sandbox locator runtime specs must not include sensitive layer type {spec.type!r}.") + + execution_context_index = next( + (index for index, spec in enumerate(layer_specs) if spec.name == "execution_context"), + None, + ) + shell_index = next((index for index, spec in enumerate(layer_specs) if spec.name == "shell"), None) + if execution_context_index is None: + raise ValueError("Sandbox locator requires an 'execution_context' runtime layer spec.") + if shell_index is None: + raise ValueError("Sandbox locator requires a 'shell' runtime layer spec.") + if execution_context_index > shell_index: + raise ValueError("Sandbox locator requires 'execution_context' to appear before 'shell'.") + + execution_context_spec = layer_specs[execution_context_index] + shell_spec = layer_specs[shell_index] + if shell_spec.deps.get("execution_context") != execution_context_spec.name: + raise ValueError("Sandbox shell layer must depend on the execution_context layer.") + + kept_specs = [execution_context_spec, shell_spec] + kept_names = [spec.name for spec in kept_specs] + snapshot_layers = [layer for layer in session_snapshot.layers if layer.name in set(kept_names)] + if [layer.name for layer in snapshot_layers] != kept_names: + raise ValueError("Sandbox locator session_snapshot must contain execution_context and shell layers in order.") + + return SandboxLocator( + composition=RunComposition( + schema_version=1, + layers=[ + RunLayerSpec( + name=spec.name, + type=spec.type, + deps=dict(spec.deps), + metadata=dict(spec.metadata), + config=spec.config, + ) + for spec in kept_specs + ], + ), + session_snapshot=CompositorSessionSnapshot( + schema_version=session_snapshot.schema_version, + layers=[ + LayerSessionSnapshot( + name=layer.name, + lifecycle_state=layer.lifecycle_state, + runtime_state=dict(layer.runtime_state), + ) + for layer in snapshot_layers + ], + ), + ) + + +__all__ = [ + "RuntimeLayerSpec", + "SandboxFileEntry", + "SandboxListRequest", + "SandboxListResponse", + "SandboxLocator", + "SandboxReadRequest", + "SandboxReadResponse", + "SandboxUploadRequest", + "SandboxUploadResponse", + "SandboxUploadedFile", + "build_sandbox_locator_from_layer_specs", + "build_sandbox_locator_from_run_request", + "extract_runtime_layer_specs", +] diff --git a/dify-agent/src/dify_agent/protocol/schemas.py b/dify-agent/src/dify_agent/protocol/schemas.py index 9a989976c71..77501942666 100644 --- a/dify-agent/src/dify_agent/protocol/schemas.py +++ b/dify-agent/src/dify_agent/protocol/schemas.py @@ -5,9 +5,9 @@ producers, storage adapters, and Python client. Create-run requests expose a Dify-friendly ``composition.layers[].config`` shape so callers can describe one layer in one place; the server normalizes that public DTO into Agenton's state-only ``CompositorConfig`` plus node-name keyed per-run configs before -calling ``Compositor.enter(configs=...)``. Session snapshots and ``on_exit`` stay -top-level because they are per-run resume state and exit policy, not graph node -definition. +calling ``Compositor.enter(configs=...)``. Session snapshots, deferred tool +results, and ``on_exit`` stay top-level because they are per-run resume state or +exit policy, not graph node definition. The server still constructs layers only from explicit provider type ids, keeping HTTP input data-only and preventing unsafe import-path construction. Run events @@ -22,21 +22,25 @@ by ``DIFY_AGENT_MODEL_LAYER_ID``, the optional history layer named by by ``DIFY_AGENT_OUTPUT_LAYER_ID``. Request-level ``on_exit`` signals decide whether each active layer is suspended or deleted when the run exits, with suspend as the default so successful terminal events can include resumable -snapshots. Successful runs publish the final JSON-safe agent output and the -resumable Agenton session snapshot together on the terminal ``run_succeeded`` -event so consumers can treat terminal events as complete run summaries. Session -snapshots carry only layer lifecycle/runtime state in compositor order; they do -not persist output-layer config. Resumed structured-output runs therefore must -resubmit the same ``output`` layer in ``composition.layers[]`` so snapshot layer -name/order still matches the composition and the runtime can rebuild the same -structured output contract. +snapshots. Successful runs always publish the resumable Agenton session snapshot +on the terminal ``run_succeeded`` event together with exactly one of the final +JSON-safe ``output`` or a deferred external ``deferred_tool_call`` payload. That +lets consumers treat terminal success events as complete run summaries without a +separate pause protocol. Session snapshots carry only layer lifecycle/runtime +state in compositor order; they do not persist output-layer config. Resumed +structured-output runs therefore must resubmit the same ``output`` layer in +``composition.layers[]`` so snapshot layer name/order still matches the +composition and the runtime can rebuild the same structured output contract. """ +from __future__ import annotations + from datetime import datetime, timezone from typing import Annotated, ClassVar, Final, Literal, TypeAlias -from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter +from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter, model_serializer, model_validator from pydantic_ai.messages import AgentStreamEvent +from pydantic_ai.tools import DeferredToolResults from agenton.compositor import CompositorConfig, CompositorSessionSnapshot, LayerConfigInput, LayerNodeConfig from agenton.layers import ExitIntent @@ -45,12 +49,11 @@ from agenton.layers import ExitIntent DIFY_AGENT_MODEL_LAYER_ID: Final[str] = "llm" DIFY_AGENT_HISTORY_LAYER_ID: Final[str] = "history" DIFY_AGENT_OUTPUT_LAYER_ID: Final[str] = "output" -RunStatus = Literal["running", "paused", "succeeded", "failed", "cancelled"] +RunStatus = Literal["running", "succeeded", "failed", "cancelled"] RunPurpose = Literal["workflow_node", "single_step", "agent_app", "babysit", "fasten_preview"] RunEventType = Literal[ "run_started", "pydantic_ai_event", - "run_paused", "run_succeeded", "run_failed", "run_cancelled", @@ -121,7 +124,15 @@ class CreateRunRequest(BaseModel): keep snapshot compatibility and rebuild the output schema. Dify tenant, user, and run-correlation identifiers must be submitted through a ``dify.execution_context`` entry in ``composition.layers[]``; there is no - parallel top-level ``execution_context`` request field. + parallel top-level ``execution_context`` request field. External deferred + tool continuation input belongs in the top-level ``deferred_tool_results`` + field rather than inside composition. Resume requests are therefore expected + to pair a prior ``session_snapshot`` with the same logical composition so + Agenton can rebuild the same layers and message history. For ask-human + continuation specifically, the matching pending tool call must still exist + in prior history state; callers should keep the history layer active across + runs so deferred tool results can be matched against the original model + response instead of starting a fresh user-prompt turn. """ composition: RunComposition @@ -129,6 +140,7 @@ class CreateRunRequest(BaseModel): idempotency_key: str | None = None metadata: dict[str, JsonValue] = Field(default_factory=dict) session_snapshot: CompositorSessionSnapshot | None = None + deferred_tool_results: DeferredToolResultsPayload | None = None on_exit: LayerExitSignals = Field(default_factory=LayerExitSignals) model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") @@ -213,14 +225,59 @@ class EmptyRunEventData(BaseModel): model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") -class RunSucceededEventData(BaseModel): - """Terminal success payload for final output and resumable session state.""" +class DeferredToolResultsPayload(BaseModel): + """Public JSON-safe DTO for deferred external tool results supplied on resume.""" - output: JsonValue + calls: dict[str, JsonValue] = Field(default_factory=dict) + metadata: dict[str, dict[str, JsonValue]] = Field(default_factory=dict) + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + def to_pydantic_ai(self) -> DeferredToolResults: + """Convert the public DTO into pydantic-ai's resume input dataclass.""" + return DeferredToolResults( + calls=dict(self.calls), + metadata={key: dict(value) for key, value in self.metadata.items()}, + ) + + +class DeferredToolCallPayload(BaseModel): + """Terminal success payload for one deferred external tool request.""" + + tool_call_id: str + tool_name: str + args: JsonValue + metadata: dict[str, JsonValue] = Field(default_factory=dict) + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class RunSucceededEventData(BaseModel): + """Terminal success payload for final output or deferred tool continuation.""" + + output: JsonValue | None = None + deferred_tool_call: DeferredToolCallPayload | None = None session_snapshot: CompositorSessionSnapshot model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + @model_validator(mode="after") + def _validate_result_shape(self) -> RunSucceededEventData: + has_output = "output" in self.model_fields_set + has_deferred_tool_call = "deferred_tool_call" in self.model_fields_set + if has_output == has_deferred_tool_call: + raise ValueError("Exactly one of output or deferred_tool_call must be set") + return self + + @model_serializer(mode="plain") + def _serialize_active_result(self) -> dict[str, object]: + data: dict[str, object] = {"session_snapshot": self.session_snapshot} + if "output" in self.model_fields_set: + data["output"] = self.output + if "deferred_tool_call" in self.model_fields_set: + data["deferred_tool_call"] = self.deferred_tool_call + return data + class RunFailedEventData(BaseModel): """Terminal failure payload shown to polling and SSE consumers.""" @@ -231,16 +288,6 @@ class RunFailedEventData(BaseModel): model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") -class RunPausedEventData(BaseModel): - """Pause payload used for human handoff or other resumable waits.""" - - reason: str - message: str | None = None - session_snapshot: CompositorSessionSnapshot | None = None - - model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") - - class RunCancelledEventData(BaseModel): """Terminal cancellation payload for explicit user/operator cancellation.""" @@ -288,13 +335,6 @@ class RunFailedEvent(BaseRunEvent): data: RunFailedEventData -class RunPausedEvent(BaseRunEvent): - """Resumable pause event emitted when a run waits for outside input.""" - - type: Literal["run_paused"] = "run_paused" - data: RunPausedEventData - - class RunCancelledEvent(BaseRunEvent): """Terminal cancellation event emitted after an explicit cancel request.""" @@ -303,12 +343,7 @@ class RunCancelledEvent(BaseRunEvent): RunEvent: TypeAlias = Annotated[ - RunStartedEvent - | PydanticAIStreamRunEvent - | RunPausedEvent - | RunSucceededEvent - | RunFailedEvent - | RunCancelledEvent, + RunStartedEvent | PydanticAIStreamRunEvent | RunSucceededEvent | RunFailedEvent | RunCancelledEvent, Field(discriminator="type"), ] RUN_EVENT_ADAPTER: TypeAdapter[RunEvent] = TypeAdapter(RunEvent) @@ -330,6 +365,8 @@ __all__ = [ "CancelRunResponse", "CreateRunRequest", "CreateRunResponse", + "DeferredToolCallPayload", + "DeferredToolResultsPayload", "DIFY_AGENT_HISTORY_LAYER_ID", "DIFY_AGENT_MODEL_LAYER_ID", "DIFY_AGENT_OUTPUT_LAYER_ID", @@ -345,8 +382,6 @@ __all__ = [ "RunEventsResponse", "RunFailedEvent", "RunFailedEventData", - "RunPausedEvent", - "RunPausedEventData", "RunPurpose", "RunStartedEvent", "RunStatus", diff --git a/dify-agent/src/dify_agent/runtime/compositor_factory.py b/dify-agent/src/dify_agent/runtime/compositor_factory.py index 8454513af22..81bfcd48e2f 100644 --- a/dify-agent/src/dify_agent/runtime/compositor_factory.py +++ b/dify-agent/src/dify_agent/runtime/compositor_factory.py @@ -2,9 +2,11 @@ Only explicitly allowed provider type ids are constructible here. The default provider set contains prompt layers, the optional pydantic-ai history layer, the -state-free Dify structured output layer, the Dify execution-context layer, the -stateful Dify shell layer, and the Dify plugin business-layer family: +state-free Dify structured output layer, the optional Dify ask-human layer, the +Dify execution-context layer, the stateful Dify shell layer, and the Dify +plugin business-layer family: +- ``dify.drive`` for the inert Skills & Files drive declaration, - ``dify.execution_context`` for shared tenant/user/run daemon context, - ``dify.shell`` for shellctl-backed shell job control, - ``dify.plugin.llm`` for plugin-backed model selection, and @@ -33,8 +35,10 @@ from agenton_collections.layers.plain.basic import PromptLayer from agenton_collections.transformers.pydantic_ai import PYDANTIC_AI_TRANSFORMERS from dify_agent.agent_stub.server.shell_agent_stub_env import ShellAgentStubTokenFactory from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec +from dify_agent.layers.ask_human.layer import DifyAskHumanLayer from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer +from dify_agent.layers.drive.layer import DifyDriveLayer from dify_agent.layers.execution_context.configs import DifyExecutionContextLayerConfig from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer from dify_agent.layers.output.output_layer import DifyOutputLayer @@ -80,6 +84,11 @@ def create_default_layer_providers( LayerProvider.from_layer_type(PromptLayer), LayerProvider.from_layer_type(PydanticAIHistoryLayer), LayerProvider.from_layer_type(DifyOutputLayer), + LayerProvider.from_layer_type(DifyAskHumanLayer), + # Inert declaration layer: makes ``dify.drive`` a known type id so runs + # carrying the Skills & Files manifest never fail before the consumption + # work (ENG-387) lands. Deliberately contributes no prompt and no tools. + LayerProvider.from_layer_type(DifyDriveLayer), LayerProvider.from_factory( layer_type=DifyExecutionContextLayer, create=lambda config: DifyExecutionContextLayer.from_config_with_settings( diff --git a/dify-agent/src/dify_agent/runtime/event_sink.py b/dify-agent/src/dify_agent/runtime/event_sink.py index 6567189c699..80cbf76cbd9 100644 --- a/dify-agent/src/dify_agent/runtime/event_sink.py +++ b/dify-agent/src/dify_agent/runtime/event_sink.py @@ -3,19 +3,21 @@ The runner only needs append-only event writes and status transitions, so tests can use ``InMemoryRunEventSink`` without Redis. Production storage implements the same protocol with Redis streams in ``dify_agent.storage.redis_run_store``. The -terminal success helper writes the final JSON-safe output and session snapshot in -one event so event consumers can stop at ``run_succeeded`` without correlating -separate payload events. +terminal success helper writes either the final JSON-safe output or one deferred +tool request together with the resumable session snapshot in a single event so +consumers can stop at ``run_succeeded`` without correlating separate payload +events. """ from collections import defaultdict -from typing import Protocol +from typing import Protocol, cast from pydantic import JsonValue from pydantic_ai.messages import AgentStreamEvent from agenton.compositor import CompositorSessionSnapshot from dify_agent.protocol.schemas import ( + DeferredToolCallPayload, EmptyRunEventData, PydanticAIStreamRunEvent, RunEvent, @@ -29,6 +31,9 @@ from dify_agent.protocol.schemas import ( ) +_UNSET = object() + + class RunEventSink(Protocol): """Boundary used by runtime code to publish observable run progress.""" @@ -95,15 +100,31 @@ async def emit_run_succeeded( sink: RunEventSink, *, run_id: str, - output: JsonValue, + output: JsonValue | None | object = _UNSET, + deferred_tool_call: DeferredToolCallPayload | object = _UNSET, session_snapshot: CompositorSessionSnapshot, ) -> str: - """Emit the terminal success event with output and resumable state.""" + """Emit the terminal success event with output or deferred continuation. + + Callers must activate exactly one result branch. ``_UNSET`` is used instead + of ``None`` to preserve the distinction between an omitted inactive branch + and an active ``output`` branch whose JSON value is explicitly ``null``. + Without that sentinel, ``output=None`` would be indistinguishable from + “output field absent”, which would break nullable-success payloads. + """ + data: dict[str, JsonValue | DeferredToolCallPayload | CompositorSessionSnapshot | None] = { + "session_snapshot": session_snapshot, + } + if output is not _UNSET: + data["output"] = cast(JsonValue | None, output) + if deferred_tool_call is not _UNSET: + data["deferred_tool_call"] = cast(DeferredToolCallPayload, deferred_tool_call) + return await emit_run_event( sink, event=RunSucceededEvent( run_id=run_id, - data=RunSucceededEventData(output=output, session_snapshot=session_snapshot), + data=RunSucceededEventData.model_validate(data), created_at=utc_now(), ), ) diff --git a/dify-agent/src/dify_agent/runtime/runner.py b/dify-agent/src/dify_agent/runtime/runner.py index 9458b5e7e33..8a6d7b9bd91 100644 --- a/dify-agent/src/dify_agent/runtime/runner.py +++ b/dify-agent/src/dify_agent/runtime/runner.py @@ -3,36 +3,47 @@ The runner is storage-agnostic: it normalizes the public Dify composition into Agenton's graph/config split, enters a fresh ``CompositorRun`` (or resumes one from a snapshot), renders the current Dify system prompts into temporary -``message_history``, runs pydantic-ai with ``run.user_prompts`` as the current -user input, emits stream events, applies request-level ``on_exit`` signals, and -then publishes a terminal success or failure event. The Pydantic AI model is -resolved from the active Agenton layer named by ``DIFY_AGENT_MODEL_LAYER_ID``. -An optional history layer contributes stored message history only through -session state; successful runs append only ``result.new_messages()`` back into -that layer so current system prompts are not persisted. An optional structured -output layer named by ``DIFY_AGENT_OUTPUT_LAYER_ID`` is read after entry and -resolved into an output contract whose type both exposes the output schema to -the model and performs runtime JSON Schema validation through custom Pydantic -hooks. Invalid structured outputs therefore trigger Pydantic AI's normal -output-validation retry behavior before Dify Agent emits ``run_succeeded``. -Layers still never own the FastAPI lifespan-owned plugin daemon HTTP client. -Successful terminal events contain both the JSON-safe final output and session -snapshot; there are no separate output or snapshot events to correlate. +``message_history``, runs pydantic-ai with either the current ``run.user_prompts`` +or deferred external tool results, emits stream events, applies request-level +``on_exit`` signals, and then publishes a terminal success or failure event. The +Pydantic AI model is resolved from the active Agenton layer named by +``DIFY_AGENT_MODEL_LAYER_ID``. An optional history layer contributes stored +message history only through session state; successful runs append only +``result.new_messages()`` back into that layer so current system prompts are not +persisted. An optional structured output layer named by +``DIFY_AGENT_OUTPUT_LAYER_ID`` is read after entry and resolved into an output +contract whose type both exposes the output schema to the model and performs +runtime JSON Schema validation through custom Pydantic hooks. When the ask-human +layer is active, the runtime also allows ``DeferredToolRequests`` output and +publishes that deferred request through the normal ``run_succeeded`` event as +``deferred_tool_call`` instead of a final ``output``. Invalid structured outputs +or invalid deferred-tool behavior still trigger normal retries/failures before +Dify Agent emits success. Layers still never own the FastAPI lifespan-owned +plugin daemon HTTP client. """ from collections.abc import AsyncIterable from collections import Counter -from typing import Any, cast +from dataclasses import dataclass +from typing import Any, Literal, cast import httpx from pydantic import JsonValue, TypeAdapter from pydantic_ai.messages import AgentStreamEvent +from pydantic_ai.output import OutputSpec +from pydantic_ai.tools import DeferredToolRequests, DeferredToolResults from agenton.compositor import CompositorSessionSnapshot, LayerProviderInput from agenton.layers.types import PydanticAITool +from dify_agent.layers.ask_human.layer import get_ask_human_layer, validate_ask_human_layer_composition from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer -from dify_agent.protocol.schemas import DIFY_AGENT_MODEL_LAYER_ID, CreateRunRequest, normalize_composition +from dify_agent.protocol.schemas import ( + CreateRunRequest, + DIFY_AGENT_MODEL_LAYER_ID, + DeferredToolCallPayload, + normalize_composition, +) from dify_agent.runtime.agent_factory import create_agent, normalize_user_input from dify_agent.runtime.agenton_validation import is_agenton_enter_validation_runtime_error from dify_agent.runtime.compositor_factory import build_pydantic_ai_compositor, create_default_layer_providers @@ -61,6 +72,16 @@ class AgentRunValidationError(ValueError): """Raised when a run request is valid JSON but cannot execute.""" +@dataclass(slots=True) +class RunSuccessOutcome: + """Normalized successful runner output before event emission.""" + + result_kind: Literal["output", "deferred_tool_call"] + output: JsonValue | None + deferred_tool_call: DeferredToolCallPayload | None + session_snapshot: CompositorSessionSnapshot + + class AgentRunRunner: """Executes one run and writes only public run events to its sink.""" @@ -92,7 +113,7 @@ class AgentRunRunner: _ = await emit_run_started(self.sink, run_id=self.run_id) try: - output, session_snapshot = await self._run_agent() + outcome = await self._run_agent() except Exception as exc: message = str(exc) or type(exc).__name__ _ = await emit_run_failed(self.sink, run_id=self.run_id, error=message) @@ -102,12 +123,16 @@ class AgentRunRunner: _ = await emit_run_succeeded( self.sink, run_id=self.run_id, - output=output, - session_snapshot=session_snapshot, + **( + {"output": outcome.output} + if outcome.result_kind == "output" + else {"deferred_tool_call": outcome.deferred_tool_call} + ), + session_snapshot=outcome.session_snapshot, ) await self.sink.update_status(self.run_id, "succeeded") - async def _run_agent(self) -> tuple[JsonValue, CompositorSessionSnapshot]: + async def _run_agent(self) -> RunSuccessOutcome: """Run pydantic-ai inside an entered Agenton run. Known request-shaped Agenton enter-time failures are normalized to @@ -128,6 +153,7 @@ class AgentRunRunner: try: validate_output_layer_composition(self.request.composition) validate_history_layer_composition(self.request.composition) + validate_ask_human_layer_composition(self.request.composition) graph_config, layer_configs = normalize_composition(self.request.composition) compositor = build_pydantic_ai_compositor(graph_config, providers=self.layer_providers) validate_layer_exit_signals(compositor, self.request.on_exit) @@ -135,12 +161,16 @@ class AgentRunRunner: raise AgentRunValidationError(str(exc)) from exc entered_run = False + output: JsonValue | None = None + deferred_tool_call: DeferredToolCallPayload | None = None + result_kind: Literal["output", "deferred_tool_call"] | None = None try: async with compositor.enter(configs=layer_configs, session_snapshot=self.request.session_snapshot) as run: entered_run = True apply_layer_exit_signals(run, self.request.on_exit) user_prompts = run.user_prompts - if not has_non_blank_user_prompt(user_prompts): + deferred_tool_results = _resolve_deferred_tool_results(self.request) + if deferred_tool_results is None and not has_non_blank_user_prompt(user_prompts): raise AgentRunValidationError(EMPTY_USER_PROMPTS_ERROR) async def handle_events(_ctx: object, events: AsyncIterable[AgentStreamEvent]) -> None: @@ -154,24 +184,44 @@ class AgentRunRunner: system_prompts=run.prompts, stored_history=history_layer.message_history if history_layer is not None else (), ) + ask_human_layer = get_ask_human_layer(run) llm_layer = run.get_layer(DIFY_AGENT_MODEL_LAYER_ID, DifyPluginLLMLayer) model = llm_layer.get_model(http_client=self.plugin_daemon_http_client) tools = await _resolve_run_tools(run, http_client=self.plugin_daemon_http_client) except (KeyError, TypeError, RuntimeError, ValueError) as exc: raise AgentRunValidationError(str(exc)) from exc + if deferred_tool_results is not None and history_layer is None: + raise AgentRunValidationError( + "Deferred tool results require a 'history' layer with prior message history." + ) + agent = create_agent( model, tools=tools, - output_type=output_contract.output_type, + output_type=_resolve_agent_output_type(output_contract.output_type, ask_human_layer is not None), ) result = await agent.run( - normalize_user_input(user_prompts), + None if deferred_tool_results is not None else normalize_user_input(user_prompts), message_history=message_history, + deferred_tool_results=deferred_tool_results, event_stream_handler=handle_events, ) - output = _serialize_agent_output(result.output) append_successful_run_history(history_layer, result.new_messages()) + if isinstance(result.output, DeferredToolRequests): + if ask_human_layer is None: + raise AgentRunValidationError( + "Deferred tool requests were returned, but no active ask_human layer is available for validation." + ) + if history_layer is None: + raise AgentRunValidationError( + "ask_human deferred tool requests require a 'history' layer so the pending tool call can be resumed." + ) + deferred_tool_call = ask_human_layer.build_deferred_tool_call_payload(result.output) + result_kind = "deferred_tool_call" + else: + output = _serialize_agent_output(result.output) + result_kind = "output" except RuntimeError as exc: if not entered_run and is_agenton_enter_validation_runtime_error(exc): raise AgentRunValidationError(str(exc)) from exc @@ -183,8 +233,15 @@ class AgentRunRunner: if run.session_snapshot is None: raise RuntimeError("Agenton run did not produce a session snapshot after exit.") + if result_kind is None: + raise RuntimeError("Agent run did not resolve either a final output or a deferred tool call.") - return output, run.session_snapshot + return RunSuccessOutcome( + result_kind=result_kind, + output=output, + deferred_tool_call=deferred_tool_call, + session_snapshot=run.session_snapshot, + ) def _serialize_agent_output(output: object) -> JsonValue: @@ -192,6 +249,20 @@ def _serialize_agent_output(output: object) -> JsonValue: return cast(JsonValue, _AGENT_OUTPUT_ADAPTER.dump_python(output, mode="json")) +def _resolve_agent_output_type(output_type: OutputSpec[object], allow_deferred_tools: bool) -> OutputSpec[object]: + """Return the run output type, optionally augmented with deferred-tool support.""" + if not allow_deferred_tools: + return output_type + return cast(OutputSpec[object], [output_type, DeferredToolRequests]) + + +def _resolve_deferred_tool_results(request: CreateRunRequest) -> DeferredToolResults | None: + """Convert public deferred tool results into the pydantic-ai resume input.""" + if request.deferred_tool_results is None: + return None + return request.deferred_tool_results.to_pydantic_ai() + + async def _resolve_run_tools( run: Any, *, diff --git a/dify-agent/src/dify_agent/server/app.py b/dify-agent/src/dify_agent/server/app.py index 90e422b6432..f4eab601a2e 100644 --- a/dify-agent/src/dify_agent/server/app.py +++ b/dify-agent/src/dify_agent/server/app.py @@ -25,9 +25,9 @@ from dify_agent.agent_stub.server.router import create_agent_stub_router from dify_agent.runtime.compositor_factory import create_default_layer_providers from dify_agent.runtime.run_scheduler import RunScheduler from dify_agent.server.routes.runs import create_runs_router -from dify_agent.server.routes.workspace_files import create_workspace_files_router +from dify_agent.server.routes.sandbox_files import create_sandbox_files_router +from dify_agent.server.sandbox_files import SandboxFileService from dify_agent.server.settings import ServerSettings -from dify_agent.server.workspace_files import WorkspaceFileService from dify_agent.storage.redis_run_store import RedisRunStore @@ -44,13 +44,8 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI: agent_stub_url=resolved_settings.agent_stub_url, agent_stub_token_codec=agent_stub_token_codec, ) - workspace_file_service = ( - WorkspaceFileService( - shellctl_entrypoint=resolved_settings.shellctl_entrypoint, - shellctl_auth_token=resolved_settings.shellctl_auth_token, - ) - if resolved_settings.shellctl_entrypoint - else None + sandbox_file_service = ( + SandboxFileService(layer_providers=layer_providers) if resolved_settings.shellctl_entrypoint else None ) state: dict[str, object] = {} @@ -100,8 +95,7 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI: return state["scheduler"] # pyright: ignore[reportReturnType] app.include_router(create_runs_router(get_store, get_scheduler)) - # TODO: refactor - app.include_router(create_workspace_files_router(lambda: workspace_file_service)) + app.include_router(create_sandbox_files_router(lambda: sandbox_file_service)) app.include_router( create_agent_stub_router( token_codec=agent_stub_token_codec, diff --git a/dify-agent/src/dify_agent/server/routes/sandbox_files.py b/dify-agent/src/dify_agent/server/routes/sandbox_files.py new file mode 100644 index 00000000000..10429620cc9 --- /dev/null +++ b/dify-agent/src/dify_agent/server/routes/sandbox_files.py @@ -0,0 +1,73 @@ +"""FastAPI routes for sandbox file operations. + +The agent backend receives a structured ``SandboxLocator`` rather than a raw +shell session id. Routes stay private-network only like ``/runs`` and forward +all sandbox work to ``SandboxFileService``. +""" + +from collections.abc import Callable +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException + +from dify_agent.protocol import ( + SandboxListRequest, + SandboxListResponse, + SandboxReadRequest, + SandboxReadResponse, + SandboxUploadRequest, + SandboxUploadResponse, +) +from dify_agent.server.sandbox_files import SandboxFileError, SandboxFileService + + +def create_sandbox_files_router(get_service: Callable[[], SandboxFileService | None]) -> APIRouter: + """Create sandbox file routes bound to the app's service provider.""" + router = APIRouter(prefix="/sandbox", tags=["sandbox"]) + + def service_dep() -> SandboxFileService: + service = get_service() + if service is None: + raise HTTPException( + status_code=503, + detail={"code": "sandbox_backend_unavailable", "message": "sandbox service is not configured"}, + ) + return service + + def raise_http(exc: SandboxFileError) -> HTTPException: + return HTTPException(status_code=exc.status_code, detail={"code": exc.code, "message": exc.message}) + + @router.post("/files/list", response_model=SandboxListResponse) + async def list_files( + request: SandboxListRequest, + service: Annotated[SandboxFileService, Depends(service_dep)], + ) -> SandboxListResponse: + try: + return await service.list_files(request) + except SandboxFileError as exc: + raise raise_http(exc) from exc + + @router.post("/files/read", response_model=SandboxReadResponse) + async def read_file( + request: SandboxReadRequest, + service: Annotated[SandboxFileService, Depends(service_dep)], + ) -> SandboxReadResponse: + try: + return await service.read_file(request) + except SandboxFileError as exc: + raise raise_http(exc) from exc + + @router.post("/files/upload", response_model=SandboxUploadResponse) + async def upload_file( + request: SandboxUploadRequest, + service: Annotated[SandboxFileService, Depends(service_dep)], + ) -> SandboxUploadResponse: + try: + return await service.upload_file(request) + except SandboxFileError as exc: + raise raise_http(exc) from exc + + return router + + +__all__ = ["create_sandbox_files_router"] diff --git a/dify-agent/src/dify_agent/server/routes/workspace_files.py b/dify-agent/src/dify_agent/server/routes/workspace_files.py deleted file mode 100644 index 22e4003f863..00000000000 --- a/dify-agent/src/dify_agent/server/routes/workspace_files.py +++ /dev/null @@ -1,78 +0,0 @@ -"""FastAPI routes for read-only inspection of shell-layer workspaces. - -These endpoints back the Dify "sandbox file system" inspector. They are -read-only and scoped to a single ``~/workspace/`` directory; the -heavy lifting (path containment, PTY-safe transport) lives in -``WorkspaceFileService``. Like the runs router, they rely on network isolation -rather than per-request auth. -""" - -from collections.abc import Callable -from typing import Annotated - -from fastapi import APIRouter, Depends, HTTPException, Query - -from dify_agent.server.workspace_files import ( - WorkspaceDownloadResponse, - WorkspaceFileError, - WorkspaceFileService, - WorkspaceListResponse, - WorkspacePreviewResponse, -) - - -def create_workspace_files_router( - get_service: Callable[[], WorkspaceFileService | None], -) -> APIRouter: - """Create read-only workspace file routes bound to the app's service provider.""" - router = APIRouter(prefix="/workspaces", tags=["workspaces"]) - - def service_dep() -> WorkspaceFileService: - service = get_service() - if service is None: - raise HTTPException( - status_code=503, - detail="workspace inspector is not configured (no shellctl entrypoint)", - ) - return service - - def _raise_http(exc: WorkspaceFileError) -> HTTPException: - return HTTPException(status_code=exc.status_code, detail={"code": exc.code, "message": exc.message}) - - @router.get("/{session_id}/files", response_model=WorkspaceListResponse) - async def list_files( - session_id: str, - service: Annotated[WorkspaceFileService, Depends(service_dep)], - path: str = Query(default="."), - ) -> WorkspaceListResponse: - try: - return await service.list_dir(session_id, path) - except WorkspaceFileError as exc: - raise _raise_http(exc) from exc - - @router.get("/{session_id}/files/preview", response_model=WorkspacePreviewResponse) - async def preview_file( - session_id: str, - service: Annotated[WorkspaceFileService, Depends(service_dep)], - path: str = Query(...), - ) -> WorkspacePreviewResponse: - try: - return await service.preview(session_id, path) - except WorkspaceFileError as exc: - raise _raise_http(exc) from exc - - @router.get("/{session_id}/files/download", response_model=WorkspaceDownloadResponse) - async def download_file( - session_id: str, - service: Annotated[WorkspaceFileService, Depends(service_dep)], - path: str = Query(...), - ) -> WorkspaceDownloadResponse: - try: - return await service.download(session_id, path) - except WorkspaceFileError as exc: - raise _raise_http(exc) from exc - - return router - - -__all__ = ["create_workspace_files_router"] diff --git a/dify-agent/src/dify_agent/server/sandbox_files.py b/dify-agent/src/dify_agent/server/sandbox_files.py new file mode 100644 index 00000000000..034fcf1d06b --- /dev/null +++ b/dify-agent/src/dify_agent/server/sandbox_files.py @@ -0,0 +1,399 @@ +"""Sandbox file service that re-enters prior shell sessions through the shell layer. + +Unlike the removed workspace inspector, this service never talks to shellctl +directly and never reads sandbox files outside the shell layer. It rebuilds a +minimal compositor from ``SandboxLocator``, enters the saved +``execution_context`` + ``shell`` layers, and executes fixed scripts through +``DifyShellLayer.run_remote_script()``. + +The scripts still frame their structured payloads with a PTY-safe +base64-between-sentinels envelope. shellctl jobs are tmux-backed, so raw JSON can +be wrapped or surrounded by prompt noise; the framing keeps list/read/upload +responses parseable without falling back to direct shellctl file access. +""" + +from __future__ import annotations + +import json +import base64 +import binascii +import shlex +import textwrap +from dataclasses import dataclass +from typing import TypeVar, cast + +from dify_agent.layers.shell.layer import DifyShellLayer, RemoteCommandResult +from dify_agent.protocol import ( + SandboxListRequest, + SandboxListResponse, + SandboxLocator, + SandboxReadRequest, + SandboxReadResponse, + SandboxUploadRequest, + SandboxUploadResponse, + normalize_composition, +) +from pydantic import BaseModel, ValidationError +from dify_agent.runtime.compositor_factory import DifyAgentLayerProvider, build_pydantic_ai_compositor + +_LIST_MAX_ENTRIES = 1000 +_LIST_TIMEOUT_SECONDS = 10.0 +_READ_TIMEOUT_SECONDS = 15.0 +_UPLOAD_TIMEOUT_SECONDS = 30.0 +_OUTPUT_BEGIN = "<<>>" +_OUTPUT_END = "<<>>" +ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel) + +_LIST_SCRIPT = """ +import base64 +import json +import stat +import sys +from pathlib import Path + + +BEGIN = "<<>>" +END = "<<>>" + + +def emit(payload): + blob = base64.b64encode(json.dumps(payload, ensure_ascii=False).encode("utf-8")).decode("ascii") + print(BEGIN + blob + END) + + +def resolve_target(root: Path, raw_path: str) -> tuple[Path | None, str]: + target = (root / raw_path).resolve() + if target != root and root not in target.parents: + emit({"error": "invalid_sandbox_path", "message": "path escapes the sandbox workspace"}) + return None, raw_path + return target, raw_path + + +root = Path.cwd().resolve() +raw_path = sys.argv[1] +limit = int(sys.argv[2]) +target_info = resolve_target(root, raw_path) +if target_info[0] is None: + sys.exit(0) +target, normalized_path = target_info + +if not target.exists(): + emit({"error": "sandbox_path_not_found", "message": "path not found in sandbox"}) + sys.exit(0) +if not target.is_dir(): + emit({"error": "sandbox_path_not_readable", "message": "path is not a directory"}) + sys.exit(0) + +entries = [] +for child in sorted(target.iterdir(), key=lambda item: item.name)[:limit]: + child_stat = child.lstat() + mode = child_stat.st_mode + if stat.S_ISLNK(mode): + entry_type = "symlink" + elif stat.S_ISDIR(mode): + entry_type = "dir" + elif stat.S_ISREG(mode): + entry_type = "file" + else: + entry_type = "other" + entries.append( + { + "name": child.name, + "type": entry_type, + "size": int(child_stat.st_size), + "mtime": int(child_stat.st_mtime), + } + ) + +emit( + { + "path": normalized_path, + "entries": entries, + "truncated": len(list(target.iterdir())) > limit, + } +) +""" + +_READ_SCRIPT = """ +import base64 +import json +import sys +from pathlib import Path + + +BEGIN = "<<>>" +END = "<<>>" + + +def emit(payload): + blob = base64.b64encode(json.dumps(payload, ensure_ascii=False).encode("utf-8")).decode("ascii") + print(BEGIN + blob + END) + + +root = Path.cwd().resolve() +raw_path = sys.argv[1] +max_bytes = int(sys.argv[2]) +target = (root / raw_path).resolve() +if target != root and root not in target.parents: + emit({"error": "invalid_sandbox_path", "message": "path escapes the sandbox workspace"}) + sys.exit(0) +if not target.exists(): + emit({"error": "sandbox_path_not_found", "message": "path not found in sandbox"}) + sys.exit(0) +if not target.is_file(): + emit({"error": "sandbox_path_not_readable", "message": "path is not a readable file"}) + sys.exit(0) + +size = int(target.stat().st_size) +with target.open("rb") as file_obj: + data = file_obj.read(max_bytes + 1) + +truncated = len(data) > max_bytes +data = data[:max_bytes] +try: + text = data.decode("utf-8") +except UnicodeDecodeError: + emit( + { + "path": raw_path, + "size": size, + "truncated": truncated, + "binary": True, + "text": None, + } + ) + sys.exit(0) + +emit( + { + "path": raw_path, + "size": size, + "truncated": truncated, + "binary": False, + "text": text, + } +) +""" + +_UPLOAD_SCRIPT = """ +import base64 +import json +import subprocess +import sys +from pathlib import Path + + +BEGIN = "<<>>" +END = "<<>>" + + +def emit(payload): + blob = base64.b64encode(json.dumps(payload, ensure_ascii=False).encode("utf-8")).decode("ascii") + print(BEGIN + blob + END) + + +root = Path.cwd().resolve() +raw_path = sys.argv[1] +target = (root / raw_path).resolve() +if target != root and root not in target.parents: + emit({"error": "invalid_sandbox_path", "message": "path escapes the sandbox workspace"}) + sys.exit(0) +if not target.exists(): + emit({"error": "sandbox_path_not_found", "message": "path not found in sandbox"}) + sys.exit(0) +if not target.is_file(): + emit({"error": "sandbox_path_not_readable", "message": "path is not a readable file"}) + sys.exit(0) + +command = ["dify-agent", "file", "upload", raw_path] +completed = subprocess.run(command, capture_output=True, text=True, check=False) +if completed.returncode != 0: + emit( + { + "error": "agent_stub_upload_failed", + "message": (completed.stderr or completed.stdout or f"upload exited with code {completed.returncode}").strip(), + } + ) + sys.exit(0) + +try: + file_mapping = json.loads(completed.stdout) +except ValueError as exc: + emit({"error": "agent_stub_upload_failed", "message": f"upload returned invalid JSON: {exc}"}) + sys.exit(0) + +emit({"path": raw_path, "file": file_mapping}) +""" + + +class SandboxFileError(Exception): + """Sandbox file failure mapped to HTTP by the FastAPI route layer.""" + + code: str + message: str + status_code: int + + def __init__(self, code: str, message: str, *, status_code: int = 400) -> None: + super().__init__(message) + self.code = code + self.message = message + self.status_code = status_code + + +@dataclass(slots=True) +class SandboxFileService: + """Execute fixed sandbox file operations through the saved shell session.""" + + layer_providers: tuple[DifyAgentLayerProvider, ...] + + async def list_files(self, request: SandboxListRequest) -> SandboxListResponse: + normalized_path = _normalize_sandbox_path(request.path, allow_current_directory=True) + payload = await self._run_locator_script( + request.locator, + script_source=_LIST_SCRIPT, + args=[normalized_path, str(_LIST_MAX_ENTRIES)], + timeout=_LIST_TIMEOUT_SECONDS, + inject_agent_stub_env=False, + ) + return _validate_response_model(SandboxListResponse, payload) + + async def read_file(self, request: SandboxReadRequest) -> SandboxReadResponse: + normalized_path = _normalize_sandbox_path(request.path, allow_current_directory=False) + payload = await self._run_locator_script( + request.locator, + script_source=_READ_SCRIPT, + args=[normalized_path, str(request.max_bytes)], + timeout=_READ_TIMEOUT_SECONDS, + inject_agent_stub_env=False, + ) + return _validate_response_model(SandboxReadResponse, payload) + + async def upload_file(self, request: SandboxUploadRequest) -> SandboxUploadResponse: + normalized_path = _normalize_sandbox_path(request.path, allow_current_directory=False) + payload = await self._run_locator_script( + request.locator, + script_source=_UPLOAD_SCRIPT, + args=[normalized_path], + timeout=_UPLOAD_TIMEOUT_SECONDS, + inject_agent_stub_env=True, + ) + return _validate_response_model(SandboxUploadResponse, payload) + + async def _run_locator_script( + self, + locator: SandboxLocator, + *, + script_source: str, + args: list[str], + timeout: float, + inject_agent_stub_env: bool, + ) -> dict[str, object]: + try: + graph_config, layer_configs = normalize_composition(locator.composition) + compositor = build_pydantic_ai_compositor(graph_config, providers=self.layer_providers) + async with compositor.enter(configs=layer_configs, session_snapshot=locator.session_snapshot) as run: + run.suspend_on_exit() + shell_layer = run.get_layer("shell", DifyShellLayer) + result = await shell_layer.run_remote_script( + _build_python_script_command(script_source=script_source, args=args), + timeout=timeout, + inject_agent_stub_env=inject_agent_stub_env, + ) + except (KeyError, TypeError, ValueError) as exc: + raise SandboxFileError("invalid_sandbox_locator", str(exc), status_code=400) from exc + except RuntimeError as exc: + raise SandboxFileError("sandbox_command_failed", str(exc), status_code=502) from exc + + return _decode_sandbox_payload(result) + + +def _normalize_sandbox_path(path: str, *, allow_current_directory: bool) -> str: + normalized = (path or "").strip() + if normalized in {"", ".", "./"}: + if allow_current_directory: + return "." + raise SandboxFileError("invalid_sandbox_path", "path must not be blank", status_code=400) + if normalized.startswith("/") or normalized.startswith("~"): + raise SandboxFileError( + "invalid_sandbox_path", "path must be relative to the sandbox workspace", status_code=400 + ) + if "\x00" in normalized or any(ord(ch) < 0x20 for ch in normalized): + raise SandboxFileError("invalid_sandbox_path", "path contains unsupported control characters", status_code=400) + return normalized + + +def _build_python_script_command(*, script_source: str, args: list[str]) -> str: + quoted_args = " ".join(shlex.quote(value) for value in args) + script = textwrap.dedent(script_source).strip() + return f"python3 - {quoted_args} <<'PY'\n{script}\nPY" + + +def _decode_sandbox_payload(result: RemoteCommandResult) -> dict[str, object]: + if result.exit_code not in (0, None): + raise SandboxFileError( + "sandbox_command_failed", + f"sandbox command exited with code {result.exit_code}: {_output_tail(result.output)!r}", + status_code=502, + ) + begin = result.output.find(_OUTPUT_BEGIN) + end = result.output.find(_OUTPUT_END, begin + len(_OUTPUT_BEGIN)) if begin != -1 else -1 + if begin == -1 or end == -1: + raise SandboxFileError( + "sandbox_command_failed", + "sandbox command returned no framed payload", + status_code=502, + ) + blob = result.output[begin + len(_OUTPUT_BEGIN) : end] + compact = "".join(blob.split()) + try: + decoded = base64.b64decode(compact, validate=True) + loaded = cast(object, json.loads(decoded.decode("utf-8"))) + except (binascii.Error, ValueError) as exc: + raise SandboxFileError( + "sandbox_command_failed", + f"sandbox command returned invalid framed payload: {exc}", + status_code=502, + ) from exc + if not isinstance(loaded, dict): + raise SandboxFileError( + "sandbox_command_failed", "sandbox command returned a non-object payload", status_code=502 + ) + payload = cast(dict[str, object], loaded) + error = payload.get("error") + if isinstance(error, str): + status_code = ( + 404 + if error in {"sandbox_not_found", "sandbox_path_not_found"} + else 502 + if error == "agent_stub_upload_failed" + else 400 + ) + if error in {"sandbox_command_failed", "agent_stub_upload_failed"}: + status_code = 502 + message = payload.get("message") + raise SandboxFileError( + error, + str(message) if isinstance(message, str) and message else error, + status_code=status_code, + ) + return payload + + +def _output_tail(output: str) -> str: + stripped = output.strip() + return stripped[-200:] + + +def _validate_response_model( + model_type: type[ResponseModelT], + payload: dict[str, object], +) -> ResponseModelT: + try: + return model_type.model_validate(payload) + except ValidationError as exc: + raise SandboxFileError( + "sandbox_command_failed", f"sandbox command returned invalid payload: {exc}", status_code=502 + ) from exc + + +__all__ = ["SandboxFileError", "SandboxFileService"] diff --git a/dify-agent/src/dify_agent/server/workspace_files.py b/dify-agent/src/dify_agent/server/workspace_files.py deleted file mode 100644 index 3991a9df4a1..00000000000 --- a/dify-agent/src/dify_agent/server/workspace_files.py +++ /dev/null @@ -1,418 +0,0 @@ -"""Read-only inspector for a shell-layer workspace (``~/workspace/``). - -The ``dify.shell`` layer runs the agent's bash in a per-session workspace that -lives on the shellctl host. shellctl exposes only job control (run/wait/...), so -there is no native file API: the only way to read those files is to run a -read-only command inside the workspace and capture its output. - -This service does exactly that, safely: - -* It runs a fixed Python reader (no shell parsing of user input) via - ``ShellctlClient.run``. The reader is delivered base64-encoded and all - user-controlled values (workspace root, relative path, op, size caps) are - passed through the environment, never interpolated into the command. -* Path containment is enforced inside the reader with ``realpath`` against the - workspace root, so ``..`` and symlink escapes are rejected. -* The reader emits its result as a single base64 blob between sentinels. base64 - tolerates the newlines a PTY inserts when wrapping long lines, so the payload - survives tmux capture intact; we strip whitespace before decoding. - -Only listing, text/binary preview, and download are supported; everything is -read-only and scoped to the workspace. -""" - -from __future__ import annotations - -import base64 -import binascii -import json -import logging -import re -from collections.abc import Callable -from dataclasses import dataclass -from typing import Literal, Protocol, cast - -from pydantic import BaseModel, Field -from shell_session_manager.shellctl.client import ShellctlClient, ShellctlClientError -from shell_session_manager.shellctl.shared import MAX_OUTPUT_LIMIT_BYTES, JobResult, TerminalSize - -logger = logging.getLogger(__name__) - -# Mirrors the dify.shell layer's workspace session-id contract (5+2 lowercase -# hex). Kept local so this read-only inspector does not depend on the layer's -# private helpers; the layer remains the source of truth for the format. -_SESSION_ID_PATTERN = re.compile(r"^[0-9a-f]{7}$") - -# Result sentinels emitted by the reader; chosen to be PTY/shell-noise resistant. -_BEGIN = "<<>>" -_END = "<<>>" - -# Conservative read caps (tunable). The download cap leaves headroom under the -# 1 MiB shellctl output window after base64 + JSON overhead, paged when needed. -PREVIEW_MAX_BYTES = 256 * 1024 -DOWNLOAD_MAX_BYTES = 8 * 1024 * 1024 -LIST_MAX_ENTRIES = 1000 -_READ_TIMEOUT_SECONDS = 20.0 -# Upper bound on output windows paged per request (backstop against a runaway -# job); DOWNLOAD_MAX_BYTES of base64 fits comfortably within this many 1 MiB windows. -_MAX_OUTPUT_WINDOWS = 64 - -# Fixed Python reader. Receives all inputs via the environment so no user value -# is ever interpolated into a shell command. Emits one base64 blob of JSON -# between the sentinels. -_READER_SOURCE = """ -import base64, json, os, stat, sys - -BEGIN = "<<>>" -END = "<<>>" - - -def emit(obj): - blob = base64.b64encode(json.dumps(obj).encode("utf-8")).decode("ascii") - sys.stdout.write(BEGIN + blob + END + "\\n") - sys.stdout.flush() - - -op = os.environ.get("DIFY_FS_OP", "") -root = os.path.realpath(os.path.expanduser(os.environ.get("DIFY_FS_ROOT", ""))) -rel = os.environ.get("DIFY_FS_REL", "") -max_bytes = int(os.environ.get("DIFY_FS_MAX", "0") or "0") -list_limit = int(os.environ.get("DIFY_FS_LIST_LIMIT", "1000") or "1000") - -if not os.path.isdir(root): - emit({"error": "workspace_not_found"}) - sys.exit(0) - -target = os.path.realpath(os.path.join(root, rel)) -if target != root and not target.startswith(root + os.sep): - emit({"error": "path_escape"}) - sys.exit(0) -if not os.path.exists(target): - emit({"error": "not_found"}) - sys.exit(0) - - -def entry_for(name, p): - st = os.lstat(p) - mode = st.st_mode - if stat.S_ISLNK(mode): - etype = "symlink" - elif stat.S_ISDIR(mode): - etype = "dir" - else: - etype = "file" - return {"name": name, "type": etype, "size": int(st.st_size), "mtime": int(st.st_mtime)} - - -if op == "list": - if not os.path.isdir(target): - emit({"error": "not_a_directory"}) - sys.exit(0) - names = sorted(os.listdir(target)) - truncated = len(names) > list_limit - entries = [entry_for(n, os.path.join(target, n)) for n in names[:list_limit]] - emit({"entries": entries, "truncated": truncated}) -elif op in ("preview", "download"): - if os.path.isdir(target): - emit({"error": "is_a_directory"}) - sys.exit(0) - size = int(os.path.getsize(target)) - with open(target, "rb") as f: - data = f.read(max_bytes + 1) - truncated = len(data) > max_bytes - data = data[:max_bytes] - content_b64 = base64.b64encode(data).decode("ascii") - payload = {"size": size, "truncated": truncated, "content_base64": content_b64} - if op == "preview": - try: - data.decode("utf-8") - payload["binary"] = False - except UnicodeDecodeError: - payload["binary"] = True - emit(payload) -else: - emit({"error": "bad_op"}) - sys.exit(0) -""" - -_READER_B64 = base64.b64encode(_READER_SOURCE.encode("utf-8")).decode("ascii") - - -class WorkspaceFileError(Exception): - """Read failure mapped to an HTTP status by the route layer.""" - - code: str - message: str - status_code: int - - def __init__(self, code: str, message: str, *, status_code: int = 400) -> None: - super().__init__(message) - self.code = code - self.message = message - self.status_code = status_code - - -# error code emitted by the reader -> (http status, client message) -_READER_ERROR_HTTP: dict[str, tuple[int, str]] = { - "workspace_not_found": (404, "workspace does not exist"), - "not_found": (404, "path not found in workspace"), - "path_escape": (400, "path escapes the workspace"), - "not_a_directory": (400, "path is not a directory"), - "is_a_directory": (400, "path is a directory"), - "bad_op": (400, "unsupported operation"), -} - - -class WorkspaceFileEntry(BaseModel): - """One entry in a workspace directory listing.""" - - name: str - type: Literal["file", "dir", "symlink"] - size: int - mtime: int - - -class WorkspaceListResponse(BaseModel): - """Directory listing of a workspace path.""" - - path: str - entries: list[WorkspaceFileEntry] - truncated: bool = Field(description="True when the directory had more than LIST_MAX_ENTRIES entries.") - - -class WorkspacePreviewResponse(BaseModel): - """Inline preview of a workspace file.""" - - path: str - size: int - truncated: bool - binary: bool - # text is omitted for binary files - text: str | None = None - - -class WorkspaceDownloadResponse(BaseModel): - """Raw bytes (base64) of a workspace file for download.""" - - path: str - size: int - truncated: bool - content_base64: str - - -class ShellctlReadClient(Protocol): - """The shellctl job-control surface this read-only inspector relies on.""" - - async def run(self, script: str, *, timeout: float = ..., terminal: TerminalSize | None = ...) -> JobResult: ... - - async def wait(self, job_id: str, *, offset: int, timeout: float = ...) -> JobResult: ... - - async def delete(self, job_id: str, *, force: bool = ...) -> object: ... - - async def close(self) -> None: ... - - -ShellctlReadClientFactory = Callable[[], ShellctlReadClient] - - -@dataclass(slots=True) -class WorkspaceFileService: - """Run read-only workspace inspection commands through shellctl.""" - - shellctl_entrypoint: str - shellctl_auth_token: str | None = None - client_factory: ShellctlReadClientFactory | None = None - - def _client(self) -> ShellctlReadClient: - if self.client_factory is not None: - return self.client_factory() - return ShellctlClient( - self.shellctl_entrypoint, - token=self.shellctl_auth_token, - output_limit=MAX_OUTPUT_LIMIT_BYTES, - ) - - async def list_dir(self, session_id: str, path: str) -> WorkspaceListResponse: - data = await self._read(session_id, op="list", path=path) - raw_entries = data.get("entries", []) - entries_in = cast(list[object], raw_entries) if isinstance(raw_entries, list) else [] - entries = [WorkspaceFileEntry.model_validate(e) for e in entries_in] - return WorkspaceListResponse( - path=_normalize_path(path), entries=entries, truncated=_payload_bool(data.get("truncated")) - ) - - async def preview(self, session_id: str, path: str) -> WorkspacePreviewResponse: - data = await self._read(session_id, op="preview", path=path, max_bytes=PREVIEW_MAX_BYTES) - binary = _payload_bool(data.get("binary")) - text: str | None = None - if not binary: - text = base64.b64decode(_payload_str(data.get("content_base64"))).decode("utf-8", errors="replace") - return WorkspacePreviewResponse( - path=_normalize_path(path), - size=_payload_int(data.get("size")), - truncated=_payload_bool(data.get("truncated")), - binary=binary, - text=text, - ) - - async def download(self, session_id: str, path: str) -> WorkspaceDownloadResponse: - data = await self._read(session_id, op="download", path=path, max_bytes=DOWNLOAD_MAX_BYTES) - return WorkspaceDownloadResponse( - path=_normalize_path(path), - size=_payload_int(data.get("size")), - truncated=_payload_bool(data.get("truncated")), - content_base64=_payload_str(data.get("content_base64")), - ) - - async def _read(self, session_id: str, *, op: str, path: str, max_bytes: int = 0) -> dict[str, object]: - safe_session_id = self._validate_session_id(session_id) - rel = _validate_rel_path(path) - script = _build_reader_command(session_id=safe_session_id, op=op, rel=rel, max_bytes=max_bytes) - - client = self._client() - job_id: str | None = None - try: - result = await client.run( - script, - timeout=_READ_TIMEOUT_SECONDS, - terminal=TerminalSize(cols=4096, rows=200), - ) - job_id = result.job_id - output = result.output - offset = result.offset - windows = 1 - while _END not in output and (result.truncated or not result.done) and windows < _MAX_OUTPUT_WINDOWS: - result = await client.wait(job_id, offset=offset, timeout=_READ_TIMEOUT_SECONDS) - output += result.output - offset = result.offset - windows += 1 - return _decode_blob(output) - except ShellctlClientError as exc: - raise WorkspaceFileError("shellctl_error", exc.message, status_code=502) from exc - finally: - if job_id is not None: - try: - _ = await client.delete(job_id, force=True) - except ShellctlClientError as exc: - if exc.code != "job_not_found": - logger.warning("failed to delete workspace read job %s: %s", job_id, exc) - await client.close() - - @staticmethod - def _validate_session_id(session_id: str) -> str: - if not _SESSION_ID_PATTERN.fullmatch(session_id): - raise WorkspaceFileError( - "invalid_session_id", - "session_id must match the 5+2 lowercase hex format '<5 hex><2 hex>'.", - status_code=400, - ) - return session_id - - -def _decode_blob(output: str) -> dict[str, object]: - start = output.find(_BEGIN) - end = output.find(_END, start + len(_BEGIN)) if start != -1 else -1 - if start == -1 or end == -1: - snippet = output[-200:].strip() - raise WorkspaceFileError( - "reader_failed", - f"workspace reader produced no result (output tail: {snippet!r})", - status_code=502, - ) - blob = output[start + len(_BEGIN) : end] - compact = "".join(blob.split()) # strip PTY-injected whitespace/newlines - try: - decoded = base64.b64decode(compact, validate=True) - loaded = cast(object, json.loads(decoded.decode("utf-8"))) - except (binascii.Error, ValueError) as exc: - raise WorkspaceFileError( - "reader_failed", f"could not decode workspace reader output: {exc}", status_code=502 - ) from exc - if not isinstance(loaded, dict): - raise WorkspaceFileError("reader_failed", "workspace reader returned a non-object payload", status_code=502) - data = cast(dict[str, object], loaded) - error = data.get("error") - if isinstance(error, str): - status, message = _READER_ERROR_HTTP.get(error, (400, error)) - raise WorkspaceFileError(error, message, status_code=status) - return data - - -def _payload_int(value: object) -> int: - if isinstance(value, bool): - return int(value) - if isinstance(value, (int, float)): - return int(value) - if isinstance(value, str): - try: - return int(value) - except ValueError as exc: - raise WorkspaceFileError( - "reader_failed", "workspace reader returned a non-integer field", status_code=502 - ) from exc - raise WorkspaceFileError("reader_failed", "workspace reader returned a non-integer field", status_code=502) - - -def _payload_str(value: object) -> str: - if isinstance(value, str): - return value - raise WorkspaceFileError("reader_failed", "workspace reader returned a non-string field", status_code=502) - - -def _payload_bool(value: object) -> bool: - return bool(value) - - -def _build_reader_command(*, session_id: str, op: str, rel: str, max_bytes: int) -> str: - """Build the shell command: fixed base64 reader + user data via the environment.""" - # session_id is validated lowercase hex, so the workspace root literal is injection-safe. - root = f"~/workspace/{session_id}" - env = ( - f"DIFY_FS_OP={_shquote(op)} " - f"DIFY_FS_ROOT={_shquote(root)} " - f"DIFY_FS_REL={_shquote(rel)} " - f"DIFY_FS_MAX={int(max_bytes)} " - f"DIFY_FS_LIST_LIMIT={LIST_MAX_ENTRIES}" - ) - return f"{env} python3 -c 'import base64;exec(base64.b64decode(\"{_READER_B64}\"))'" - - -def _shquote(value: str) -> str: - """Single-quote a value for POSIX shells, escaping embedded single quotes.""" - return "'" + value.replace("'", "'\\''") + "'" - - -def _normalize_path(path: str) -> str: - return path.strip().lstrip("/") or "." - - -def _validate_rel_path(path: str) -> str: - """Reject absolute paths, parent traversal, and control characters early. - - Containment is also enforced inside the reader via realpath; this is a cheap - first gate and keeps obviously-bad input from reaching the workspace at all. - """ - rel = (path or "").strip() - if rel in ("", ".", "./"): - return "." - if rel.startswith("/") or rel.startswith("~"): - raise WorkspaceFileError("invalid_path", "path must be relative to the workspace", status_code=400) - if "\x00" in rel or any(ord(ch) < 0x20 for ch in rel): - raise WorkspaceFileError("invalid_path", "path contains control characters", status_code=400) - segments = rel.split("/") - if any(seg == ".." for seg in segments): - raise WorkspaceFileError("invalid_path", "path must not traverse outside the workspace", status_code=400) - return rel - - -__all__ = [ - "DOWNLOAD_MAX_BYTES", - "LIST_MAX_ENTRIES", - "PREVIEW_MAX_BYTES", - "WorkspaceDownloadResponse", - "WorkspaceFileEntry", - "WorkspaceFileError", - "WorkspaceFileService", - "WorkspaceListResponse", - "WorkspacePreviewResponse", -] diff --git a/dify-agent/tests/local/dify_agent/client/test_client.py b/dify-agent/tests/local/dify_agent/client/test_client.py index b66bd805b1e..d1b06ad5fc9 100644 --- a/dify-agent/tests/local/dify_agent/client/test_client.py +++ b/dify-agent/tests/local/dify_agent/client/test_client.py @@ -20,7 +20,7 @@ from dify_agent.client import ( DifyAgentTimeoutError, DifyAgentValidationError, ) -from dify_agent.protocol.schemas import ( +from dify_agent.protocol import ( CancelRunRequest, CancelRunResponse, CreateRunRequest, @@ -31,6 +31,10 @@ from dify_agent.protocol.schemas import ( RunStartedEvent, RunSucceededEvent, RunSucceededEventData, + SandboxListResponse, + SandboxLocator, + SandboxReadResponse, + SandboxUploadResponse, ) @@ -65,6 +69,44 @@ def _run_status_json(status: str) -> dict[str, object]: return {"run_id": "run-1", "status": status, "created_at": now, "updated_at": now, "error": None} +def _sandbox_locator() -> SandboxLocator: + return SandboxLocator.model_validate( + { + "composition": { + "schema_version": 1, + "layers": [ + { + "name": "execution_context", + "type": "dify.execution_context", + "config": { + "tenant_id": "tenant-1", + "user_from": "account", + "agent_mode": "agent_app", + "invoke_from": "service-api", + }, + }, + { + "name": "shell", + "type": "dify.shell", + "deps": {"execution_context": "execution_context"}, + "config": {}, + }, + ], + }, + "session_snapshot": { + "layers": [ + {"name": "execution_context", "lifecycle_state": "suspended", "runtime_state": {}}, + { + "name": "shell", + "lifecycle_state": "suspended", + "runtime_state": {"session_id": "abc12ff", "workspace_cwd": "~/workspace/abc12ff"}, + }, + ] + }, + } + ) + + def _function_tool_result_payload(key: str) -> dict[str, object]: return { "type": "pydantic_ai_event", @@ -195,6 +237,99 @@ def test_async_methods_and_wait_run_parse_protocol_dtos() -> None: asyncio.run(scenario()) +def test_sync_sandbox_methods_post_dtos_and_parse_responses() -> None: + locator = _sandbox_locator() + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/sandbox/files/list": + payload = cast(dict[str, object], json.loads(request.content)) + assert payload["path"] == "." + return httpx.Response(200, json={"path": ".", "entries": [], "truncated": False}) + if request.url.path == "/sandbox/files/read": + payload = cast(dict[str, object], json.loads(request.content)) + assert payload["path"] == "note.txt" + assert payload["max_bytes"] == 128 + return httpx.Response( + 200, json={"path": "note.txt", "size": 5, "truncated": False, "binary": False, "text": "hello"} + ) + if request.url.path == "/sandbox/files/upload": + payload = cast(dict[str, object], json.loads(request.content)) + assert payload["path"] == "report.txt" + return httpx.Response( + 200, + json={ + "path": "report.txt", + "file": {"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"}, + }, + ) + raise AssertionError(f"unexpected request: {request.method} {request.url}") + + client = Client(base_url="http://testserver", sync_http_client=httpx.Client(transport=httpx.MockTransport(handler))) + + listing = client.list_sandbox_files_sync(locator, ".") + preview = client.read_sandbox_file_sync(locator, "note.txt", max_bytes=128) + uploaded = client.upload_sandbox_file_sync(locator, "report.txt") + + assert isinstance(listing, SandboxListResponse) + assert listing.path == "." + assert isinstance(preview, SandboxReadResponse) + assert preview.text == "hello" + assert isinstance(uploaded, SandboxUploadResponse) + assert uploaded.file.reference == "dify-file-ref:file-1" + + +def test_async_sandbox_methods_post_dtos_and_parse_responses() -> None: + locator = _sandbox_locator() + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/sandbox/files/list": + return httpx.Response(200, json={"path": ".", "entries": [], "truncated": False}) + if request.url.path == "/sandbox/files/read": + return httpx.Response( + 200, json={"path": "note.txt", "size": 5, "truncated": False, "binary": False, "text": "hello"} + ) + if request.url.path == "/sandbox/files/upload": + return httpx.Response( + 200, + json={ + "path": "report.txt", + "file": {"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"}, + }, + ) + raise AssertionError(f"unexpected request: {request.method} {request.url}") + + async def scenario() -> None: + http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + client = Client(base_url="http://testserver", async_http_client=http_client) + + listing = await client.list_sandbox_files(locator, ".") + preview = await client.read_sandbox_file(locator, "note.txt") + uploaded = await client.upload_sandbox_file(locator, "report.txt") + + assert listing.path == "." + assert preview.text == "hello" + assert uploaded.file.reference == "dify-file-ref:file-1" + await http_client.aclose() + + asyncio.run(scenario()) + + +def test_sync_sandbox_methods_map_invalid_json_to_validation_error() -> None: + responses = iter([httpx.Response(200, text="not-json"), httpx.Response(404, json={"detail": "missing"})]) + + def handler(_request: httpx.Request) -> httpx.Response: + return next(responses) + + client = Client(base_url="http://testserver", sync_http_client=httpx.Client(transport=httpx.MockTransport(handler))) + + with pytest.raises(DifyAgentValidationError): + _ = client.list_sandbox_files_sync(_sandbox_locator(), ".") + + with pytest.raises(DifyAgentHTTPError) as http_error: + _ = client.read_sandbox_file_sync(_sandbox_locator(), "missing.txt") + assert http_error.value.status_code == 404 + + def test_error_mapping_and_create_run_input_validation() -> None: responses = iter( [ diff --git a/dify-agent/tests/local/dify_agent/layers/ask_human/test_configs.py b/dify-agent/tests/local/dify_agent/layers/ask_human/test_configs.py new file mode 100644 index 00000000000..f9b42220e03 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/layers/ask_human/test_configs.py @@ -0,0 +1,73 @@ +import pytest +from pydantic import ValidationError + +import dify_agent.layers.ask_human as ask_human_exports +from dify_agent.layers.ask_human import DIFY_ASK_HUMAN_LAYER_TYPE_ID, DifyAskHumanLayerConfig +from dify_agent.layers.ask_human.schema import AskHumanToolArgs + + +def test_ask_human_package_exports_client_safe_symbols_only() -> None: + assert ask_human_exports.DIFY_ASK_HUMAN_LAYER_TYPE_ID == "dify.ask_human" + assert ask_human_exports.__all__ == [ + "AskHumanAction", + "AskHumanActionStyle", + "AskHumanField", + "AskHumanFieldType", + "AskHumanFileField", + "AskHumanFileListField", + "AskHumanParagraphField", + "AskHumanResultStatus", + "AskHumanSelectField", + "AskHumanSelectOption", + "AskHumanSelectedAction", + "AskHumanToolArgs", + "AskHumanToolResult", + "AskHumanUrgency", + "DEFAULT_ASK_HUMAN_TOOL_DESCRIPTION", + "DIFY_ASK_HUMAN_LAYER_TYPE_ID", + "DifyAskHumanLayerConfig", + ] + assert not hasattr(ask_human_exports, "DifyAskHumanLayer") + + +def test_ask_human_layer_config_defaults_and_effective_description() -> None: + config = DifyAskHumanLayerConfig() + + assert DIFY_ASK_HUMAN_LAYER_TYPE_ID == "dify.ask_human" + assert config.model_dump(mode="json") == { + "enabled": True, + "tool_name": "ask_human", + "tool_description": None, + "max_fields": 8, + "max_actions": 4, + "allowed_field_types": ["paragraph", "select"], + "allow_file_fields": False, + "max_markdown_chars": 8000, + "max_question_chars": 1000, + "max_field_label_chars": 120, + "max_action_label_chars": 80, + } + assert "Ask a human for missing information" in config.effective_tool_description + + +def test_ask_human_layer_config_rejects_invalid_tool_name() -> None: + with pytest.raises(ValidationError, match="tool_name must be a valid tool identifier"): + _ = DifyAskHumanLayerConfig(tool_name="ask-human") + + +def test_ask_human_layer_config_rejects_file_field_types_when_disabled() -> None: + with pytest.raises(ValidationError, match="cannot include file field types"): + _ = DifyAskHumanLayerConfig(allowed_field_types=["paragraph", "file"]) + + +def test_ask_human_tool_args_reject_duplicate_field_names() -> None: + with pytest.raises(ValidationError, match="field name 'comment' must be unique"): + _ = AskHumanToolArgs.model_validate( + { + "question": "Need a reply", + "fields": [ + {"type": "paragraph", "name": "comment", "label": "Comment"}, + {"type": "paragraph", "name": "comment", "label": "Another comment"}, + ], + } + ) diff --git a/dify-agent/tests/local/dify_agent/layers/ask_human/test_layer.py b/dify-agent/tests/local/dify_agent/layers/ask_human/test_layer.py new file mode 100644 index 00000000000..903a3164a01 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/layers/ask_human/test_layer.py @@ -0,0 +1,152 @@ +import asyncio +import inspect +from collections.abc import Awaitable +from typing import Any, cast + +import pytest +from pydantic_ai.messages import ToolCallPart +from pydantic_ai.tools import DeferredToolRequests, ToolDefinition + +from dify_agent.layers.ask_human import DifyAskHumanLayerConfig +from dify_agent.layers.ask_human.schema import AskHumanToolArgs +from dify_agent.layers.ask_human.layer import DifyAskHumanLayer + + +async def _await_tool_definition(value: Awaitable[ToolDefinition | None]) -> ToolDefinition | None: + return await value + + +def test_ask_human_layer_exposes_one_external_tool_and_prompt_hint() -> None: + config = DifyAskHumanLayerConfig( + tool_name="human_gate", + tool_description="Collect a human decision.", + max_fields=2, + max_actions=3, + allowed_field_types=["paragraph"], + allow_file_fields=False, + max_question_chars=240, + max_markdown_chars=512, + max_field_label_chars=32, + max_action_label_chars=16, + ) + layer = DifyAskHumanLayer.from_config(config) + + prompt_hint = layer.build_prompt_hint() + tool = layer.tools[0] + prepare = tool.prepare + assert prepare is not None + prepared_or_awaitable = prepare( + cast(Any, None), + ToolDefinition( + name=tool.name, description=tool.description, parameters_json_schema=tool.function_schema.json_schema + ), + ) + prepared = ( + asyncio.run(_await_tool_definition(cast(Awaitable[ToolDefinition | None], prepared_or_awaitable))) + if inspect.isawaitable(prepared_or_awaitable) + else prepared_or_awaitable + ) + + assert len(layer.prefix_prompts) == 1 + assert len(layer.tools) == 1 + assert "Allowed field types: paragraph." in prompt_hint + assert "File upload fields are disabled." in prompt_hint + assert "Use at most 2 field(s)." in prompt_hint + assert "Use at most 3 action(s)." in prompt_hint + assert "Keep 'question' under 240 characters." in prompt_hint + assert "Keep 'markdown' under 512 characters." in prompt_hint + assert "Keep each field label under 32 characters." in prompt_hint + assert "Keep each action label under 16 characters." in prompt_hint + assert prepared is not None + assert prepared.name == "human_gate" + assert prepared.description == "Collect a human decision." + assert prepared.kind == "external" + assert prepared.parameters_json_schema == AskHumanToolArgs.model_json_schema() + + +def test_ask_human_layer_normalizes_default_action_in_deferred_payload() -> None: + layer = DifyAskHumanLayer.from_config(DifyAskHumanLayerConfig()) + + payload = layer.build_deferred_tool_call_payload( + DeferredToolRequests( + calls=[ + ToolCallPart( + tool_name="ask_human", + args={ + "question": "Need a human answer", + "fields": [{"type": "paragraph", "name": "comment", "label": "Comment"}], + }, + tool_call_id="call-1", + ) + ] + ) + ) + + assert payload.tool_call_id == "call-1" + assert payload.tool_name == "ask_human" + assert payload.args == { + "title": None, + "question": "Need a human answer", + "markdown": None, + "fields": [ + { + "type": "paragraph", + "name": "comment", + "label": "Comment", + "required": False, + "placeholder": None, + "default": None, + } + ], + "actions": [{"id": "submit", "label": "Submit", "style": "primary"}], + "urgency": "normal", + } + assert payload.metadata == { + "layer_type": "dify.ask_human", + "tool_name": "ask_human", + "schema_version": 1, + } + + +def test_ask_human_layer_rejects_disallowed_field_types_in_deferred_payload() -> None: + layer = DifyAskHumanLayer.from_config(DifyAskHumanLayerConfig(allowed_field_types=["paragraph"])) + + with pytest.raises(ValueError, match="field type 'select' is not allowed"): + _ = layer.build_deferred_tool_call_payload( + DeferredToolRequests( + calls=[ + ToolCallPart( + tool_name="ask_human", + args={ + "question": "Need a choice", + "fields": [ + { + "type": "select", + "name": "decision", + "label": "Decision", + "options": [{"value": "yes", "label": "Yes"}], + } + ], + }, + tool_call_id="call-2", + ) + ] + ) + ) + + +def test_ask_human_layer_rejects_tool_name_mismatch_in_deferred_payload() -> None: + layer = DifyAskHumanLayer.from_config(DifyAskHumanLayerConfig(tool_name="human_gate")) + + with pytest.raises(ValueError, match="deferred tool name must be 'human_gate', got 'ask_human'"): + _ = layer.build_deferred_tool_call_payload( + DeferredToolRequests( + calls=[ + ToolCallPart( + tool_name="ask_human", + args={"question": "Need a human answer"}, + tool_call_id="call-3", + ) + ] + ) + ) diff --git a/dify-agent/tests/local/dify_agent/layers/drive/__init__.py b/dify-agent/tests/local/dify_agent/layers/drive/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dify-agent/tests/local/dify_agent/layers/drive/test_configs.py b/dify-agent/tests/local/dify_agent/layers/drive/test_configs.py new file mode 100644 index 00000000000..3052827c5b2 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/layers/drive/test_configs.py @@ -0,0 +1,58 @@ +"""Contract tests for the dify.drive declaration layer (ENG-623).""" + +import pytest +from pydantic import ValidationError + +from dify_agent.layers.drive import ( + DIFY_DRIVE_LAYER_TYPE_ID, + DifyDriveFileConfig, + DifyDriveLayerConfig, + DifyDriveSkillConfig, +) +from dify_agent.layers.drive.layer import DifyDriveLayer +from dify_agent.runtime.compositor_factory import create_default_layer_providers + + +def test_type_id_is_frozen_contract() -> None: + assert DIFY_DRIVE_LAYER_TYPE_ID == "dify.drive" + assert DifyDriveLayer.type_id == DIFY_DRIVE_LAYER_TYPE_ID + + +def test_layer_config_round_trips_manifest_entries() -> None: + config = DifyDriveLayerConfig.model_validate( + { + "drive_ref": "agent-019e9112", + "skills": [ + { + "name": "Tender Analyzer", + "description": "Parses RFP documents step by step.", + "skill_md_key": "tender-analyzer/SKILL.md", + "archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip", + } + ], + "files": [{"name": "sample.pdf", "key": "files/sample.pdf", "size": 1024, "mime_type": "application/pdf"}], + } + ) + + dumped = config.model_dump(mode="json") + assert dumped["drive_ref"] == "agent-019e9112" + assert dumped["skills"][0]["skill_md_key"] == "tender-analyzer/SKILL.md" + assert dumped["files"][0]["key"] == "files/sample.pdf" + # the declaration is an index only — there is no field that could carry file content + assert "content" not in DifyDriveSkillConfig.model_fields + assert "content" not in DifyDriveFileConfig.model_fields + + +def test_layer_config_rejects_unknown_fields() -> None: + with pytest.raises(ValidationError): + DifyDriveLayerConfig.model_validate({"drive_ref": "agent-1", "skill_md_body": "# inline content"}) + + +def test_inert_layer_is_registered_and_constructible_from_config() -> None: + providers = create_default_layer_providers() + provider = next(p for p in providers if p.type_id == DIFY_DRIVE_LAYER_TYPE_ID) + + layer = provider.create_layer({"drive_ref": "agent-1", "skills": [], "files": []}) + + assert isinstance(layer, DifyDriveLayer) + assert layer.config.drive_ref == "agent-1" diff --git a/dify-agent/tests/local/dify_agent/layers/execution_context/__init__.py b/dify-agent/tests/local/dify_agent/layers/execution_context/__init__.py new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/layers/execution_context/__init__.py @@ -0,0 +1 @@ + diff --git a/dify-agent/tests/local/dify_agent/layers/shell/__init__.py b/dify-agent/tests/local/dify_agent/layers/shell/__init__.py new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/layers/shell/__init__.py @@ -0,0 +1 @@ + diff --git a/dify-agent/tests/local/dify_agent/layers/shell/test_configs.py b/dify-agent/tests/local/dify_agent/layers/shell/test_configs.py index 10ada98149a..c15b15ee284 100644 --- a/dify-agent/tests/local/dify_agent/layers/shell/test_configs.py +++ b/dify-agent/tests/local/dify_agent/layers/shell/test_configs.py @@ -43,7 +43,10 @@ def test_shell_layer_config_accepts_agent_soul_shell_settings() -> None: config = DifyShellLayerConfig( cli_tools=[ DifyShellCliToolConfig( - name="ripgrep", install_commands=[" apt-get update ", "", "apt-get install -y ripgrep"] + name="ripgrep", + install_commands=[" apt-get update ", "", "apt-get install -y ripgrep"], + env=[DifyShellEnvVarConfig(name="RG_CONFIG_PATH", value="/workspace/.ripgreprc")], + secret_refs=[DifyShellSecretRefConfig(name="GITHUB_TOKEN", ref="credential-2")], ) ], env=[DifyShellEnvVarConfig(name="PROJECT_NAME", value="demo")], @@ -52,6 +55,8 @@ def test_shell_layer_config_accepts_agent_soul_shell_settings() -> None: ) assert config.cli_tools[0].install_commands == ["apt-get update", "apt-get install -y ripgrep"] + assert config.cli_tools[0].env[0].name == "RG_CONFIG_PATH" + assert config.cli_tools[0].secret_refs[0].ref == "credential-2" assert config.env[0].name == "PROJECT_NAME" assert config.secret_refs[0].ref == "credential-1" assert config.sandbox is not None diff --git a/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py b/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py index c1459b8df2c..61368295fad 100644 --- a/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py +++ b/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py @@ -436,8 +436,11 @@ def test_shell_layer_create_bootstraps_agent_soul_shell_config(monkeypatch: pyte assert "export PROJECT_NAME='demo project'" in script assert "export QUOTED='it'\\''s ok'" in script assert 'export OPENAI_API_KEY="${OPENAI_API_KEY:-}"' in script + assert "export RG_CONFIG_PATH='.ripgreprc'" in script + assert 'export GITHUB_TOKEN="${GITHUB_TOKEN:-}"' in script assert "export DIFY_SANDBOX_PROVIDER='independent'" in script assert "export DIFY_SANDBOX_CONFIG_JSON='{\"cpu\": 2}'" in script + assert '. ".dify/env.sh"' in script assert "apt-get install -y ripgrep" in script return _job_result("bootstrap-job", status=JobStatusName.EXITED, done=True, exit_code=0) @@ -445,7 +448,14 @@ def test_shell_layer_create_bootstraps_agent_soul_shell_config(monkeypatch: pyte layer = _shell_layer( client_factory=lambda _entrypoint: client, config=DifyShellLayerConfig( - cli_tools=[DifyShellCliToolConfig(name="ripgrep", install_commands=["apt-get install -y ripgrep"])], + cli_tools=[ + DifyShellCliToolConfig( + name="ripgrep", + install_commands=["apt-get install -y ripgrep"], + env=[DifyShellEnvVarConfig(name="RG_CONFIG_PATH", value=".ripgreprc")], + secret_refs=[DifyShellSecretRefConfig(name="GITHUB_TOKEN", ref="secret-2")], + ) + ], env=[ DifyShellEnvVarConfig(name="PROJECT_NAME", value="demo project"), DifyShellEnvVarConfig(name="QUOTED", value="it's ok"), @@ -626,6 +636,148 @@ def test_shell_layer_injects_agent_stub_env_only_for_user_visible_shell_run() -> assert all(call.env is None for call in internal_run_calls) +def test_run_remote_script_uses_workspace_cwd_accumulates_output_and_deletes_job() -> None: + def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult: + assert '. ".dify/env.sh"' not in script + assert script == "printf 'hello world'" + assert cwd == "~/workspace/abc12ff" + assert env is None + assert timeout == 7.5 + return _job_result( + "remote-job", + status=JobStatusName.RUNNING, + done=False, + output="hello ", + offset=6, + truncated=True, + ) + + def wait_handler(job_id: str, offset: int, timeout: float) -> JobResult: + assert job_id == "remote-job" + assert offset == 6 + assert timeout == 7.5 + return _job_result( + "remote-job", + status=JobStatusName.EXITED, + done=True, + exit_code=0, + output="world", + offset=11, + ) + + client = FakeShellctlClient(run_handler=run_handler, wait_handler=wait_handler) + layer = _shell_layer(client_factory=lambda _entrypoint: client) + + async def scenario() -> None: + async with layer.resource_context(): + layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") + result = await layer.run_remote_script("printf 'hello world'", timeout=7.5) + assert result.output == "hello world" + assert result.exit_code == 0 + + asyncio.run(scenario()) + + assert [call.job_id for call in client.delete_calls] == ["remote-job"] + assert layer.runtime_state.job_ids == [] + assert layer.runtime_state.job_offsets == {} + + +def test_run_remote_script_deletes_job_even_when_command_exits_non_zero() -> None: + def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult: + assert script == "exit 17" + assert cwd == "~/workspace/abc12ff" + assert env is None + assert timeout == 3.0 + return _job_result( + "remote-failed-job", + status=JobStatusName.EXITED, + done=True, + exit_code=17, + output="failed\n", + offset=7, + ) + + client = FakeShellctlClient(run_handler=run_handler) + layer = _shell_layer(client_factory=lambda _entrypoint: client) + + async def scenario() -> None: + async with layer.resource_context(): + layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") + result = await layer.run_remote_script("exit 17", timeout=3.0) + assert result.exit_code == 17 + assert result.output == "failed\n" + + asyncio.run(scenario()) + + assert [call.job_id for call in client.delete_calls] == ["remote-failed-job"] + assert layer.runtime_state.job_ids == [] + assert layer.runtime_state.job_offsets == {} + + +def test_run_remote_script_can_inject_agent_stub_env_for_server_owned_uploads() -> None: + def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult: + assert script == "dify-agent file upload report.txt" + assert '. ".dify/env.sh"' not in script + del timeout + assert cwd == "~/workspace/abc12ff" + assert env == { + AGENT_STUB_URL_ENV_VAR: "https://agent.example.com/agent-stub", + AGENT_STUB_AUTH_JWE_ENV_VAR: "token-for:tenant-1:abc12ff", + } + return _job_result("remote-upload", status=JobStatusName.EXITED, done=True, exit_code=0, output="{}") + + client = FakeShellctlClient(run_handler=run_handler) + layer = DifyShellLayer.from_config_with_settings( + DifyShellLayerConfig(), + shellctl_entrypoint="http://shellctl", + shellctl_client_factory=lambda _entrypoint: client, + agent_stub_url="https://agent.example.com/agent-stub", + agent_stub_token_factory=lambda execution_context, *, session_id: ( + f"token-for:{execution_context.tenant_id}:{session_id}" + ), + ) + layer.deps = layer.deps_type(execution_context=_execution_context_layer()) + + async def scenario() -> None: + async with layer.resource_context(): + layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") + _ = await layer.run_remote_script("dify-agent file upload report.txt", inject_agent_stub_env=True) + + asyncio.run(scenario()) + + assert [call.job_id for call in client.delete_calls] == ["remote-upload"] + + +def test_run_remote_script_raises_when_agent_stub_env_is_unavailable() -> None: + client = FakeShellctlClient( + run_handler=lambda _script, _cwd, _env, _timeout: _job_result( + "unexpected-run", + status=JobStatusName.EXITED, + done=True, + exit_code=0, + ) + ) + layer = DifyShellLayer.from_config_with_settings( + DifyShellLayerConfig(), + shellctl_entrypoint="http://shellctl", + shellctl_client_factory=lambda _entrypoint: client, + agent_stub_url="https://agent.example.com/agent-stub", + agent_stub_token_factory=lambda execution_context, *, session_id: ( + f"token-for:{execution_context.tenant_id}:{session_id}" + ), + ) + + async def scenario() -> None: + async with layer.resource_context(): + layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") + with pytest.raises(RuntimeError, match="Agent Stub environment injection is not available"): + await layer.run_remote_script("dify-agent file upload report.txt", inject_agent_stub_env=True) + + asyncio.run(scenario()) + + assert client.run_calls == [] + + def test_shell_layer_skips_agent_stub_env_without_execution_context_dependency() -> None: client = FakeShellctlClient( run_handler=lambda _script, _cwd, _env, _timeout: _job_result( diff --git a/dify-agent/tests/local/dify_agent/protocol/test_protocol_schemas.py b/dify-agent/tests/local/dify_agent/protocol/test_protocol_schemas.py index 76606b9132c..b58e0818229 100644 --- a/dify-agent/tests/local/dify_agent/protocol/test_protocol_schemas.py +++ b/dify-agent/tests/local/dify_agent/protocol/test_protocol_schemas.py @@ -6,6 +6,7 @@ from agenton.compositor import CompositorSessionSnapshot from agenton.layers import ExitIntent from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig import dify_agent.protocol as protocol_exports +from dify_agent.layers.ask_human import DifyAskHumanLayerConfig from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig from dify_agent.layers.dify_plugin import DIFY_PLUGIN_LLM_LAYER_TYPE_ID, DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig @@ -13,6 +14,7 @@ from dify_agent.protocol import DIFY_AGENT_HISTORY_LAYER_ID, DIFY_AGENT_MODEL_LA from dify_agent.protocol.schemas import ( RUN_EVENT_ADAPTER, CreateRunRequest, + DeferredToolCallPayload, LayerExitSignals, PydanticAIStreamRunEvent, RunCancelledEvent, @@ -21,8 +23,6 @@ from dify_agent.protocol.schemas import ( RunFailedEvent, RunFailedEventData, RunLayerSpec, - RunPausedEvent, - RunPausedEventData, RunStartedEvent, RunSucceededEvent, RunSucceededEventData, @@ -49,15 +49,19 @@ def test_run_event_adapter_round_trips_typed_variants() -> None: session_snapshot=CompositorSessionSnapshot(layers=[]), ), ), - RunFailedEvent(run_id="run-1", data=RunFailedEventData(error="boom", reason="shutdown")), - RunPausedEvent( - run_id="run-1", - data=RunPausedEventData( - reason="human_handoff", - message="Need review", + RunSucceededEvent( + run_id="run-2", + data=RunSucceededEventData( + deferred_tool_call=DeferredToolCallPayload( + tool_call_id="tool-call-1", + tool_name="ask_human", + args={"question": "Need approval"}, + metadata={"layer_type": "dify.ask_human"}, + ), session_snapshot=CompositorSessionSnapshot(layers=[]), ), ), + RunFailedEvent(run_id="run-1", data=RunFailedEventData(error="boom", reason="shutdown")), RunCancelledEvent(run_id="run-1", data=RunCancelledEventData(reason="user_cancelled")), ] @@ -204,6 +208,35 @@ def test_create_run_request_accepts_dto_first_public_composition_and_normalizes_ } +def test_create_run_request_accepts_deferred_tool_results_payload() -> None: + request = CreateRunRequest.model_validate( + { + "composition": { + "layers": [ + {"name": "prompt", "type": PLAIN_PROMPT_LAYER_TYPE_ID, "config": {"user": "hello"}}, + {"name": "ask_human", "type": "dify.ask_human", "config": DifyAskHumanLayerConfig().model_dump()}, + ] + }, + "deferred_tool_results": { + "calls": { + "tool-call-1": { + "status": "submitted", + "action": {"id": "submit", "label": "Submit"}, + "values": {"comment": "Looks good."}, + } + } + }, + } + ) + + assert request.deferred_tool_results is not None + assert request.deferred_tool_results.calls["tool-call-1"] == { + "status": "submitted", + "action": {"id": "submit", "label": "Submit"}, + "values": {"comment": "Looks good."}, + } + + def test_create_run_request_accepts_plugin_tools_layer_with_prepared_parameters_and_schema() -> None: request = CreateRunRequest.model_validate( { @@ -327,6 +360,49 @@ def test_on_exit_default_to_suspend_and_are_public() -> None: assert request.on_exit.layers == {} +def test_run_succeeded_event_data_requires_exactly_one_result_variant() -> None: + snapshot = CompositorSessionSnapshot(layers=[]) + + with pytest.raises(ValidationError, match="Exactly one of output or deferred_tool_call must be set"): + _ = RunSucceededEventData(session_snapshot=snapshot) + + with pytest.raises(ValidationError, match="Exactly one of output or deferred_tool_call must be set"): + _ = RunSucceededEventData( + output="done", + deferred_tool_call=DeferredToolCallPayload( + tool_call_id="tool-call-1", + tool_name="ask_human", + args={"question": "Need approval"}, + ), + session_snapshot=snapshot, + ) + + +def test_run_succeeded_event_data_allows_explicit_json_null_output() -> None: + snapshot = CompositorSessionSnapshot(layers=[]) + + data = RunSucceededEventData(output=None, session_snapshot=snapshot) + + assert data.output is None + assert data.deferred_tool_call is None + + +def test_run_succeeded_event_round_trips_explicit_json_null_output() -> None: + event = RunSucceededEvent( + run_id="run-null-output", + data=RunSucceededEventData(output=None, session_snapshot=CompositorSessionSnapshot(layers=[])), + ) + + payload = RUN_EVENT_ADAPTER.dump_json(event) + decoded = RUN_EVENT_ADAPTER.validate_json(payload) + + assert isinstance(decoded, RunSucceededEvent) + assert decoded.data.output is None + assert decoded.data.deferred_tool_call is None + assert b'"output":null' in payload + assert b'"deferred_tool_call"' not in payload + + def test_on_exit_accept_layer_overrides() -> None: request = CreateRunRequest.model_validate( { diff --git a/dify-agent/tests/local/dify_agent/protocol/test_sandbox_locator.py b/dify-agent/tests/local/dify_agent/protocol/test_sandbox_locator.py new file mode 100644 index 00000000000..a717c6b899b --- /dev/null +++ b/dify-agent/tests/local/dify_agent/protocol/test_sandbox_locator.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import pytest +from agenton.compositor import CompositorSessionSnapshot +from agenton.compositor.schemas import LayerSessionSnapshot +from agenton.layers.base import LifecycleState +from agenton_collections.layers.plain import PromptLayerConfig +from dify_agent.layers.dify_plugin import DIFY_PLUGIN_LLM_LAYER_TYPE_ID +from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig +from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig +from dify_agent.protocol import ( + CreateRunRequest, + RunComposition, + RunLayerSpec, + RuntimeLayerSpec, + build_sandbox_locator_from_layer_specs, + build_sandbox_locator_from_run_request, + extract_runtime_layer_specs, +) + + +def _request() -> CreateRunRequest: + composition = RunComposition( + layers=[ + RunLayerSpec(name="prompt", type="plain.prompt", config=PromptLayerConfig(prefix="hi")), + RunLayerSpec( + name="execution_context", + type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, + config=DifyExecutionContextLayerConfig( + tenant_id="tenant-1", + user_from="account", + agent_mode="workflow_run", + invoke_from="service-api", + ), + ), + RunLayerSpec(name="llm", type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID), + RunLayerSpec( + name="shell", + type=DIFY_SHELL_LAYER_TYPE_ID, + deps={"execution_context": "execution_context"}, + config=DifyShellLayerConfig(), + ), + ] + ) + snapshot = CompositorSessionSnapshot( + layers=[ + LayerSessionSnapshot(name="prompt", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}), + LayerSessionSnapshot( + name="execution_context", + lifecycle_state=LifecycleState.SUSPENDED, + runtime_state={}, + ), + LayerSessionSnapshot(name="llm", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}), + LayerSessionSnapshot( + name="shell", + lifecycle_state=LifecycleState.SUSPENDED, + runtime_state={"session_id": "abc12ff", "workspace_cwd": "~/workspace/abc12ff"}, + ), + ] + ) + return CreateRunRequest(composition=composition, session_snapshot=snapshot) + + +def test_build_sandbox_locator_from_run_request_filters_to_execution_context_and_shell() -> None: + locator = build_sandbox_locator_from_run_request(_request()) + + assert [layer.name for layer in locator.composition.layers] == ["execution_context", "shell"] + assert [layer.type for layer in locator.composition.layers] == [ + DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, + DIFY_SHELL_LAYER_TYPE_ID, + ] + assert [layer.name for layer in locator.session_snapshot.layers] == ["execution_context", "shell"] + + +def test_build_sandbox_locator_from_run_request_rejects_missing_session_snapshot() -> None: + request = _request() + request.session_snapshot = None + + with pytest.raises(ValueError, match="session_snapshot"): + build_sandbox_locator_from_run_request(request) + + +def test_extract_runtime_layer_specs_drops_sensitive_plugin_layers() -> None: + specs = extract_runtime_layer_specs(_request().composition) + + assert [spec.name for spec in specs] == ["prompt", "execution_context", "shell"] + + +def test_build_sandbox_locator_from_layer_specs_rejects_missing_shell() -> None: + with pytest.raises(ValueError, match="shell"): + build_sandbox_locator_from_layer_specs( + layer_specs=[RuntimeLayerSpec(name="execution_context", type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID)], + session_snapshot=CompositorSessionSnapshot(layers=[]), + ) + + +def test_build_sandbox_locator_from_layer_specs_rejects_missing_snapshot_layer() -> None: + specs = [ + RuntimeLayerSpec(name="execution_context", type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID), + RuntimeLayerSpec(name="shell", type=DIFY_SHELL_LAYER_TYPE_ID, deps={"execution_context": "execution_context"}), + ] + + with pytest.raises(ValueError, match="session_snapshot"): + build_sandbox_locator_from_layer_specs( + layer_specs=specs, + session_snapshot=CompositorSessionSnapshot( + layers=[ + LayerSessionSnapshot( + name="execution_context", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={} + ) + ] + ), + ) + + +def test_build_sandbox_locator_from_layer_specs_rejects_shell_dep_mismatch() -> None: + specs = [ + RuntimeLayerSpec(name="execution_context", type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID), + RuntimeLayerSpec(name="shell", type=DIFY_SHELL_LAYER_TYPE_ID, deps={"execution_context": "wrong-layer"}), + ] + + with pytest.raises(ValueError, match="depend on the execution_context"): + build_sandbox_locator_from_layer_specs( + layer_specs=specs, + session_snapshot=CompositorSessionSnapshot( + layers=[ + LayerSessionSnapshot( + name="execution_context", + lifecycle_state=LifecycleState.SUSPENDED, + runtime_state={}, + ), + LayerSessionSnapshot( + name="shell", + lifecycle_state=LifecycleState.SUSPENDED, + runtime_state={}, + ), + ] + ), + ) + + +def test_build_sandbox_locator_from_layer_specs_rejects_order_mismatch() -> None: + specs = [ + RuntimeLayerSpec(name="execution_context", type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID), + RuntimeLayerSpec(name="shell", type=DIFY_SHELL_LAYER_TYPE_ID, deps={"execution_context": "execution_context"}), + ] + + with pytest.raises(ValueError, match="in order"): + build_sandbox_locator_from_layer_specs( + layer_specs=specs, + session_snapshot=CompositorSessionSnapshot( + layers=[ + LayerSessionSnapshot(name="shell", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}), + LayerSessionSnapshot( + name="execution_context", + lifecycle_state=LifecycleState.SUSPENDED, + runtime_state={}, + ), + ] + ), + ) + + +def test_build_sandbox_locator_from_layer_specs_rejects_sensitive_runtime_specs() -> None: + with pytest.raises(ValueError, match="sensitive"): + build_sandbox_locator_from_layer_specs( + layer_specs=[RuntimeLayerSpec(name="llm", type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID)], + session_snapshot=CompositorSessionSnapshot(layers=[]), + ) diff --git a/dify-agent/tests/local/dify_agent/runtime/test_runner.py b/dify-agent/tests/local/dify_agent/runtime/test_runner.py index 93f18446d55..c910b7c3dd9 100644 --- a/dify-agent/tests/local/dify_agent/runtime/test_runner.py +++ b/dify-agent/tests/local/dify_agent/runtime/test_runner.py @@ -1,5 +1,5 @@ import asyncio -from collections.abc import Mapping +from collections.abc import Iterable, Mapping from typing import Any, ClassVar, cast import httpx @@ -8,6 +8,7 @@ from pydantic import JsonValue from pydantic_ai import Tool from pydantic_ai.exceptions import UnexpectedModelBehavior from pydantic_ai.messages import ( + ToolReturnPart, ModelMessage, ModelRequest, ModelResponse, @@ -18,12 +19,14 @@ from pydantic_ai.messages import ( ) from pydantic_ai.models import ModelRequestParameters from pydantic_ai.models.test import TestModel +from pydantic_ai.tools import DeferredToolRequests, DeferredToolResults from pydantic_ai.settings import ModelSettings from agenton.compositor import CompositorSessionSnapshot, LayerProvider, LayerSessionSnapshot from agenton.layers import ExitIntent, LifecycleState from agenton_collections.layers.pydantic_ai import PYDANTIC_AI_HISTORY_LAYER_TYPE_ID, PydanticAIHistoryRuntimeState from agenton_collections.layers.plain import PromptLayerConfig, ToolsLayer +from dify_agent.layers.ask_human import DIFY_ASK_HUMAN_LAYER_TYPE_ID, DifyAskHumanLayerConfig from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig from dify_agent.layers.shell.layer import DifyShellLayer @@ -42,6 +45,7 @@ from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerC from dify_agent.protocol import DIFY_AGENT_HISTORY_LAYER_ID, DIFY_AGENT_MODEL_LAYER_ID, DIFY_AGENT_OUTPUT_LAYER_ID from dify_agent.protocol.schemas import ( CreateRunRequest, + DeferredToolResultsPayload, LayerExitSignals, RunComposition, RunLayerSpec, @@ -54,7 +58,7 @@ from shell_session_manager.shellctl.shared import DeleteJobResponse, JobResult, class StaticToolsTestLayer(ToolsLayer): - type_id: ClassVar[str] = "test.static.tools" + type_id: ClassVar[str | None] = "test.static.tools" class FakeRunnerShellctlClient: @@ -115,6 +119,8 @@ def _request( user: str | list[str] = "hello", *, include_history: bool = False, + include_ask_human: bool = False, + ask_human_config: DifyAskHumanLayerConfig | None = None, llm_layer_name: str = DIFY_AGENT_MODEL_LAYER_ID, execution_context_layer_name: str = "execution_context", on_exit: LayerExitSignals | None = None, @@ -131,6 +137,17 @@ def _request( if include_history else [] ), + *( + [ + RunLayerSpec( + name="ask_human", + type=DIFY_ASK_HUMAN_LAYER_TYPE_ID, + config=ask_human_config or DifyAskHumanLayerConfig(), + ) + ] + if include_ask_human + else [] + ), RunLayerSpec( name=execution_context_layer_name, type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, @@ -270,6 +287,7 @@ class RecordingTestModel(TestModel): def _history_session_snapshot( messages: list[ModelMessage], *, + include_ask_human: bool = False, include_output: bool = False, ) -> CompositorSessionSnapshot: layers = [ @@ -279,6 +297,11 @@ def _history_session_snapshot( lifecycle_state=LifecycleState.SUSPENDED, runtime_state=PydanticAIHistoryRuntimeState(messages=messages).model_dump(mode="json"), ), + *( + [LayerSessionSnapshot(name="ask_human", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={})] + if include_ask_human + else [] + ), LayerSessionSnapshot(name="execution_context", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}), LayerSessionSnapshot( name=DIFY_AGENT_MODEL_LAYER_ID, lifecycle_state=LifecycleState.SUSPENDED, runtime_state={} @@ -302,6 +325,18 @@ def _flatten_message_parts(messages: list[ModelMessage]) -> list[object]: return [part for message in messages for part in message.parts] +class FakeAgentRunResult: + output: object + _new_messages: list[ModelMessage] + + def __init__(self, output: object, new_messages: list[ModelMessage]) -> None: + self.output = output + self._new_messages = new_messages + + def new_messages(self) -> list[ModelMessage]: + return list(self._new_messages) + + def test_runner_emits_terminal_success_and_snapshot(monkeypatch: pytest.MonkeyPatch) -> None: seen_clients: list[httpx.AsyncClient] = [] @@ -350,6 +385,489 @@ def test_runner_emits_terminal_success_and_snapshot(monkeypatch: pytest.MonkeyPa assert sink.statuses["run-1"] == "succeeded" +def test_runner_preserves_explicit_json_null_output(monkeypatch: pytest.MonkeyPatch) -> None: + def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient): + assert http_client.is_closed is False + return TestModel(custom_output_text="unused") # pyright: ignore[reportReturnType] + + class FakeAgent: + async def run(self, *_args: object, **_kwargs: object) -> FakeAgentRunResult: + return FakeAgentRunResult(None, []) + + monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model) + monkeypatch.setattr("dify_agent.runtime.runner.create_agent", lambda *_args, **_kwargs: FakeAgent()) + request = _request() + sink = InMemoryRunEventSink() + + async def scenario() -> None: + async with httpx.AsyncClient() as client: + await AgentRunRunner( + sink=sink, + request=request, + run_id="run-null-output", + plugin_daemon_http_client=client, + ).run() + + asyncio.run(scenario()) + + terminal = sink.events["run-null-output"][-1] + assert isinstance(terminal, RunSucceededEvent) + assert terminal.data.output is None + assert terminal.data.deferred_tool_call is None + assert sink.statuses["run-null-output"] == "succeeded" + + +def test_runner_emits_deferred_tool_call_and_persists_pending_history(monkeypatch: pytest.MonkeyPatch) -> None: + captured_output_types: list[object] = [] + captured_user_prompts: list[object] = [] + pending_tool_call = ToolCallPart( + tool_name="ask_human", + args={ + "question": "Which deployment window should we use?", + "fields": [{"type": "paragraph", "name": "window", "label": "Deployment window"}], + }, + tool_call_id="tool-call-1", + ) + + def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient): + assert http_client.is_closed is False + return TestModel(custom_output_text="unused") # pyright: ignore[reportReturnType] + + class FakeAgent: + async def run(self, user_prompt: object, **kwargs: object) -> FakeAgentRunResult: + captured_user_prompts.append(user_prompt) + assert kwargs["deferred_tool_results"] is None + return FakeAgentRunResult( + DeferredToolRequests(calls=[pending_tool_call]), + [ + ModelRequest(parts=[UserPromptPart(content="current user")]), + ModelResponse(parts=[pending_tool_call]), + ], + ) + + def fake_create_agent(model: object, *, tools: list[Tool[object]], output_type: object) -> FakeAgent: + del model, tools + captured_output_types.append(output_type) + return FakeAgent() + + monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model) + monkeypatch.setattr("dify_agent.runtime.runner.create_agent", fake_create_agent) + request = _request("current user", include_history=True, include_ask_human=True) + sink = InMemoryRunEventSink() + + async def scenario() -> None: + async with httpx.AsyncClient() as client: + await AgentRunRunner( + sink=sink, + request=request, + run_id="run-ask-human", + plugin_daemon_http_client=client, + ).run() + + asyncio.run(scenario()) + + terminal = sink.events["run-ask-human"][-1] + assert isinstance(terminal, RunSucceededEvent) + assert captured_user_prompts == ["current user"] + assert any(item is DeferredToolRequests for item in cast(Iterable[object], captured_output_types[0])) + assert terminal.data.output is None + assert terminal.data.deferred_tool_call is not None + assert terminal.data.deferred_tool_call.tool_call_id == "tool-call-1" + assert terminal.data.deferred_tool_call.tool_name == "ask_human" + assert terminal.data.deferred_tool_call.args == { + "title": None, + "question": "Which deployment window should we use?", + "markdown": None, + "fields": [ + { + "type": "paragraph", + "name": "window", + "label": "Deployment window", + "required": False, + "placeholder": None, + "default": None, + } + ], + "actions": [{"id": "submit", "label": "Submit", "style": "primary"}], + "urgency": "normal", + } + saved_history = _history_messages_from_snapshot(terminal.data.session_snapshot) + assert isinstance(saved_history[-1], ModelResponse) + assert saved_history[-1].parts == [pending_tool_call] + + +def test_runner_resumes_with_deferred_tool_results_and_no_user_prompt(monkeypatch: pytest.MonkeyPatch) -> None: + seen_user_prompts: list[object] = [] + seen_deferred_results: list[object] = [] + pending_tool_call = ToolCallPart( + tool_name="ask_human", + args={"question": "Need approval"}, + tool_call_id="tool-call-1", + ) + + def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient): + assert http_client.is_closed is False + return TestModel(custom_output_text="unused") # pyright: ignore[reportReturnType] + + class FakeAgent: + async def run(self, user_prompt: object, **kwargs: object) -> FakeAgentRunResult: + seen_user_prompts.append(user_prompt) + seen_deferred_results.append(kwargs.get("deferred_tool_results")) + if kwargs.get("deferred_tool_results") is None: + return FakeAgentRunResult( + DeferredToolRequests(calls=[pending_tool_call]), + [ + ModelRequest(parts=[UserPromptPart(content="current user")]), + ModelResponse(parts=[pending_tool_call]), + ], + ) + + deferred_tool_results = cast(DeferredToolResults, kwargs["deferred_tool_results"]) + assert deferred_tool_results is not None + submitted_result = cast(dict[str, object], deferred_tool_results.calls["tool-call-1"]) + assert submitted_result["status"] == "submitted" + return FakeAgentRunResult( + "done after human", + [ + ModelRequest( + parts=[ + ToolReturnPart( + tool_name="ask_human", + content={"status": "submitted", "values": {"comment": "Ship it"}}, + tool_call_id="tool-call-1", + ) + ] + ), + ModelResponse(parts=[TextPart(content="done after human")]), + ], + ) + + def fake_create_agent(model: object, *, tools: list[Tool[object]], output_type: object) -> FakeAgent: + del model, tools, output_type + return FakeAgent() + + monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model) + monkeypatch.setattr("dify_agent.runtime.runner.create_agent", fake_create_agent) + request = _request("current user", include_history=True, include_ask_human=True) + sink = InMemoryRunEventSink() + + async def scenario() -> None: + async with httpx.AsyncClient() as client: + await AgentRunRunner( + sink=sink, + request=request, + run_id="run-ask-human-initial", + plugin_daemon_http_client=client, + ).run() + + initial_terminal = sink.events["run-ask-human-initial"][-1] + assert isinstance(initial_terminal, RunSucceededEvent) + + resumed_request = request.model_copy(deep=True) + resumed_request.session_snapshot = initial_terminal.data.session_snapshot + resumed_request.deferred_tool_results = DeferredToolResultsPayload.model_validate( + { + "calls": { + "tool-call-1": { + "status": "submitted", + "action": {"id": "submit", "label": "Submit"}, + "values": {"comment": "Ship it"}, + } + } + } + ) + + await AgentRunRunner( + sink=sink, + request=resumed_request, + run_id="run-ask-human-resume", + plugin_daemon_http_client=client, + ).run() + + asyncio.run(scenario()) + + resumed_terminal = sink.events["run-ask-human-resume"][-1] + assert isinstance(resumed_terminal, RunSucceededEvent) + assert resumed_terminal.data.output == "done after human" + assert resumed_terminal.data.deferred_tool_call is None + assert seen_user_prompts == ["current user", None] + assert seen_deferred_results[0] is None + assert seen_deferred_results[1] is not None + + +def test_runner_can_emit_second_deferred_tool_call_after_resume(monkeypatch: pytest.MonkeyPatch) -> None: + seen_user_prompts: list[object] = [] + first_pending_tool_call = ToolCallPart( + tool_name="ask_human", + args={"question": "Need deployment owner"}, + tool_call_id="tool-call-1", + ) + second_pending_tool_call = ToolCallPart( + tool_name="ask_human", + args={"question": "Need final go-live confirmation"}, + tool_call_id="tool-call-2", + ) + + def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient): + assert http_client.is_closed is False + return TestModel(custom_output_text="unused") # pyright: ignore[reportReturnType] + + class FakeAgent: + async def run(self, user_prompt: object, **kwargs: object) -> FakeAgentRunResult: + seen_user_prompts.append(user_prompt) + deferred_tool_results = kwargs.get("deferred_tool_results") + if deferred_tool_results is None: + return FakeAgentRunResult( + DeferredToolRequests(calls=[first_pending_tool_call]), + [ + ModelRequest(parts=[UserPromptPart(content="current user")]), + ModelResponse(parts=[first_pending_tool_call]), + ], + ) + + return FakeAgentRunResult( + DeferredToolRequests(calls=[second_pending_tool_call]), + [ + ModelRequest( + parts=[ + ToolReturnPart( + tool_name="ask_human", + content={"status": "submitted", "values": {"owner": "ops"}}, + tool_call_id="tool-call-1", + ) + ] + ), + ModelResponse(parts=[second_pending_tool_call]), + ], + ) + + def fake_create_agent(model: object, *, tools: list[Tool[object]], output_type: object) -> FakeAgent: + del model, tools, output_type + return FakeAgent() + + monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model) + monkeypatch.setattr("dify_agent.runtime.runner.create_agent", fake_create_agent) + request = _request("current user", include_history=True, include_ask_human=True) + sink = InMemoryRunEventSink() + + async def scenario() -> None: + async with httpx.AsyncClient() as client: + await AgentRunRunner( + sink=sink, + request=request, + run_id="run-ask-human-turn-1", + plugin_daemon_http_client=client, + ).run() + + first_terminal = sink.events["run-ask-human-turn-1"][-1] + assert isinstance(first_terminal, RunSucceededEvent) + + resumed_request = request.model_copy(deep=True) + resumed_request.session_snapshot = first_terminal.data.session_snapshot + resumed_request.deferred_tool_results = DeferredToolResultsPayload.model_validate( + { + "calls": { + "tool-call-1": { + "status": "submitted", + "action": {"id": "submit", "label": "Submit"}, + "values": {"owner": "ops"}, + } + } + } + ) + + await AgentRunRunner( + sink=sink, + request=resumed_request, + run_id="run-ask-human-turn-2", + plugin_daemon_http_client=client, + ).run() + + asyncio.run(scenario()) + + second_terminal = sink.events["run-ask-human-turn-2"][-1] + assert isinstance(second_terminal, RunSucceededEvent) + assert second_terminal.data.output is None + assert second_terminal.data.deferred_tool_call is not None + assert second_terminal.data.deferred_tool_call.tool_call_id == "tool-call-2" + assert seen_user_prompts == ["current user", None] + saved_history = _history_messages_from_snapshot(second_terminal.data.session_snapshot) + assert isinstance(saved_history[1], ModelResponse) + assert saved_history[1].parts == [first_pending_tool_call] + assert isinstance(saved_history[2], ModelRequest) + assert len(saved_history[2].parts) == 1 + assert isinstance(saved_history[2].parts[0], ToolReturnPart) + assert saved_history[2].parts[0].tool_name == "ask_human" + assert saved_history[2].parts[0].tool_call_id == "tool-call-1" + assert saved_history[2].parts[0].content == {"status": "submitted", "values": {"owner": "ops"}} + assert isinstance(saved_history[3], ModelResponse) + assert saved_history[3].parts == [second_pending_tool_call] + + +def test_runner_rejects_deferred_tool_call_without_history_layer(monkeypatch: pytest.MonkeyPatch) -> None: + def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient): + assert http_client.is_closed is False + return TestModel(custom_output_text="unused") # pyright: ignore[reportReturnType] + + class FakeAgent: + async def run(self, *_args: object, **_kwargs: object) -> FakeAgentRunResult: + return FakeAgentRunResult( + DeferredToolRequests( + calls=[ + ToolCallPart(tool_name="ask_human", args={"question": "Need owner"}, tool_call_id="tool-call-1") + ] + ), + [], + ) + + monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model) + monkeypatch.setattr("dify_agent.runtime.runner.create_agent", lambda *args, **kwargs: FakeAgent()) + request = _request("current user", include_history=False, include_ask_human=True) + sink = InMemoryRunEventSink() + + async def scenario() -> None: + async with httpx.AsyncClient() as client: + with pytest.raises( + AgentRunValidationError, + match="ask_human deferred tool requests require a 'history' layer so the pending tool call can be resumed", + ): + await AgentRunRunner( + sink=sink, + request=request, + run_id="run-ask-human-no-history", + plugin_daemon_http_client=client, + ).run() + + asyncio.run(scenario()) + + assert [event.type for event in sink.events["run-ask-human-no-history"]] == ["run_started", "run_failed"] + + +def test_runner_rejects_resume_with_deferred_tool_results_without_history_layer( + monkeypatch: pytest.MonkeyPatch, +) -> None: + agent_run_called = False + + def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient): + assert http_client.is_closed is False + return TestModel(custom_output_text="unused") # pyright: ignore[reportReturnType] + + class FakeAgent: + async def run(self, *_args: object, **_kwargs: object) -> FakeAgentRunResult: + nonlocal agent_run_called + agent_run_called = True + return FakeAgentRunResult("unexpected", []) + + monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model) + monkeypatch.setattr("dify_agent.runtime.runner.create_agent", lambda *args, **kwargs: FakeAgent()) + request = _request("current user", include_history=False, include_ask_human=True) + request.deferred_tool_results = DeferredToolResultsPayload.model_validate( + { + "calls": { + "tool-call-1": { + "status": "submitted", + "action": {"id": "submit", "label": "Submit"}, + "values": {"owner": "ops"}, + } + } + } + ) + sink = InMemoryRunEventSink() + + async def scenario() -> None: + async with httpx.AsyncClient() as client: + with pytest.raises( + AgentRunValidationError, + match="Deferred tool results require a 'history' layer with prior message history", + ): + await AgentRunRunner( + sink=sink, + request=request, + run_id="run-ask-human-resume-no-history", + plugin_daemon_http_client=client, + ).run() + + asyncio.run(scenario()) + + assert agent_run_called is False + assert [event.type for event in sink.events["run-ask-human-resume-no-history"]] == ["run_started", "run_failed"] + + +def test_runner_rejects_multiple_deferred_tool_calls(monkeypatch: pytest.MonkeyPatch) -> None: + def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient): + assert http_client.is_closed is False + return TestModel(custom_output_text="unused") # pyright: ignore[reportReturnType] + + class FakeAgent: + async def run(self, *_args: object, **_kwargs: object) -> FakeAgentRunResult: + return FakeAgentRunResult( + DeferredToolRequests( + calls=[ + ToolCallPart(tool_name="ask_human", args={"question": "One"}, tool_call_id="tool-call-1"), + ToolCallPart(tool_name="ask_human", args={"question": "Two"}, tool_call_id="tool-call-2"), + ] + ), + [], + ) + + monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model) + monkeypatch.setattr("dify_agent.runtime.runner.create_agent", lambda *args, **kwargs: FakeAgent()) + request = _request("current user", include_history=True, include_ask_human=True) + sink = InMemoryRunEventSink() + + async def scenario() -> None: + async with httpx.AsyncClient() as client: + with pytest.raises(ValueError, match="supports exactly one deferred call per run"): + await AgentRunRunner( + sink=sink, + request=request, + run_id="run-ask-human-multi", + plugin_daemon_http_client=client, + ).run() + + asyncio.run(scenario()) + + assert [event.type for event in sink.events["run-ask-human-multi"]] == ["run_started", "run_failed"] + + +def test_runner_rejects_deferred_approval_requests(monkeypatch: pytest.MonkeyPatch) -> None: + def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient): + assert http_client.is_closed is False + return TestModel(custom_output_text="unused") # pyright: ignore[reportReturnType] + + class FakeAgent: + async def run(self, *_args: object, **_kwargs: object) -> FakeAgentRunResult: + return FakeAgentRunResult( + DeferredToolRequests( + approvals=[ + ToolCallPart( + tool_name="ask_human", args={"question": "Need approval"}, tool_call_id="tool-call-1" + ) + ] + ), + [], + ) + + monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model) + monkeypatch.setattr("dify_agent.runtime.runner.create_agent", lambda *args, **kwargs: FakeAgent()) + request = _request("current user", include_history=True, include_ask_human=True) + sink = InMemoryRunEventSink() + + async def scenario() -> None: + async with httpx.AsyncClient() as client: + with pytest.raises(ValueError, match="does not support approval requests"): + await AgentRunRunner( + sink=sink, + request=request, + run_id="run-ask-human-approval", + plugin_daemon_http_client=client, + ).run() + + asyncio.run(scenario()) + + assert [event.type for event in sink.events["run-ask-human-approval"]] == ["run_started", "run_failed"] + + def test_runner_passes_dynamic_dify_plugin_tools_to_agent(monkeypatch: pytest.MonkeyPatch) -> None: seen_tools: list[Tool[object]] = [] diff --git a/dify-agent/tests/local/dify_agent/server/test_sandbox_files.py b/dify-agent/tests/local/dify_agent/server/test_sandbox_files.py new file mode 100644 index 00000000000..35e83667d17 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/server/test_sandbox_files.py @@ -0,0 +1,446 @@ +from __future__ import annotations + +import asyncio +import base64 +import json +from collections.abc import Callable, Mapping +from dataclasses import dataclass + +import pytest +from agenton.compositor import CompositorSessionSnapshot, LayerProvider +from agenton.compositor.schemas import LayerSessionSnapshot +from agenton.layers.base import LifecycleState +from dify_agent.agent_stub.server.shell_agent_stub_env import AGENT_STUB_AUTH_JWE_ENV_VAR, AGENT_STUB_URL_ENV_VAR +from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig +from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer +from dify_agent.layers.shell import DifyShellLayerConfig +from dify_agent.layers.shell.layer import DifyShellLayer +from dify_agent.protocol import ( + CreateRunRequest, + RunComposition, + RunLayerSpec, + SandboxLocator, + SandboxListRequest, + SandboxReadRequest, + SandboxUploadRequest, + build_sandbox_locator_from_run_request, +) +from dify_agent.server.routes.sandbox_files import create_sandbox_files_router +from dify_agent.server.sandbox_files import ( + SandboxFileError, + SandboxFileService, + _OUTPUT_BEGIN, + _OUTPUT_END, +) +from fastapi import FastAPI +from fastapi.testclient import TestClient +from shell_session_manager.shellctl.shared import DeleteJobResponse, JobResult, JobStatusName + + +@dataclass(slots=True) +class RunCall: + script: str + cwd: str | None + env: Mapping[str, str] | None + timeout: float + + +class FakeShellctlClient: + def __init__( + self, + *, + run_handler: Callable[[str, str | None, Mapping[str, str] | None, float], JobResult], + ) -> None: + self.run_handler = run_handler + self.run_calls: list[RunCall] = [] + self.delete_calls: list[str] = [] + + async def run( + self, + script: str, + *, + cwd: str | None = None, + env: Mapping[str, str] | None = None, + timeout: float = 10.0, + ) -> JobResult: + self.run_calls.append(RunCall(script=script, cwd=cwd, env=env, timeout=timeout)) + return self.run_handler(script, cwd, env, timeout) + + async def wait(self, job_id: str, *, offset: int, timeout: float = 10.0) -> JobResult: + raise AssertionError(f"Unexpected wait() call for {job_id} offset={offset} timeout={timeout}") + + async def input(self, job_id: str, text: str, *, offset: int, timeout: float = 10.0) -> JobResult: + raise AssertionError(f"Unexpected input() call for {job_id} text={text!r}") + + async def terminate(self, job_id: str, grace_seconds: float = 2.0): + raise AssertionError(f"Unexpected terminate() call for {job_id} grace={grace_seconds}") + + async def delete( + self, + job_id: str, + *, + force: bool = False, + grace_seconds: float | None = None, + ) -> DeleteJobResponse: + del force, grace_seconds + self.delete_calls.append(job_id) + return DeleteJobResponse(job_id=job_id) + + async def close(self) -> None: + return None + + +def _wrap(payload: dict[str, object], *, pty_wrap: int = 0, noise: bool = False) -> str: + blob = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii") + if pty_wrap: + blob = "\n".join(blob[index : index + pty_wrap] for index in range(0, len(blob), pty_wrap)) + framed = f"{_OUTPUT_BEGIN}{blob}{_OUTPUT_END}\n" + if noise: + framed = f"user@host:~/workspace/abc12ff$ python3 - ...\r\n{framed}user@host:~/workspace/abc12ff$ \r\n" + return framed + + +def _job_result(*, output: dict[str, object] | str, job_id: str = "sandbox-job") -> JobResult: + return JobResult( + job_id=job_id, + status=JobStatusName.EXITED, + done=True, + exit_code=0, + output=_wrap(output) if isinstance(output, dict) else output, + offset=0, + truncated=False, + output_path="/tmp/sandbox-job.out", + ) + + +def _failed_job_result(*, output: str, exit_code: int, job_id: str = "sandbox-job") -> JobResult: + return JobResult( + job_id=job_id, + status=JobStatusName.EXITED, + done=True, + exit_code=exit_code, + output=output, + offset=0, + truncated=False, + output_path="/tmp/sandbox-job.out", + ) + + +def _execution_context() -> DifyExecutionContextLayerConfig: + return DifyExecutionContextLayerConfig( + tenant_id="tenant-1", + user_id="user-1", + user_from="account", + app_id="app-1", + conversation_id="conv-1", + agent_id="agent-1", + agent_config_version_id="snapshot-1", + agent_mode="agent_app", + invoke_from="service-api", + ) + + +def _locator() -> SandboxLocator: + request = CreateRunRequest( + composition=RunComposition( + layers=[ + RunLayerSpec(name="execution_context", type="dify.execution_context", config=_execution_context()), + RunLayerSpec( + name="shell", + type="dify.shell", + deps={"execution_context": "execution_context"}, + config=DifyShellLayerConfig(), + ), + ] + ), + session_snapshot=CompositorSessionSnapshot( + layers=[ + LayerSessionSnapshot( + name="execution_context", + lifecycle_state=LifecycleState.SUSPENDED, + runtime_state={}, + ), + LayerSessionSnapshot( + name="shell", + lifecycle_state=LifecycleState.SUSPENDED, + runtime_state={"session_id": "abc12ff", "workspace_cwd": "~/workspace/abc12ff"}, + ), + ] + ), + ) + return build_sandbox_locator_from_run_request(request) + + +def _service( + run_handler: Callable[[str, str | None, Mapping[str, str] | None, float], JobResult], +) -> tuple[SandboxFileService, FakeShellctlClient]: + client = FakeShellctlClient(run_handler=run_handler) + execution_context_provider = LayerProvider.from_factory( + layer_type=DifyExecutionContextLayer, + create=lambda config: DifyExecutionContextLayer.from_config_with_settings( + DifyExecutionContextLayerConfig.model_validate(config), + daemon_url="http://plugin-daemon", + daemon_api_key="daemon-secret", + ), + ) + shell_provider = LayerProvider.from_factory( + layer_type=DifyShellLayer, + create=lambda config: DifyShellLayer.from_config_with_settings( + DifyShellLayerConfig.model_validate(config), + shellctl_entrypoint="http://shellctl", + shellctl_client_factory=lambda _entrypoint: client, + agent_stub_url="https://agent.example.com/agent-stub", + agent_stub_token_factory=lambda execution_context, *, session_id: ( + f"token-for:{execution_context.tenant_id}:{session_id}" + ), + ), + ) + return SandboxFileService(layer_providers=(execution_context_provider, shell_provider)), client + + +def test_list_files_runs_fixed_script_and_parses_response() -> None: + service, client = _service( + lambda script, cwd, env, timeout: _job_result( + output={ + "path": ".", + "entries": [{"name": "notes.txt", "type": "file", "size": 5, "mtime": 1}], + "truncated": False, + } + ) + ) + + result = asyncio.run(service.list_files(SandboxListRequest(locator=_locator(), path="."))) + + assert result.entries[0].name == "notes.txt" + assert client.run_calls[0].cwd == "~/workspace/abc12ff" + assert client.run_calls[0].env is None + assert "python3 - . 1000 <<'PY'" in client.run_calls[0].script + assert client.delete_calls == ["sandbox-job"] + + +@pytest.mark.parametrize("bad_path", ["/etc/passwd", "~/secret-dir", "bad\x00path"]) +def test_list_files_rejects_invalid_paths_before_shell_execution(bad_path: str) -> None: + service, client = _service( + lambda script, cwd, env, timeout: _job_result( + output={"path": ".", "entries": [], "truncated": False}, + ) + ) + + with pytest.raises(SandboxFileError) as exc_info: + asyncio.run(service.list_files(SandboxListRequest(locator=_locator(), path=bad_path))) + + assert exc_info.value.code == "invalid_sandbox_path" + assert client.run_calls == [] + + +def test_decode_tolerates_pty_wrapped_base64_and_shell_noise() -> None: + service, _client = _service( + lambda script, cwd, env, timeout: _job_result( + output=_wrap( + { + "path": "note.txt", + "size": 40, + "truncated": False, + "binary": False, + "text": "hello from sandbox\n" * 4, + }, + pty_wrap=12, + noise=True, + ) + ) + ) + + result = asyncio.run(service.read_file(SandboxReadRequest(locator=_locator(), path="note.txt"))) + + assert result.text == "hello from sandbox\n" * 4 + + +def test_read_file_maps_script_error_codes() -> None: + service, _client = _service( + lambda script, cwd, env, timeout: _job_result( + output={"error": "sandbox_path_not_found", "message": "path not found in sandbox"} + ) + ) + + with pytest.raises(SandboxFileError) as exc_info: + asyncio.run(service.read_file(SandboxReadRequest(locator=_locator(), path="missing.txt"))) + + assert exc_info.value.code == "sandbox_path_not_found" + assert exc_info.value.status_code == 404 + + +@pytest.mark.parametrize("bad_path", ["", "/etc/passwd", "~/secret.txt", "bad\x00path"]) +def test_read_file_rejects_invalid_paths_before_shell_execution(bad_path: str) -> None: + service, client = _service( + lambda script, cwd, env, timeout: _job_result( + output={ + "path": "should-not-run", + "size": 1, + "truncated": False, + "binary": False, + "text": "x", + } + ) + ) + + with pytest.raises(SandboxFileError) as exc_info: + asyncio.run(service.read_file(SandboxReadRequest(locator=_locator(), path=bad_path))) + + assert exc_info.value.code == "invalid_sandbox_path" + assert client.run_calls == [] + + +def test_read_file_returns_binary_payload_without_text() -> None: + service, _client = _service( + lambda script, cwd, env, timeout: _job_result( + output={ + "path": "blob.bin", + "size": 64, + "truncated": False, + "binary": True, + "text": None, + } + ) + ) + + result = asyncio.run(service.read_file(SandboxReadRequest(locator=_locator(), path="blob.bin"))) + + assert result.binary is True + assert result.text is None + assert result.truncated is False + + +def test_read_file_preserves_truncated_flag_for_large_text() -> None: + service, _client = _service( + lambda script, cwd, env, timeout: _job_result( + output={ + "path": "large.txt", + "size": 1024, + "truncated": True, + "binary": False, + "text": "partial preview", + } + ) + ) + + result = asyncio.run(service.read_file(SandboxReadRequest(locator=_locator(), path="large.txt"))) + + assert result.binary is False + assert result.text == "partial preview" + assert result.truncated is True + + +def test_upload_file_injects_agent_stub_env_and_returns_mapping() -> None: + def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult: + assert cwd == "~/workspace/abc12ff" + assert timeout == 30.0 + assert env == { + AGENT_STUB_URL_ENV_VAR: "https://agent.example.com/agent-stub", + AGENT_STUB_AUTH_JWE_ENV_VAR: "token-for:tenant-1:abc12ff", + } + assert 'dify-agent", "file", "upload"' in script + return _job_result( + output={ + "path": "report.txt", + "file": {"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"}, + }, + job_id="upload-job", + ) + + service, client = _service(run_handler) + + result = asyncio.run(service.upload_file(SandboxUploadRequest(locator=_locator(), path="report.txt"))) + + assert result.file.reference == "dify-file-ref:file-1" + assert client.delete_calls == ["upload-job"] + + +def test_upload_file_maps_agent_stub_upload_failed_payload() -> None: + service, _client = _service( + lambda script, cwd, env, timeout: _job_result( + output={ + "error": "agent_stub_upload_failed", + "message": "upload returned invalid JSON", + }, + job_id="upload-failed-job", + ) + ) + + with pytest.raises(SandboxFileError) as exc_info: + asyncio.run(service.upload_file(SandboxUploadRequest(locator=_locator(), path="report.txt"))) + + assert exc_info.value.code == "agent_stub_upload_failed" + assert exc_info.value.status_code == 502 + + +def test_read_file_maps_non_zero_command_exit_to_sandbox_command_failed() -> None: + service, _client = _service( + lambda script, cwd, env, timeout: _failed_job_result( + output="python traceback or stderr tail", + exit_code=17, + ) + ) + + with pytest.raises(SandboxFileError) as exc_info: + asyncio.run(service.read_file(SandboxReadRequest(locator=_locator(), path="note.txt"))) + + assert exc_info.value.code == "sandbox_command_failed" + assert exc_info.value.status_code == 502 + + +def test_upload_file_maps_missing_framed_payload_to_sandbox_command_failed() -> None: + service, _client = _service( + lambda script, cwd, env, timeout: _job_result(output="plain output without sentinel framing") + ) + + with pytest.raises(SandboxFileError) as exc_info: + asyncio.run(service.upload_file(SandboxUploadRequest(locator=_locator(), path="report.txt"))) + + assert exc_info.value.code == "sandbox_command_failed" + assert exc_info.value.status_code == 502 + + +@pytest.mark.parametrize("bad_path", ["", "/etc/passwd", "~/secret.txt", "bad\x00path"]) +def test_upload_file_rejects_invalid_paths_before_shell_execution(bad_path: str) -> None: + service, client = _service( + lambda script, cwd, env, timeout: _job_result( + output={ + "path": "should-not-run", + "file": {"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"}, + } + ) + ) + + with pytest.raises(SandboxFileError) as exc_info: + asyncio.run(service.upload_file(SandboxUploadRequest(locator=_locator(), path=bad_path))) + + assert exc_info.value.code == "invalid_sandbox_path" + assert client.run_calls == [] + + +def _client(service: SandboxFileService | None) -> TestClient: + app = FastAPI() + app.include_router(create_sandbox_files_router(lambda: service)) + return TestClient(app) + + +def test_router_list_ok() -> None: + service, _client_instance = _service( + lambda script, cwd, env, timeout: _job_result(output={"path": ".", "entries": [], "truncated": False}) + ) + + response = _client(service).post( + "/sandbox/files/list", json={"locator": _locator().model_dump(mode="json"), "path": "."} + ) + + assert response.status_code == 200 + assert response.json()["path"] == "." + + +def test_router_returns_503_when_service_unconfigured() -> None: + response = _client(None).post( + "/sandbox/files/list", json={"locator": _locator().model_dump(mode="json"), "path": "."} + ) + + assert response.status_code == 503 + assert response.json()["detail"]["code"] == "sandbox_backend_unavailable" diff --git a/dify-agent/tests/local/dify_agent/server/test_workspace_files.py b/dify-agent/tests/local/dify_agent/server/test_workspace_files.py deleted file mode 100644 index 0e38ed99d1c..00000000000 --- a/dify-agent/tests/local/dify_agent/server/test_workspace_files.py +++ /dev/null @@ -1,270 +0,0 @@ -"""Unit tests for the read-only workspace file inspector (agent-backend side). - -A fake shellctl client returns reader-style output (base64-of-JSON between -sentinels) so the tests cover decode/error-mapping/paging and PTY-newline -tolerance without a live shellctl. -""" - -from __future__ import annotations - -import asyncio -import base64 -import json - -import pytest -from fastapi import FastAPI -from fastapi.testclient import TestClient -from shell_session_manager.shellctl.shared import JobResult, JobStatusName - -from dify_agent.server.routes.workspace_files import create_workspace_files_router -from dify_agent.server.workspace_files import ( - _BEGIN, - _END, - WorkspaceFileError, - WorkspaceFileService, - _validate_rel_path, -) - -SID = "abc1234" # valid 5+2 lowercase hex - - -def _wrap(payload: dict[str, object], *, pty_wrap: int = 0, noise: bool = True) -> str: - """Render a reader result the way the in-workspace Python reader would.""" - blob = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii") - if pty_wrap: - blob = "\n".join(blob[i : i + pty_wrap] for i in range(0, len(blob), pty_wrap)) - body = f"{_BEGIN}{blob}{_END}\n" - if noise: - body = f"user@host:~/workspace/{SID}$ python3 -c ...\r\n" + body + f"user@host:~/workspace/{SID}$ \r\n" - return body - - -class FakeShellctlClient: - """Returns queued output windows; records cleanup calls.""" - - def __init__(self, windows: list[str]) -> None: - self.windows = windows - self._cursor = 0 - self.run_scripts: list[str] = [] - self.deleted: list[str] = [] - self.closed = False - - def _result(self, chunk: str, *, last: bool) -> JobResult: - return JobResult( - job_id="job-1", - done=last, - status=JobStatusName.EXITED, - exit_code=0, - output_path="/tmp/job-1.out", - output=chunk, - offset=64 * (self._cursor + 1), - truncated=not last, - ) - - async def run(self, script: str, *, timeout: float = 30.0, terminal: object | None = None) -> JobResult: - del timeout, terminal - self.run_scripts.append(script) - self._cursor = 0 - return self._result(self.windows[0], last=len(self.windows) == 1) - - async def wait(self, job_id: str, *, offset: int, timeout: float = 30.0) -> JobResult: - del job_id, offset, timeout - self._cursor += 1 - chunk = self.windows[self._cursor] - return self._result(chunk, last=self._cursor == len(self.windows) - 1) - - async def delete(self, job_id: str, *, force: bool = False) -> object: - del force - self.deleted.append(job_id) - return {"deleted": True} - - async def close(self) -> None: - self.closed = True - - -def _service(windows: list[str]) -> tuple[WorkspaceFileService, FakeShellctlClient]: - fake = FakeShellctlClient(windows) - service = WorkspaceFileService(shellctl_entrypoint="http://shellctl", client_factory=lambda: fake) - return service, fake - - -# --- service: happy paths ------------------------------------------------------ - - -def test_list_dir_returns_entries_and_cleans_up() -> None: - payload = { - "entries": [ - {"name": "notes.txt", "type": "file", "size": 12, "mtime": 1700000000}, - {"name": "sub", "type": "dir", "size": 4096, "mtime": 1700000001}, - ], - "truncated": False, - } - service, fake = _service([_wrap(payload)]) - - result = asyncio.run(service.list_dir(SID, ".")) - - assert [e.name for e in result.entries] == ["notes.txt", "sub"] - assert result.entries[1].type == "dir" - assert result.truncated is False - # cleanup: read job deleted and client closed - assert fake.deleted == ["job-1"] - assert fake.closed is True - - -def test_preview_text_decodes_content() -> None: - content = "hello ZEBRA\nsecond line\n" - payload = { - "size": len(content), - "truncated": False, - "binary": False, - "content_base64": base64.b64encode(content.encode()).decode(), - } - service, _ = _service([_wrap(payload)]) - - result = asyncio.run(service.preview(SID, "notes.txt")) - - assert result.binary is False - assert result.text == content - assert result.size == len(content) - - -def test_preview_binary_has_no_text() -> None: - payload = {"size": 300, "truncated": True, "binary": True, "content_base64": base64.b64encode(b"\x00\x01").decode()} - service, _ = _service([_wrap(payload)]) - - result = asyncio.run(service.preview(SID, "blob.bin")) - - assert result.binary is True - assert result.text is None - assert result.truncated is True - - -def test_download_roundtrips_bytes() -> None: - raw = bytes(range(256)) - payload = {"size": len(raw), "truncated": False, "content_base64": base64.b64encode(raw).decode()} - service, _ = _service([_wrap(payload)]) - - result = asyncio.run(service.download(SID, "sub/data.bin")) - - assert base64.b64decode(result.content_base64) == raw - assert result.size == 256 - - -# --- service: PTY tolerance + paging ------------------------------------------ - - -def test_decode_tolerates_pty_inserted_newlines() -> None: - raw = bytes(range(200)) - payload = {"size": len(raw), "truncated": False, "content_base64": base64.b64encode(raw).decode()} - # wrap the base64 blob every 10 chars with newlines, as a narrow PTY would - service, _ = _service([_wrap(payload, pty_wrap=10)]) - - result = asyncio.run(service.download(SID, "blob.bin")) - - assert base64.b64decode(result.content_base64) == raw - - -def test_reads_across_multiple_output_windows() -> None: - raw = bytes(range(128)) - payload = {"size": len(raw), "truncated": False, "content_base64": base64.b64encode(raw).decode()} - full = _wrap(payload, noise=False) - third = len(full) // 3 - windows = [full[:third], full[third : 2 * third], full[2 * third :]] - service, fake = _service(windows) - - result = asyncio.run(service.download(SID, "blob.bin")) - - assert base64.b64decode(result.content_base64) == raw - assert fake._cursor == 2 # paged through all three windows - - -# --- service: error mapping ---------------------------------------------------- - - -@pytest.mark.parametrize( - ("error_code", "status"), - [ - ("workspace_not_found", 404), - ("not_found", 404), - ("path_escape", 400), - ("not_a_directory", 400), - ("is_a_directory", 400), - ], -) -def test_reader_error_maps_to_status(error_code: str, status: int) -> None: - service, _ = _service([_wrap({"error": error_code})]) - - with pytest.raises(WorkspaceFileError) as exc_info: - asyncio.run(service.list_dir(SID, ".")) - - assert exc_info.value.code == error_code - assert exc_info.value.status_code == status - - -def test_invalid_session_id_rejected_before_any_shell_call() -> None: - service, fake = _service([_wrap({"entries": [], "truncated": False})]) - - with pytest.raises(WorkspaceFileError) as exc_info: - asyncio.run(service.list_dir("NOTHEX", ".")) - - assert exc_info.value.code == "invalid_session_id" - assert exc_info.value.status_code == 400 - assert fake.run_scripts == [] # never reached shellctl - - -def test_missing_sentinel_is_reader_failure() -> None: - service, _ = _service(["command not found: python3\r\n"]) - - with pytest.raises(WorkspaceFileError) as exc_info: - asyncio.run(service.list_dir(SID, ".")) - - assert exc_info.value.code == "reader_failed" - assert exc_info.value.status_code == 502 - - -# --- path validation ----------------------------------------------------------- - - -@pytest.mark.parametrize("good", [".", "", "notes.txt", "sub/inner.txt", "a/b/c.json"]) -def test_validate_rel_path_accepts(good: str) -> None: - _validate_rel_path(good) - - -@pytest.mark.parametrize("bad", ["/etc/passwd", "../escape", "sub/../../etc", "~/secrets", "a/\x00b"]) -def test_validate_rel_path_rejects(bad: str) -> None: - with pytest.raises(WorkspaceFileError): - _validate_rel_path(bad) - - -# --- router -------------------------------------------------------------------- - - -def _client(service: WorkspaceFileService | None) -> TestClient: - app = FastAPI() - app.include_router(create_workspace_files_router(lambda: service)) - return TestClient(app) - - -def test_router_list_ok() -> None: - payload = {"entries": [{"name": "a.txt", "type": "file", "size": 1, "mtime": 1}], "truncated": False} - service, _ = _service([_wrap(payload)]) - - response = _client(service).get(f"/workspaces/{SID}/files", params={"path": "."}) - - assert response.status_code == 200 - assert response.json()["entries"][0]["name"] == "a.txt" - - -def test_router_maps_reader_error_to_status() -> None: - service, _ = _service([_wrap({"error": "not_found"})]) - - response = _client(service).get(f"/workspaces/{SID}/files/preview", params={"path": "missing.txt"}) - - assert response.status_code == 404 - assert response.json()["detail"]["code"] == "not_found" - - -def test_router_returns_503_when_inspector_unconfigured() -> None: - response = _client(None).get(f"/workspaces/{SID}/files", params={"path": "."}) - - assert response.status_code == 503 diff --git a/dify-agent/tests/local/dify_agent/test_client_safe_exports.py b/dify-agent/tests/local/dify_agent/test_client_safe_exports.py index 7bcf5515935..30f430521ad 100644 --- a/dify-agent/tests/local/dify_agent/test_client_safe_exports.py +++ b/dify-agent/tests/local/dify_agent/test_client_safe_exports.py @@ -72,8 +72,10 @@ def test_client_public_exports_work_with_default_dependencies_only(tmp_path: Pat agent_stub_protocol_module = importlib.import_module("dify_agent.agent_stub.protocol") agent_stub_cli_main_module = importlib.import_module("dify_agent.agent_stub.cli.main") shell_module = importlib.import_module("dify_agent.layers.shell") + drive_module = importlib.import_module("dify_agent.layers.drive") execution_context_module = importlib.import_module("dify_agent.layers.execution_context") plugin_module = importlib.import_module("dify_agent.layers.dify_plugin") + ask_human_module = importlib.import_module("dify_agent.layers.ask_human") output_module = importlib.import_module("dify_agent.layers.output") assert agenton_layers.ExitIntent is not None @@ -90,8 +92,10 @@ def test_client_public_exports_work_with_default_dependencies_only(tmp_path: Pat assert agent_stub_protocol_module.AgentStubConnectRequest is not None assert agent_stub_cli_main_module.main is not None assert shell_module.DifyShellLayerConfig is not None + assert drive_module.DifyDriveLayerConfig is not None assert execution_context_module.DifyExecutionContextLayerConfig is not None assert plugin_module.DifyPluginLLMLayerConfig is not None + assert ask_human_module.DifyAskHumanLayerConfig is not None assert output_module.DifyOutputLayerConfig is not None grpc_error = importlib.import_module("dify_agent.agent_stub.client._errors").AgentStubMissingGRPCDependencyError diff --git a/dify-agent/tests/local/dify_agent/test_import_boundaries.py b/dify-agent/tests/local/dify_agent/test_import_boundaries.py index 19f1c6c7316..0ac1d77615b 100644 --- a/dify-agent/tests/local/dify_agent/test_import_boundaries.py +++ b/dify-agent/tests/local/dify_agent/test_import_boundaries.py @@ -79,7 +79,9 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() -> blocked_imports=[ "anthropic", "dify_agent.adapters.llm", + "dify_agent.layers.drive.layer", "dify_agent.layers.execution_context.layer", + "dify_agent.layers.ask_human.layer", "dify_agent.layers.dify_plugin.llm_layer", "dify_agent.layers.dify_plugin.tools_layer", "dify_agent.layers.output.output_layer", @@ -97,14 +99,18 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() -> ], imports=[ "dify_agent.protocol", + "dify_agent.layers.drive", "dify_agent.layers.execution_context", + "dify_agent.layers.ask_human", "dify_agent.layers.dify_plugin", "dify_agent.layers.output", "dify_agent.layers.shell", ], assertions=[ "assert hasattr(dify_agent_protocol, 'PydanticAIStreamRunEvent')", + "assert dify_agent_layers_drive.__all__ == ['DIFY_DRIVE_LAYER_TYPE_ID', 'DifyDriveFileConfig', 'DifyDriveLayerConfig', 'DifyDriveSkillConfig']", "assert dify_agent_layers_execution_context.__all__ == ['DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID', 'DifyExecutionContextAgentMode', 'DifyExecutionContextInvokeFrom', 'DifyExecutionContextLayerConfig', 'DifyExecutionContextUserFrom']", + "assert dify_agent_layers_ask_human.__all__ == ['AskHumanAction', 'AskHumanActionStyle', 'AskHumanField', 'AskHumanFieldType', 'AskHumanFileField', 'AskHumanFileListField', 'AskHumanParagraphField', 'AskHumanResultStatus', 'AskHumanSelectField', 'AskHumanSelectOption', 'AskHumanSelectedAction', 'AskHumanToolArgs', 'AskHumanToolResult', 'AskHumanUrgency', 'DEFAULT_ASK_HUMAN_TOOL_DESCRIPTION', 'DIFY_ASK_HUMAN_LAYER_TYPE_ID', 'DifyAskHumanLayerConfig']", "assert dify_agent_layers_dify_plugin.__all__ == ['DIFY_PLUGIN_LLM_LAYER_TYPE_ID', 'DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID', 'DifyPluginCredentialValue', 'DifyPluginLLMLayerConfig', 'DifyPluginToolCredentialType', 'DifyPluginToolConfig', 'DifyPluginToolOption', 'DifyPluginToolParameter', 'DifyPluginToolParameterForm', 'DifyPluginToolParameterType', 'DifyPluginToolsLayerConfig', 'DifyPluginToolValue']", "assert dify_agent_layers_output.__all__ == ['DIFY_OUTPUT_LAYER_TYPE_ID', 'DifyOutputLayerConfig']", "assert dify_agent_layers_shell.__all__ == ['DIFY_SHELL_LAYER_TYPE_ID', 'DifyShellCliToolConfig', 'DifyShellEnvVarConfig', 'DifyShellLayerConfig', 'DifyShellSandboxConfig', 'DifyShellSecretRefConfig']", diff --git a/dify-agent/tests/local/test_packaging.py b/dify-agent/tests/local/test_packaging.py index 6de34442af6..23ae6e65e8b 100644 --- a/dify-agent/tests/local/test_packaging.py +++ b/dify-agent/tests/local/test_packaging.py @@ -16,7 +16,7 @@ CLIENT_SHARED_DTO_DEPENDENCIES = { SERVER_RUNTIME_DEPENDENCIES = { "fastapi==0.136.0", - "graphon==0.2.2", + "graphon==0.5.1", "jsonschema>=4.23.0,<5.0.0", "jwcrypto>=1.5.6,<2", "pydantic-ai-slim[anthropic,google,openai]>=1.85.1,<2.0.0", diff --git a/dify-agent/uv.lock b/dify-agent/uv.lock index 69b54dc1137..0ee1bf4f8bf 100644 --- a/dify-agent/uv.lock +++ b/dify-agent/uv.lock @@ -628,7 +628,7 @@ docs = [ [package.metadata] requires-dist = [ { name = "fastapi", marker = "extra == 'server'", specifier = "==0.136.0" }, - { name = "graphon", marker = "extra == 'server'", specifier = "==0.2.2" }, + { name = "graphon", marker = "extra == 'server'", specifier = "==0.5.1" }, { name = "grpclib", extras = ["protobuf"], marker = "extra == 'grpc'", specifier = ">=0.4.9,<0.5.0" }, { name = "httpx", specifier = "==0.28.1" }, { name = "jsonschema", marker = "extra == 'server'", specifier = ">=4.23.0,<5.0.0" }, @@ -808,7 +808,7 @@ wheels = [ [[package]] name = "graphon" -version = "0.2.2" +version = "0.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "charset-normalizer" }, @@ -829,9 +829,9 @@ dependencies = [ { name = "unstructured", extra = ["docx", "epub", "md", "ppt", "pptx"] }, { name = "webvtt-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/08/50/e745a79c5f742f88f6011a1f7c9ba2c2f9cc1beedd982f0b192f1ab8c748/graphon-0.2.2.tar.gz", hash = "sha256:141f0de536171850f1af6f738dc66f0285aadd3c097f1dad2a038636789e0aa5", size = 236360, upload-time = "2026-04-17T08:52:28.047Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/fa/432fa802bcb13f7f51dc323ddef92594b15333eafef181d937ffa554116e/graphon-0.5.1.tar.gz", hash = "sha256:ca38cc62ef3fbc2f3072b68235bcb41e32a6369a1753b46418c1d761c57125fe", size = 269741, upload-time = "2026-06-11T03:01:38.197Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/89/a6340afdaf5169d17a318e00fc685fb67ed99baa602c2cbbbf6af6a76096/graphon-0.2.2-py3-none-any.whl", hash = "sha256:754e544d08779138f99eac6547ab08559463680e2c76488b05e1c978210392b4", size = 340808, upload-time = "2026-04-17T08:52:26.5Z" }, + { url = "https://files.pythonhosted.org/packages/e9/c5/61e8634b89c320af9453083213e8be436071634dbc69cb14b5fe646763e4/graphon-0.5.1-py3-none-any.whl", hash = "sha256:70b49c244a46fb6e338905210cc895bd67584d9ab1412f6ba3cd4ed284010091", size = 381866, upload-time = "2026-06-11T03:01:36.693Z" }, ] [[package]] diff --git a/docker/.env.example b/docker/.env.example index 76d04b4b0ce..8daa82d05a1 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -135,7 +135,6 @@ AMPLITUDE_API_KEY= TEXT_GENERATION_TIMEOUT_MS=60000 CSP_WHITELIST= ALLOW_EMBED=false -ALLOW_INLINE_STYLES=false ALLOW_UNSAFE_DATA_SCHEME=false TOP_K_MAX_VALUE=10 INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH=4000 diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index 309ea20eb6f..9987483156f 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -387,7 +387,6 @@ services: TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000} CSP_WHITELIST: ${CSP_WHITELIST:-} ALLOW_EMBED: ${ALLOW_EMBED:-false} - ALLOW_INLINE_STYLES: ${ALLOW_INLINE_STYLES:-false} ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false} MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai} MARKETPLACE_URL: ${MARKETPLACE_URL:-https://marketplace.dify.ai} diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index d8aee5bb1e9..2b9f91492eb 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -393,7 +393,6 @@ services: TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000} CSP_WHITELIST: ${CSP_WHITELIST:-} ALLOW_EMBED: ${ALLOW_EMBED:-false} - ALLOW_INLINE_STYLES: ${ALLOW_INLINE_STYLES:-false} ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false} MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai} MARKETPLACE_URL: ${MARKETPLACE_URL:-https://marketplace.dify.ai} diff --git a/docker/envs/core-services/shared.env.example b/docker/envs/core-services/shared.env.example index 49a8d9bbaad..0cc840d2a4d 100644 --- a/docker/envs/core-services/shared.env.example +++ b/docker/envs/core-services/shared.env.example @@ -481,5 +481,11 @@ MILVUS_ENABLE_HYBRID_SEARCH=False ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true HUMAN_INPUT_TIMEOUT_TASK_INTERVAL=1 +# Nacos remote settings source HTTP timeouts (seconds). +# Bound how long requests to the Nacos endpoint wait before failing, so a slow or +# unresponsive Nacos server cannot stall API startup or token refresh. +DIFY_ENV_NACOS_REQUEST_TIMEOUT=10.0 +DIFY_ENV_NACOS_CONNECT_TIMEOUT=3.0 + # uv cache dir UV_CACHE_DIR=/tmp/uv_cache diff --git a/e2e/cucumber.config.ts b/e2e/cucumber.config.ts index c162a6562e9..4f768cc9d26 100644 --- a/e2e/cucumber.config.ts +++ b/e2e/cucumber.config.ts @@ -7,7 +7,7 @@ const config = { 'html:./cucumber-report/report.html', 'json:./cucumber-report/report.json', ], - import: ['features/**/*.ts'], + import: ['./tsx-register.js', 'features/**/*.ts'], parallel: 1, paths: ['features/**/*.feature'], tags: process.env.E2E_CUCUMBER_TAGS || 'not @fresh and not @skip', diff --git a/e2e/features/smoke/authenticated-entry.feature b/e2e/features/smoke/authenticated-entry.feature index 3c1191a330d..53d72bd667c 100644 --- a/e2e/features/smoke/authenticated-entry.feature +++ b/e2e/features/smoke/authenticated-entry.feature @@ -4,5 +4,4 @@ Feature: Authenticated app console Given I am signed in as the default E2E admin When I open the apps console Then I should stay on the apps console - And I should see the "Create from Blank" button And I should not see the "Sign in" button diff --git a/e2e/features/smoke/install.feature b/e2e/features/smoke/install.feature index 39fc1f996b9..5685d874866 100644 --- a/e2e/features/smoke/install.feature +++ b/e2e/features/smoke/install.feature @@ -4,4 +4,3 @@ Feature: Fresh installation bootstrap Given the last authentication bootstrap came from a fresh install When I open the apps console Then I should stay on the apps console - And I should see the "Create from Blank" button diff --git a/e2e/features/step-definitions/apps/create-app.steps.ts b/e2e/features/step-definitions/apps/create-app.steps.ts index 931d4662a22..d6a5eb21d54 100644 --- a/e2e/features/step-definitions/apps/create-app.steps.ts +++ b/e2e/features/step-definitions/apps/create-app.steps.ts @@ -1,12 +1,10 @@ import type { DifyWorld } from '../../support/world' import { Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' +import { openBlankAppCreation } from '../../../support/apps' When('I start creating a blank app', async function (this: DifyWorld) { - const page = this.getPage() - - await expect(page.getByRole('button', { name: 'Create from Blank' })).toBeVisible() - await page.getByRole('button', { name: 'Create from Blank' }).click() + await openBlankAppCreation(this.getPage()) }) When('I enter a unique E2E app name', async function (this: DifyWorld) { diff --git a/e2e/features/step-definitions/apps/delete-app.steps.ts b/e2e/features/step-definitions/apps/delete-app.steps.ts index e5da626645f..e5a5da424aa 100644 --- a/e2e/features/step-definitions/apps/delete-app.steps.ts +++ b/e2e/features/step-definitions/apps/delete-app.steps.ts @@ -29,7 +29,7 @@ Then('the app should no longer appear in the apps console', async function (this ) } - await expect(this.getPage().getByTitle(appName)).not.toBeVisible({ + await expect(this.getPage().getByRole('link', { name: appName, exact: true })).not.toBeVisible({ timeout: 10_000, }) }) diff --git a/e2e/features/step-definitions/apps/duplicate-app.steps.ts b/e2e/features/step-definitions/apps/duplicate-app.steps.ts index e5e3694e4d4..5e998b3603e 100644 --- a/e2e/features/step-definitions/apps/duplicate-app.steps.ts +++ b/e2e/features/step-definitions/apps/duplicate-app.steps.ts @@ -1,5 +1,6 @@ import type { DifyWorld } from '../../support/world' import { Given, When } from '@cucumber/cucumber' +import { expect } from '@playwright/test' import { createTestApp } from '../../../support/api' Given('there is an existing E2E app available for testing', async function (this: DifyWorld) { @@ -15,14 +16,13 @@ When('I open the options menu for the last created E2E app', async function (thi throw new Error('No app name stored. Run "I enter a unique E2E app name" first.') const page = this.getPage() - // Scope to the specific card: the card root is the innermost div that contains - // both the unique app name text and a More button (they are in separate branches, - // so no child div satisfies both). .last() picks the deepest match in DOM order. + const appLink = page.getByRole('link', { name: appName, exact: true }) const appCard = page .locator('div') - .filter({ has: page.getByText(appName, { exact: true }) }) + .filter({ has: appLink }) .filter({ has: page.getByRole('button', { name: 'More' }) }) .last() + await expect(appLink).toBeVisible() await appCard.hover() await appCard.getByRole('button', { name: 'More' }).click() }) diff --git a/e2e/features/step-definitions/auth/sign-in.steps.ts b/e2e/features/step-definitions/auth/sign-in.steps.ts index 095f8164078..469203d8bfd 100644 --- a/e2e/features/step-definitions/auth/sign-in.steps.ts +++ b/e2e/features/step-definitions/auth/sign-in.steps.ts @@ -4,7 +4,7 @@ import { expect } from '@playwright/test' import { adminCredentials } from '../../../fixtures/auth' When('I open the sign-in page', async function (this: DifyWorld) { - await this.getPage().goto('/signin') + await this.getPage().goto('/signin?redirect_url=%2Fapps') }) When('I sign in as the default E2E admin', async function (this: DifyWorld) { diff --git a/e2e/features/step-definitions/common/app.steps.ts b/e2e/features/step-definitions/common/app.steps.ts index 93e808e3c53..6deca22c609 100644 --- a/e2e/features/step-definitions/common/app.steps.ts +++ b/e2e/features/step-definitions/common/app.steps.ts @@ -2,6 +2,7 @@ import type { DifyWorld } from '../../support/world' import { Given, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' import { createTestApp, syncMinimalWorkflowDraft } from '../../../support/api' +import { waitForAppsConsole } from '../../../support/apps' Given('a {string} app has been created via API', async function (this: DifyWorld, mode: string) { const app = await createTestApp(`E2E ${Date.now()}`, mode) @@ -17,6 +18,8 @@ Given('a minimal workflow draft has been synced', async function (this: DifyWorl When('I open the app from the app list', async function (this: DifyWorld) { const page = this.getPage() await page.goto('/apps') - await expect(page.getByRole('button', { name: 'Create from Blank' })).toBeVisible() - await page.getByText(this.lastCreatedAppName!).click() + await waitForAppsConsole(page) + const appLink = page.getByRole('link', { name: this.lastCreatedAppName!, exact: true }) + await expect(appLink).toBeVisible() + await appLink.click() }) diff --git a/e2e/features/step-definitions/common/navigation.steps.ts b/e2e/features/step-definitions/common/navigation.steps.ts index 9bec34c2248..02b860b372b 100644 --- a/e2e/features/step-definitions/common/navigation.steps.ts +++ b/e2e/features/step-definitions/common/navigation.steps.ts @@ -1,13 +1,14 @@ import type { DifyWorld } from '../../support/world' import { Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' +import { waitForAppsConsole } from '../../../support/apps' When('I open the apps console', async function (this: DifyWorld) { await this.getPage().goto('/apps') }) Then('I should stay on the apps console', async function (this: DifyWorld) { - await expect(this.getPage()).toHaveURL(/\/apps(?:\?.*)?$/) + await waitForAppsConsole(this.getPage()) }) Then('I should be redirected to the signin page', async function (this: DifyWorld) { diff --git a/e2e/fixtures/auth.ts b/e2e/fixtures/auth.ts index cc54a6d47b1..9039f97483d 100644 --- a/e2e/fixtures/auth.ts +++ b/e2e/fixtures/auth.ts @@ -1,9 +1,10 @@ -import type { Browser, Page } from '@playwright/test' +import type { APIResponse, Browser, BrowserContext } from '@playwright/test' +import { Buffer } from 'node:buffer' import { mkdir, readFile, writeFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { expect } from '@playwright/test' -import { defaultBaseURL, defaultLocale } from '../test-env' +import { waitForAppsConsole } from '../support/apps' +import { apiURL, defaultBaseURL, defaultLocale } from '../test-env' export type AuthSessionMetadata = { adminEmail: string @@ -12,7 +13,8 @@ export type AuthSessionMetadata = { usedInitPassword: boolean } -export const AUTH_BOOTSTRAP_TIMEOUT_MS = 120_000 +export const AUTH_BOOTSTRAP_TIMEOUT_MS = 180_000 +const AUTH_FLOW_TIMEOUT_MS = AUTH_BOOTSTRAP_TIMEOUT_MS - 30_000 const e2eRoot = fileURLToPath(new URL('..', import.meta.url)) export const authDir = path.join(e2eRoot, '.auth') @@ -35,89 +37,106 @@ export const readAuthSessionMetadata = async () => { return JSON.parse(content) as AuthSessionMetadata } -const escapeRegex = (value: string) => value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') - const appURL = (baseURL: string, pathname: string) => new URL(pathname, baseURL).toString() +const apiEndpoint = (pathname: string) => new URL(pathname, apiURL).toString() -type AuthPageState = 'install' | 'login' | 'init' +type SetupStatusResponse = { + step: 'not_started' | 'finished' +} + +type InitStatusResponse = { + status: 'not_started' | 'finished' +} + +type AuthBootstrapResult = { + mode: AuthSessionMetadata['mode'] + usedInitPassword: boolean +} const getRemainingTimeout = (deadline: number) => Math.max(deadline - Date.now(), 1) -const waitForPageState = async (page: Page, deadline: number): Promise => { - const installHeading = page.getByRole('heading', { name: 'Setting up an admin account' }) - const signInButton = page.getByRole('button', { name: 'Sign in' }) - const initPasswordField = page.getByLabel('Admin initialization password') +const encodeField = (value: string) => Buffer.from(value, 'utf8').toString('base64') - try { - return await Promise.any([ - installHeading - .waitFor({ state: 'visible', timeout: getRemainingTimeout(deadline) }) - .then(() => 'install'), - signInButton - .waitFor({ state: 'visible', timeout: getRemainingTimeout(deadline) }) - .then(() => 'login'), - initPasswordField - .waitFor({ state: 'visible', timeout: getRemainingTimeout(deadline) }) - .then(() => 'init'), - ]) - } - catch { - throw new Error(`Unable to determine auth page state for ${page.url()}`) - } +const assertAPIResponse = async (response: APIResponse, action: string) => { + if (response.ok()) + return + + const body = await response.text().catch(() => '') + throw new Error( + `${action} failed with ${response.status()} ${response.statusText()}${body ? `: ${body}` : ''}`, + ) } -const completeInitPasswordIfNeeded = async (page: Page, deadline: number) => { - const initPasswordField = page.getByLabel('Admin initialization password') - - const needsInitPassword = await initPasswordField - .waitFor({ state: 'visible', timeout: Math.min(getRemainingTimeout(deadline), 3_000) }) - .then(() => true) - .catch(() => false) - - if (!needsInitPassword) - return false - - await initPasswordField.fill(initPassword) - await page.getByRole('button', { name: 'Validate' }).click() - await expect(page.getByRole('heading', { name: 'Setting up an admin account' })).toBeVisible({ +const getConsoleAPI = async (context: BrowserContext, pathname: string, deadline: number) => { + const response = await context.request.get(apiEndpoint(pathname), { timeout: getRemainingTimeout(deadline), }) + await assertAPIResponse(response, `GET ${pathname}`) + return response.json() as Promise +} +const postConsoleAPI = async ( + context: BrowserContext, + pathname: string, + deadline: number, + data: Record, +) => { + const response = await context.request.post(apiEndpoint(pathname), { + data, + timeout: getRemainingTimeout(deadline), + }) + await assertAPIResponse(response, `POST ${pathname}`) +} + +const validateInitPasswordIfNeeded = async (context: BrowserContext, deadline: number) => { + const initStatus = await getConsoleAPI(context, '/console/api/init', deadline) + if (initStatus.status === 'finished') + return false + + console.warn('[e2e] auth bootstrap: validating init password') + await postConsoleAPI(context, '/console/api/init', deadline, { password: initPassword }) return true } -const completeInstall = async (page: Page, baseURL: string, deadline: number) => { - await expect(page.getByRole('heading', { name: 'Setting up an admin account' })).toBeVisible({ - timeout: getRemainingTimeout(deadline), - }) +const ensureAdminAccount = async ( + context: BrowserContext, + deadline: number, +): Promise => { + const setupStatus = await getConsoleAPI( + context, + '/console/api/setup', + deadline, + ) + let usedInitPassword = false - await page.getByLabel('Email address').fill(adminCredentials.email) - await page.getByLabel('Username').fill(adminCredentials.name) - await page.getByLabel('Password').fill(adminCredentials.password) - await page.getByRole('button', { name: 'Set up' }).click() + if (setupStatus.step === 'not_started') { + usedInitPassword = await validateInitPasswordIfNeeded(context, deadline) + console.warn('[e2e] auth bootstrap: creating admin account') + await postConsoleAPI(context, '/console/api/setup', deadline, { + email: adminCredentials.email, + name: adminCredentials.name, + password: adminCredentials.password, + language: defaultLocale, + }) - await expect(page).toHaveURL(new RegExp(`^${escapeRegex(baseURL)}/apps(?:\\?.*)?$`), { - timeout: getRemainingTimeout(deadline), - }) + return { mode: 'install', usedInitPassword } + } + + return { mode: 'login', usedInitPassword } } -const completeLogin = async (page: Page, baseURL: string, deadline: number) => { - await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible({ - timeout: getRemainingTimeout(deadline), - }) - - await page.getByLabel('Email address').fill(adminCredentials.email) - await page.getByLabel('Password').fill(adminCredentials.password) - await page.getByRole('button', { name: 'Sign in' }).click() - - await expect(page).toHaveURL(new RegExp(`^${escapeRegex(baseURL)}/apps(?:\\?.*)?$`), { - timeout: getRemainingTimeout(deadline), +const loginAdmin = async (context: BrowserContext, deadline: number) => { + console.warn('[e2e] auth bootstrap: logging in admin') + await postConsoleAPI(context, '/console/api/login', deadline, { + email: adminCredentials.email, + password: encodeField(adminCredentials.password), + remember_me: true, }) } export const ensureAuthenticatedState = async (browser: Browser, configuredBaseURL?: string) => { const baseURL = resolveBaseURL(configuredBaseURL) - const deadline = Date.now() + AUTH_BOOTSTRAP_TIMEOUT_MS + const deadline = Date.now() + AUTH_FLOW_TIMEOUT_MS await mkdir(authDir, { recursive: true }) @@ -128,37 +147,22 @@ export const ensureAuthenticatedState = async (browser: Browser, configuredBaseU const page = await context.newPage() try { - await page.goto(appURL(baseURL, '/install'), { + const { mode, usedInitPassword } = await ensureAdminAccount(context, deadline) + await loginAdmin(context, deadline) + + console.warn('[e2e] auth bootstrap: verifying apps console') + await page.goto(appURL(baseURL, '/apps'), { timeout: getRemainingTimeout(deadline), waitUntil: 'domcontentloaded', }) - - let usedInitPassword = await completeInitPasswordIfNeeded(page, deadline) - let pageState = await waitForPageState(page, deadline) - - while (pageState === 'init') { - const completedInitPassword = await completeInitPasswordIfNeeded(page, deadline) - if (!completedInitPassword) - throw new Error(`Unable to validate initialization password for ${page.url()}`) - - usedInitPassword = true - pageState = await waitForPageState(page, deadline) - } - - if (pageState === 'install') - await completeInstall(page, baseURL, deadline) - else await completeLogin(page, baseURL, deadline) - - await expect(page.getByRole('button', { name: 'Create from Blank' })).toBeVisible({ - timeout: getRemainingTimeout(deadline), - }) + await waitForAppsConsole(page, getRemainingTimeout(deadline)) await context.storageState({ path: authStatePath }) const metadata: AuthSessionMetadata = { adminEmail: adminCredentials.email, baseURL, - mode: pageState, + mode, usedInitPassword, } diff --git a/e2e/support/apps.ts b/e2e/support/apps.ts new file mode 100644 index 00000000000..f035b5f4a1b --- /dev/null +++ b/e2e/support/apps.ts @@ -0,0 +1,24 @@ +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' + +export const waitForAppsConsole = async (page: Page, timeout?: number) => { + await expect(page).toHaveURL(/\/apps(?:\?.*)?$/, timeout === undefined ? undefined : { timeout }) + await expect(page.getByRole('heading', { name: 'Studio' })).toBeVisible( + timeout === undefined ? undefined : { timeout }, + ) +} + +export const openBlankAppCreation = async (page: Page) => { + const createFromBlankButton = page.getByRole('button', { name: 'Create from Blank' }).first() + const isDirectCreateVisible = await createFromBlankButton + .isVisible({ timeout: 3_000 }) + .catch(() => false) + + if (isDirectCreateVisible) { + await createFromBlankButton.click() + return + } + + await page.getByRole('button', { name: 'Create' }).click() + await page.getByRole('menuitem', { name: 'Create from Blank' }).click() +} diff --git a/e2e/tsx-register.js b/e2e/tsx-register.js new file mode 100644 index 00000000000..257bae887a3 --- /dev/null +++ b/e2e/tsx-register.js @@ -0,0 +1,3 @@ +import { register } from 'tsx/esm/api' + +register() diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 5af212a016d..87230e947ef 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -37,6 +37,44 @@ "count": 3 } }, + "web/__mocks__/base-ui-dropdown-menu.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 3 + }, + "jsx-a11y/interactive-supports-focus": { + "count": 2 + }, + "jsx-a11y/role-has-required-aria-props": { + "count": 1 + } + }, + "web/__mocks__/base-ui-popover.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/__mocks__/base-ui-select.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/interactive-supports-focus": { + "count": 1 + }, + "jsx-a11y/role-has-required-aria-props": { + "count": 2 + } + }, + "web/__mocks__/base-ui-tooltip.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/__mocks__/zustand.ts": { "no-barrel-files/no-barrel-files": { "count": 1 @@ -63,6 +101,12 @@ } }, "web/__tests__/goto-anything/command-selector.test.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "ts/no-explicit-any": { "count": 2 } @@ -108,14 +152,11 @@ "count": 1 } }, - "web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx": { - "no-restricted-globals": { + "web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/date-picker.tsx": { + "jsx-a11y/click-events-have-key-events": { "count": 1 }, - "react/set-state-in-effect": { - "count": 2 - }, - "ts/no-explicit-any": { + "jsx-a11y/no-static-element-interactions": { "count": 1 } }, @@ -133,6 +174,12 @@ } }, "web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-panel.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 3 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 3 + }, "react/static-components": { "count": 2 }, @@ -145,17 +192,32 @@ "count": 1 } }, - "web/app/(commonLayout)/snippets/[snippetId]/page.tsx": { - "no-restricted-imports": { + "web/app/(shareLayout)/components/authenticated-layout.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { "count": 1 } }, "web/app/(shareLayout)/components/splash.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "react/set-state-in-effect": { "count": 1 } }, "web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "no-restricted-imports": { "count": 1 } @@ -179,6 +241,12 @@ } }, "web/app/(shareLayout)/webapp-signin/check-code/page.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "no-restricted-imports": { "count": 1 } @@ -192,6 +260,9 @@ } }, "web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx": { + "jsx-a11y/tabindex-no-positive": { + "count": 3 + }, "no-restricted-imports": { "count": 1 }, @@ -199,12 +270,40 @@ "count": 2 } }, + "web/app/(shareLayout)/webapp-signin/normalForm.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + } + }, + "web/app/(shareLayout)/webapp-signin/page.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/account/(commonLayout)/account-page/email-change-modal.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 3 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 3 + }, "no-restricted-imports": { "count": 2 } }, "web/app/account/(commonLayout)/account-page/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + }, "no-restricted-imports": { "count": 1 } @@ -237,7 +336,15 @@ "count": 1 } }, + "web/app/components/app-sidebar/__tests__/sidebar-animation-issues.spec.tsx": { + "jsx-a11y/anchor-is-valid": { + "count": 1 + } + }, "web/app/components/app-sidebar/app-info/app-info-modals.tsx": { + "jsx-a11y/label-has-associated-control": { + "count": 1 + }, "no-restricted-imports": { "count": 1 } @@ -247,11 +354,32 @@ "count": 4 } }, + "web/app/components/app-sidebar/app-sidebar-dropdown.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/app-sidebar/dataset-info/menu-item.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/app-sidebar/index.tsx": { "no-restricted-globals": { - "count": 2 - }, - "ts/no-explicit-any": { "count": 1 } }, @@ -259,6 +387,15 @@ "erasable-syntax-only/enums": { "count": 1 }, + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-autofocus": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "react-refresh/only-export-components": { "count": 1 } @@ -286,6 +423,15 @@ "erasable-syntax-only/enums": { "count": 1 }, + "jsx-a11y/click-events-have-key-events": { + "count": 3 + }, + "jsx-a11y/no-autofocus": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 3 + }, "react-refresh/only-export-components": { "count": 1 }, @@ -293,6 +439,14 @@ "count": 1 } }, + "web/app/components/app/annotation/edit-annotation-modal/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/app/annotation/filter.tsx": { "no-restricted-imports": { "count": 1 @@ -320,6 +474,12 @@ "erasable-syntax-only/enums": { "count": 1 }, + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "react/set-state-in-effect": { "count": 5 }, @@ -327,11 +487,43 @@ "count": 1 } }, + "web/app/components/app/app-access-control/access-control-item.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/app/app-publisher/sections.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/app/configuration/base/operation-btn/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/app/configuration/base/var-highlight/index.tsx": { "react-refresh/only-export-components": { "count": 1 } }, + "web/app/components/app/configuration/config-prompt/__tests__/index.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx": { "ts/no-explicit-any": { "count": 2 @@ -342,6 +534,19 @@ "count": 1 } }, + "web/app/components/app/configuration/config-prompt/message-type-selector.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + } + }, + "web/app/components/app/configuration/config-prompt/prompt-editor-height-resize-wrap.tsx": { + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx": { "ts/no-explicit-any": { "count": 3 @@ -353,6 +558,9 @@ } }, "web/app/components/app/configuration/config-var/config-modal/form-fields.tsx": { + "jsx-a11y/anchor-has-content": { + "count": 1 + }, "no-restricted-imports": { "count": 1 } @@ -362,17 +570,44 @@ "count": 4 } }, + "web/app/components/app/configuration/config-var/config-select/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/app/configuration/config-var/config-string/index.tsx": { "no-restricted-imports": { "count": 1 } }, + "web/app/components/app/configuration/config-var/select-type-item/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/app/configuration/config-var/select-var-type.tsx": { "ts/no-explicit-any": { "count": 1 } }, + "web/app/components/app/configuration/config-var/var-item.tsx": { + "jsx-a11y/mouse-events-have-key-events": { + "count": 1 + } + }, "web/app/components/app/configuration/config/agent/agent-setting/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "react/set-state-in-effect": { "count": 1 }, @@ -381,11 +616,20 @@ } }, "web/app/components/app/configuration/config/agent/agent-tools/index.tsx": { + "jsx-a11y/mouse-events-have-key-events": { + "count": 2 + }, "ts/no-explicit-any": { "count": 9 } }, "web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "react-hooks/exhaustive-deps": { "count": 1 }, @@ -397,11 +641,23 @@ } }, "web/app/components/app/configuration/config/assistant-type-picker/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "ts/no-explicit-any": { "count": 1 } }, "web/app/components/app/configuration/config/automatic/get-automatic-res.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "no-restricted-globals": { "count": 6 }, @@ -412,6 +668,14 @@ "count": 1 } }, + "web/app/components/app/configuration/config/automatic/idea-output.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/app/configuration/config/automatic/instruction-editor.tsx": { "ts/no-explicit-any": { "count": 2 @@ -440,6 +704,30 @@ "count": 2 } }, + "web/app/components/app/configuration/dataset-config/context-var/__tests__/index.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/app/configuration/dataset-config/context-var/__tests__/var-picker.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/app/configuration/dataset-config/context-var/var-picker.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/app/configuration/dataset-config/index.tsx": { "ts/no-explicit-any": { "count": 1 @@ -450,12 +738,26 @@ "count": 1 } }, + "web/app/components/app/configuration/dataset-config/params-config/config-content.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/app/configuration/dataset-config/params-config/index.tsx": { "react/set-state-in-effect": { "count": 1 } }, "web/app/components/app/configuration/dataset-config/settings-modal/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "no-restricted-imports": { "count": 1 }, @@ -471,12 +773,20 @@ "count": 1 } }, + "web/app/components/app/configuration/debug/__tests__/chat-user-input.spec.tsx": { + "jsx-a11y/role-has-required-aria-props": { + "count": 1 + } + }, "web/app/components/app/configuration/debug/__tests__/index.spec.tsx": { "ts/no-explicit-any": { "count": 1 } }, "web/app/components/app/configuration/debug/chat-user-input.tsx": { + "jsx-a11y/no-autofocus": { + "count": 2 + }, "no-restricted-imports": { "count": 1 } @@ -523,6 +833,9 @@ } }, "web/app/components/app/configuration/prompt-value-panel/index.tsx": { + "jsx-a11y/no-autofocus": { + "count": 2 + }, "no-restricted-imports": { "count": 1 } @@ -532,6 +845,14 @@ "count": 1 } }, + "web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/app/create-app-dialog/app-list/index.tsx": { "no-restricted-imports": { "count": 1 @@ -546,6 +867,12 @@ } }, "web/app/components/app/create-app-modal/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "no-restricted-imports": { "count": 1 } @@ -554,6 +881,12 @@ "erasable-syntax-only/enums": { "count": 1 }, + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + }, "no-restricted-imports": { "count": 1 }, @@ -598,13 +931,45 @@ "count": 2 } }, + "web/app/components/app/log/var-panel.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + } + }, + "web/app/components/app/overview/apikey-info-panel/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/app/overview/app-card-sections.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + } + }, "web/app/components/app/overview/app-chart.tsx": { "react/component-hook-factories": { "count": 1 } }, + "web/app/components/app/overview/customize/index.tsx": { + "jsx-a11y/anchor-has-content": { + "count": 3 + } + }, "web/app/components/app/overview/settings/index.tsx": { - "no-restricted-imports": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { "count": 1 } }, @@ -636,6 +1001,14 @@ "count": 2 } }, + "web/app/components/app/text-generate/item/workflow-body.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + } + }, "web/app/components/app/workflow-log/filter.tsx": { "no-restricted-imports": { "count": 1 @@ -654,14 +1027,19 @@ "count": 1 } }, - "web/app/components/apps/new-app-card.tsx": { - "react-hooks-extra/no-direct-set-state-in-use-effect": { + "web/app/components/apps/app-card.tsx": { + "jsx-a11y/click-events-have-key-events": { "count": 1 }, - "react/set-state-in-effect": { + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/apps/import-from-marketplace-template-modal.tsx": { + "jsx-a11y/click-events-have-key-events": { "count": 1 }, - "ts/no-explicit-any": { + "jsx-a11y/no-static-element-interactions": { "count": 1 } }, @@ -694,6 +1072,12 @@ } }, "web/app/components/base/agent-log-modal/tool-call.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "ts/no-explicit-any": { "count": 2 } @@ -713,6 +1097,14 @@ "count": 1 } }, + "web/app/components/base/app-icon/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/base/audio-btn/audio.ts": { "node/prefer-global/buffer": { "count": 1 @@ -722,10 +1114,18 @@ } }, "web/app/components/base/audio-gallery/AudioPlayer.tsx": { + "jsx-a11y/media-has-caption": { + "count": 1 + }, "ts/no-explicit-any": { "count": 2 } }, + "web/app/components/base/auto-height-textarea/__tests__/index.spec.tsx": { + "jsx-a11y/no-autofocus": { + "count": 2 + } + }, "web/app/components/base/auto-height-textarea/index.stories.tsx": { "no-console": { "count": 2 @@ -735,6 +1135,9 @@ } }, "web/app/components/base/auto-height-textarea/index.tsx": { + "jsx-a11y/no-autofocus": { + "count": 1 + }, "react-hooks/rules-of-hooks": { "count": 1 }, @@ -756,6 +1159,12 @@ } }, "web/app/components/base/block-input/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "react-refresh/only-export-components": { "count": 1 }, @@ -791,6 +1200,12 @@ } }, "web/app/components/base/chat/chat-with-history/header-in-mobile.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 4 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 4 + }, "ts/no-explicit-any": { "count": 2 } @@ -810,6 +1225,17 @@ "count": 1 } }, + "web/app/components/base/chat/chat-with-history/inputs-form/__tests__/content.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/interactive-supports-focus": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/base/chat/chat-with-history/inputs-form/content.tsx": { "no-restricted-imports": { "count": 1 @@ -818,6 +1244,22 @@ "count": 3 } }, + "web/app/components/base/chat/chat-with-history/sidebar/__tests__/operation.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + } + }, + "web/app/components/base/chat/chat-with-history/sidebar/item.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + } + }, "web/app/components/base/chat/chat-with-history/sidebar/operation.tsx": { "react/set-state-in-effect": { "count": 1 @@ -841,12 +1283,45 @@ "count": 1 } }, + "web/app/components/base/chat/chat/answer/human-input-content/content-wrapper.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/base/chat/chat/answer/suggested-questions.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/base/chat/chat/answer/tool-detail.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/base/chat/chat/answer/workflow-process.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "react/set-state-in-effect": { "count": 1 } }, "web/app/components/base/chat/chat/chat-input-area/index.tsx": { + "jsx-a11y/no-autofocus": { + "count": 1 + }, "ts/no-explicit-any": { "count": 3 } @@ -857,6 +1332,12 @@ } }, "web/app/components/base/chat/chat/citation/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "react-hooks/exhaustive-deps": { "count": 1 }, @@ -883,6 +1364,19 @@ "count": 1 } }, + "web/app/components/base/chat/chat/log/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/base/chat/chat/question.tsx": { + "jsx-a11y/no-autofocus": { + "count": 1 + } + }, "web/app/components/base/chat/chat/type.ts": { "ts/no-explicit-any": { "count": 5 @@ -929,11 +1423,35 @@ "count": 10 } }, + "web/app/components/base/date-and-time-picker/date-picker/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/base/date-and-time-picker/hooks.ts": { "react/no-unnecessary-use-prefix": { "count": 2 } }, + "web/app/components/base/date-and-time-picker/time-picker/__tests__/index.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/base/date-and-time-picker/time-picker/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/base/date-and-time-picker/types.ts": { "erasable-syntax-only/enums": { "count": 2 @@ -970,6 +1488,11 @@ "count": 1 } }, + "web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/config-param-modal.spec.tsx": { + "jsx-a11y/no-redundant-roles": { + "count": 1 + } + }, "web/app/components/base/features/new-feature-panel/annotation-reply/index.tsx": { "ts/no-explicit-any": { "count": 3 @@ -985,6 +1508,25 @@ "count": 2 } }, + "web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-autofocus": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + } + }, + "web/app/components/base/features/new-feature-panel/feature-bar.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/base/features/new-feature-panel/feature-card.tsx": { "ts/no-explicit-any": { "count": 5 @@ -996,6 +1538,12 @@ } }, "web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + }, "ts/no-explicit-any": { "count": 2 } @@ -1013,6 +1561,17 @@ "count": 1 } }, + "web/app/components/base/file-uploader/audio-preview.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/media-has-caption": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/base/file-uploader/dynamic-pdf-preview.tsx": { "ts/no-explicit-any": { "count": 1 @@ -1023,6 +1582,30 @@ "count": 1 } }, + "web/app/components/base/file-uploader/file-uploader-in-attachment/file-item.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/base/file-uploader/file-uploader-in-chat-input/file-image-item.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/base/file-uploader/file-uploader-in-chat-input/file-item.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/base/file-uploader/hooks.ts": { "react/no-unnecessary-use-prefix": { "count": 1 @@ -1038,6 +1621,14 @@ "count": 2 } }, + "web/app/components/base/file-uploader/pdf-preview.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/base/file-uploader/store.tsx": { "react-refresh/only-export-components": { "count": 4 @@ -1056,6 +1647,17 @@ "count": 3 } }, + "web/app/components/base/file-uploader/video-preview.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/media-has-caption": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/base/form/components/base/base-field.tsx": { "no-restricted-imports": { "count": 1 @@ -1072,6 +1674,12 @@ } }, "web/app/components/base/form/components/field/mixed-variable-text-input/placeholder.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + }, "ts/no-explicit-any": { "count": 1 } @@ -1245,7 +1853,7 @@ }, "web/app/components/base/icons/src/vender/line/alertsAndFeedback/index.ts": { "no-barrel-files/no-barrel-files": { - "count": 2 + "count": 1 } }, "web/app/components/base/icons/src/vender/line/arrows/index.ts": { @@ -1280,7 +1888,7 @@ }, "web/app/components/base/icons/src/vender/line/financeAndECommerce/index.ts": { "no-barrel-files/no-barrel-files": { - "count": 4 + "count": 3 } }, "web/app/components/base/icons/src/vender/line/general/index.ts": { @@ -1408,6 +2016,14 @@ "count": 3 } }, + "web/app/components/base/image-gallery/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-noninteractive-element-interactions": { + "count": 1 + } + }, "web/app/components/base/image-uploader/__tests__/image-preview.spec.tsx": { "erasable-syntax-only/parameter-properties": { "count": 1 @@ -1421,6 +2037,17 @@ "count": 3 } }, + "web/app/components/base/image-uploader/audio-preview.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/media-has-caption": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/base/image-uploader/hooks.ts": { "ts/no-explicit-any": { "count": 4 @@ -1431,7 +2058,21 @@ "count": 1 } }, + "web/app/components/base/image-uploader/image-list.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-noninteractive-element-interactions": { + "count": 1 + } + }, "web/app/components/base/image-uploader/image-preview.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "ts/no-explicit-any": { "count": 1 } @@ -1444,6 +2085,17 @@ "count": 2 } }, + "web/app/components/base/image-uploader/video-preview.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/media-has-caption": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/base/inline-delete-confirm/index.stories.tsx": { "no-console": { "count": 2 @@ -1455,6 +2107,9 @@ } }, "web/app/components/base/input/index.stories.tsx": { + "jsx-a11y/label-has-associated-control": { + "count": 9 + }, "no-console": { "count": 2 }, @@ -1472,6 +2127,11 @@ "count": 2 } }, + "web/app/components/base/markdown-blocks/__tests__/paragraph.spec.tsx": { + "jsx-a11y/anchor-is-valid": { + "count": 1 + } + }, "web/app/components/base/markdown-blocks/audio-block.tsx": { "ts/no-explicit-any": { "count": 5 @@ -1498,6 +2158,12 @@ } }, "web/app/components/base/markdown-blocks/link.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-noninteractive-element-interactions": { + "count": 1 + }, "ts/no-explicit-any": { "count": 1 } @@ -1530,6 +2196,11 @@ "count": 5 } }, + "web/app/components/base/markdown/__tests__/streamdown-wrapper.spec.tsx": { + "jsx-a11y/anchor-is-valid": { + "count": 1 + } + }, "web/app/components/base/markdown/error-boundary.tsx": { "ts/no-explicit-any": { "count": 3 @@ -1541,6 +2212,15 @@ } }, "web/app/components/base/mermaid/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 3 + }, + "jsx-a11y/label-has-associated-control": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 3 + }, "react/set-state-in-effect": { "count": 4 }, @@ -1590,12 +2270,39 @@ "count": 1 } }, + "web/app/components/base/notion-page-selector/page-selector/page-row.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + } + }, "web/app/components/base/permission-selector/index.tsx": { "no-restricted-imports": { "count": 1 } }, + "web/app/components/base/permission-selector/member-item.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/base/permission-selector/permission-item.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/base/prompt-editor/index.stories.tsx": { + "jsx-a11y/label-has-associated-control": { + "count": 4 + }, "no-console": { "count": 1 }, @@ -1604,6 +2311,9 @@ } }, "web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx": { + "jsx-a11y/no-autofocus": { + "count": 1 + }, "no-restricted-imports": { "count": 1 } @@ -1613,6 +2323,22 @@ "count": 1 } }, + "web/app/components/base/prompt-editor/plugins/component-picker-block/prompt-option.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/base/prompt-editor/plugins/component-picker-block/variable-option.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/base/prompt-editor/plugins/context-block/index.tsx": { "no-barrel-files/no-barrel-files": { "count": 3 @@ -1621,6 +2347,22 @@ "count": 2 } }, + "web/app/components/base/prompt-editor/plugins/current-block/__tests__/component.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/base/prompt-editor/plugins/current-block/component.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/base/prompt-editor/plugins/current-block/index.tsx": { "no-barrel-files/no-barrel-files": { "count": 3 @@ -1634,6 +2376,22 @@ "count": 2 } }, + "web/app/components/base/prompt-editor/plugins/error-message-block/__tests__/component.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/base/prompt-editor/plugins/error-message-block/component.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/base/prompt-editor/plugins/error-message-block/index.tsx": { "no-barrel-files/no-barrel-files": { "count": 3 @@ -1642,6 +2400,14 @@ "count": 2 } }, + "web/app/components/base/prompt-editor/plugins/history-block/component.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + } + }, "web/app/components/base/prompt-editor/plugins/history-block/index.tsx": { "no-barrel-files/no-barrel-files": { "count": 3 @@ -1658,6 +2424,40 @@ "count": 2 } }, + "web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx": { + "jsx-a11y/no-autofocus": { + "count": 1 + } + }, + "web/app/components/base/prompt-editor/plugins/hitl-input-block/pre-populate.tsx": { + "jsx-a11y/no-autofocus": { + "count": 1 + } + }, + "web/app/components/base/prompt-editor/plugins/hitl-input-block/tag-label.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/base/prompt-editor/plugins/last-run-block/__tests__/component.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/base/prompt-editor/plugins/last-run-block/component.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/base/prompt-editor/plugins/last-run-block/index.tsx": { "no-barrel-files/no-barrel-files": { "count": 3 @@ -1725,7 +2525,23 @@ "count": 1 } }, + "web/app/components/base/qrcode/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/base/search-input/__tests__/index.spec.tsx": { + "jsx-a11y/no-autofocus": { + "count": 1 + } + }, "web/app/components/base/search-input/index.stories.tsx": { + "jsx-a11y/label-has-associated-control": { + "count": 3 + }, "no-console": { "count": 3 } @@ -1735,12 +2551,37 @@ "count": 1 } }, + "web/app/components/base/tab-slider-new/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/base/tab-slider-plain/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/base/tab-slider/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "react/set-state-in-effect": { "count": 2 } }, "web/app/components/base/tag-input/index.stories.tsx": { + "jsx-a11y/label-has-associated-control": { + "count": 12 + }, "no-console": { "count": 2 }, @@ -1766,12 +2607,32 @@ "count": 1 } }, + "web/app/components/base/video-gallery/VideoPlayer.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/media-has-caption": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + } + }, "web/app/components/base/voice-input/__tests__/index.spec.tsx": { "ts/no-explicit-any": { "count": 3 } }, "web/app/components/base/voice-input/index.stories.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/label-has-associated-control": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + }, "no-console": { "count": 2 }, @@ -1779,12 +2640,23 @@ "count": 1 } }, + "web/app/components/base/voice-input/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + } + }, "web/app/components/base/voice-input/utils.ts": { "ts/no-explicit-any": { "count": 4 } }, "web/app/components/base/with-input-validation/index.stories.tsx": { + "jsx-a11y/aria-role": { + "count": 7 + }, "no-console": { "count": 1 } @@ -1804,6 +2676,14 @@ "count": 4 } }, + "web/app/components/billing/header-billing-btn/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/billing/plan/assets/index.tsx": { "no-barrel-files/no-barrel-files": { "count": 4 @@ -1822,6 +2702,14 @@ "count": 1 } }, + "web/app/components/billing/pricing/plan-switcher/tab.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/billing/pricing/plans/self-hosted-plan-item/button.tsx": { "react/static-components": { "count": 2 @@ -1837,11 +2725,37 @@ "count": 4 } }, + "web/app/components/datasets/chunk.tsx": { + "jsx-a11y/label-has-associated-control": { + "count": 2 + } + }, + "web/app/components/datasets/common/credential-icon.tsx": { + "jsx-a11y/alt-text": { + "count": 1 + } + }, "web/app/components/datasets/common/document-status-with-action/status-with-action.tsx": { "react/static-components": { "count": 2 } }, + "web/app/components/datasets/common/image-list/__tests__/index.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/datasets/common/image-list/__tests__/more.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/datasets/common/image-previewer/index.tsx": { "no-irregular-whitespace": { "count": 1 @@ -1857,6 +2771,38 @@ "count": 3 } }, + "web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/image-input.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/image-item.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + } + }, + "web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/image-input.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/image-item.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + } + }, "web/app/components/datasets/common/image-uploader/store.tsx": { "react-refresh/only-export-components": { "count": 3 @@ -1867,6 +2813,14 @@ "count": 1 } }, + "web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/header.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/use-dsl-import.ts": { "erasable-syntax-only/enums": { "count": 1 @@ -1880,6 +2834,22 @@ "count": 1 } }, + "web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/item.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/datasets/create-from-pipeline/list/create-card.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/datasets/create-from-pipeline/list/template-card/details/types.ts": { "erasable-syntax-only/enums": { "count": 1 @@ -1890,6 +2860,14 @@ "count": 1 } }, + "web/app/components/datasets/create-from-pipeline/list/template-card/operations.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 3 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 3 + } + }, "web/app/components/datasets/create/empty-dataset-creation-modal/index.tsx": { "no-restricted-imports": { "count": 1 @@ -1900,6 +2878,22 @@ "count": 1 } }, + "web/app/components/datasets/create/file-uploader/components/file-list-item.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + } + }, + "web/app/components/datasets/create/file-uploader/components/upload-dropzone.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-noninteractive-element-interactions": { + "count": 1 + } + }, "web/app/components/datasets/create/file-uploader/hooks/use-file-upload.ts": { "no-restricted-imports": { "count": 1 @@ -1910,6 +2904,14 @@ "count": 1 } }, + "web/app/components/datasets/create/step-one/components/data-source-type-selector.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/datasets/create/step-one/components/index.ts": { "no-barrel-files/no-barrel-files": { "count": 3 @@ -1920,6 +2922,14 @@ "count": 1 } }, + "web/app/components/datasets/create/step-one/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/datasets/create/step-two/components/index.ts": { "no-barrel-files/no-barrel-files": { "count": 5 @@ -1930,6 +2940,14 @@ "count": 2 } }, + "web/app/components/datasets/create/step-two/components/option-card.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/datasets/create/step-two/hooks/index.ts": { "no-barrel-files/no-barrel-files": { "count": 6 @@ -1959,6 +2977,19 @@ "count": 1 } }, + "web/app/components/datasets/create/website/base/crawled-result-item.tsx": { + "jsx-a11y/label-has-associated-control": { + "count": 1 + } + }, + "web/app/components/datasets/create/website/base/options-wrap.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/datasets/create/website/firecrawl/index.tsx": { "no-console": { "count": 1 @@ -2007,6 +3038,14 @@ "count": 1 } }, + "web/app/components/datasets/documents/components/document-list/components/document-table-row.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + } + }, "web/app/components/datasets/documents/components/document-list/components/index.ts": { "no-barrel-files/no-barrel-files": { "count": 2 @@ -2022,16 +3061,64 @@ "count": 1 } }, + "web/app/components/datasets/documents/components/list.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/datasets/documents/components/operations.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/datasets/documents/components/rename-modal.tsx": { "no-restricted-imports": { "count": 1 } }, + "web/app/components/datasets/documents/create-from-pipeline/data-source-options/option-card.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/index.spec.tsx": { "erasable-syntax-only/enums": { "count": 1 } }, + "web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/item.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/file-list-item.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + } + }, + "web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/upload-dropzone.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-noninteractive-element-interactions": { + "count": 1 + } + }, "web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx": { "no-restricted-imports": { "count": 1 @@ -2045,6 +3132,14 @@ "count": 1 } }, + "web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/item.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 3 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 3 + } + }, "web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.tsx": { "no-restricted-imports": { "count": 1 @@ -2068,7 +3163,18 @@ "count": 4 } }, + "web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/crawled-result-item.tsx": { + "jsx-a11y/label-has-associated-control": { + "count": 2 + } + }, "web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "react/static-components": { "count": 2 }, @@ -2112,6 +3218,14 @@ "count": 1 } }, + "web/app/components/datasets/documents/detail/__tests__/document-title.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/datasets/documents/detail/batch-modal/csv-downloader.tsx": { "react/static-components": { "count": 2 @@ -2123,15 +3237,32 @@ } }, "web/app/components/datasets/documents/detail/completed/child-segment-list.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "no-restricted-imports": { "count": 1 } }, "web/app/components/datasets/documents/detail/completed/common/chunk-content.tsx": { + "jsx-a11y/no-autofocus": { + "count": 2 + }, "react/set-state-in-effect": { "count": 1 } }, + "web/app/components/datasets/documents/detail/completed/components/__tests__/segment-list-content.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/datasets/documents/detail/completed/components/menu-bar.tsx": { "no-restricted-imports": { "count": 1 @@ -2155,6 +3286,14 @@ "count": 1 } }, + "web/app/components/datasets/documents/detail/completed/segment-card/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + } + }, "web/app/components/datasets/documents/detail/completed/segment-list.tsx": { "react/static-components": { "count": 2 @@ -2214,6 +3353,12 @@ } }, "web/app/components/datasets/external-knowledge-base/create/ExternalApiSelect.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 3 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 3 + }, "react/set-state-in-effect": { "count": 1 } @@ -2231,6 +3376,14 @@ "count": 1 } }, + "web/app/components/datasets/formatted-text/flavours/__tests__/edit-slice.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/datasets/formatted-text/flavours/edit-slice.tsx": { "no-restricted-imports": { "count": 2 @@ -2246,6 +3399,38 @@ "count": 1 } }, + "web/app/components/datasets/hit-testing/components/query-input/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/datasets/hit-testing/components/records.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/datasets/hit-testing/components/result-item-external.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/datasets/hit-testing/components/result-item.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + } + }, "web/app/components/datasets/hit-testing/components/score.tsx": { "unicorn/prefer-number-properties": { "count": 1 @@ -2256,8 +3441,56 @@ "count": 1 } }, - "web/app/components/datasets/list/index.tsx": { - "no-restricted-imports": { + "web/app/components/datasets/list/__tests__/header.spec.tsx": { + "jsx-a11y/label-has-associated-control": { + "count": 1 + } + }, + "web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/datasets/list/dataset-card/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/datasets/list/dataset-card/operation-item.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/datasets/metadata/edit-metadata-batch/add-row.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/datasets/metadata/edit-metadata-batch/edit-row.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { "count": 1 } }, @@ -2317,11 +3550,35 @@ "count": 1 } }, + "web/app/components/datasets/settings/option-card.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/datasets/settings/permission-selector/index.tsx": { "no-restricted-imports": { "count": 1 } }, + "web/app/components/datasets/settings/permission-selector/member-item.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/datasets/settings/permission-selector/permission-item.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/develop/code.tsx": { "ts/no-explicit-any": { "count": 7 @@ -2333,6 +3590,9 @@ } }, "web/app/components/develop/md.tsx": { + "jsx-a11y/no-redundant-roles": { + "count": 1 + }, "ts/no-empty-object-type": { "count": 1 }, @@ -2340,16 +3600,19 @@ "count": 2 } }, - "web/app/components/explore/app-list/index.tsx": { - "no-restricted-imports": { + "web/app/components/explore/banner/__tests__/indicator-button.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { "count": 1 } }, "web/app/components/explore/banner/banner-item.tsx": { - "react-hooks-extra/no-direct-set-state-in-use-effect": { + "jsx-a11y/click-events-have-key-events": { "count": 1 }, - "react/set-state-in-effect": { + "jsx-a11y/no-static-element-interactions": { "count": 1 } }, @@ -2361,13 +3624,37 @@ "count": 2 } }, - "web/app/components/goto-anything/actions/commands/command-bus.ts": { - "ts/no-explicit-any": { - "count": 2 + "web/app/components/explore/item-operation/__tests__/index.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 } }, - "web/app/components/goto-anything/actions/commands/index.ts": { - "no-barrel-files/no-barrel-files": { + "web/app/components/explore/learn-dify/item.tsx": { + "jsx-a11y/no-noninteractive-element-interactions": { + "count": 1 + } + }, + "web/app/components/explore/sidebar/app-nav-item/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/explore/try-app/app/text-generation.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/goto-anything/actions/commands/command-bus.ts": { + "ts/no-explicit-any": { "count": 2 } }, @@ -2376,24 +3663,11 @@ "count": 3 } }, - "web/app/components/goto-anything/actions/commands/slash.tsx": { - "react-refresh/only-export-components": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/components/goto-anything/actions/commands/types.ts": { "ts/no-explicit-any": { "count": 1 } }, - "web/app/components/goto-anything/actions/index.ts": { - "no-barrel-files/no-barrel-files": { - "count": 2 - } - }, "web/app/components/goto-anything/actions/plugin.tsx": { "no-restricted-imports": { "count": 1 @@ -2409,9 +3683,9 @@ "count": 2 } }, - "web/app/components/goto-anything/command-selector.tsx": { - "react/unsupported-syntax": { - "count": 2 + "web/app/components/goto-anything/components/__tests__/search-input.spec.tsx": { + "jsx-a11y/no-autofocus": { + "count": 1 } }, "web/app/components/goto-anything/components/footer.tsx": { @@ -2425,6 +3699,9 @@ } }, "web/app/components/goto-anything/components/search-input.tsx": { + "jsx-a11y/no-autofocus": { + "count": 1 + }, "no-restricted-imports": { "count": 1 } @@ -2437,18 +3714,24 @@ "count": 4 } }, - "web/app/components/goto-anything/hooks/index.ts": { - "no-barrel-files/no-barrel-files": { - "count": 4 - } - }, "web/app/components/goto-anything/hooks/use-goto-anything-results.ts": { "@tanstack/query/exhaustive-deps": { "count": 1 } }, + "web/app/components/header/account-setting/data-source-page-new/__tests__/item.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/header/account-setting/data-source-page-new/card.tsx": { - "ts/no-explicit-any": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { "count": 2 } }, @@ -2462,16 +3745,11 @@ "count": 1 } }, - "web/app/components/header/account-setting/data-source-page-new/install-from-marketplace.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, - "web/app/components/header/account-setting/data-source-page-new/item.tsx": { - "no-restricted-imports": { + "web/app/components/header/account-setting/data-source-page-new/plugin-actions.tsx": { + "jsx-a11y/click-events-have-key-events": { "count": 1 }, - "ts/no-explicit-any": { + "jsx-a11y/no-static-element-interactions": { "count": 1 } }, @@ -2480,16 +3758,37 @@ "count": 2 } }, + "web/app/components/header/account-setting/key-validator/Operate.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 4 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 4 + } + }, "web/app/components/header/account-setting/key-validator/declarations.ts": { "ts/no-explicit-any": { "count": 1 } }, + "web/app/components/header/account-setting/language-page/__tests__/index.spec.tsx": { + "jsx-a11y/role-has-required-aria-props": { + "count": 1 + } + }, "web/app/components/header/account-setting/members-page/edit-workspace-modal/index.tsx": { + "jsx-a11y/no-autofocus": { + "count": 1 + }, "no-restricted-imports": { "count": 1 } }, + "web/app/components/header/account-setting/members-page/invite-modal/index.tsx": { + "jsx-a11y/no-autofocus": { + "count": 1 + } + }, "web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx": { "erasable-syntax-only/enums": { "count": 1 @@ -2502,6 +3801,12 @@ } }, "web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "no-restricted-imports": { "count": 1 } @@ -2522,16 +3827,62 @@ "count": 2 } }, + "web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/switch-credential-in-load-balancing.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/header/account-setting/model-provider-page/model-auth/add-credential-in-load-balancing.tsx": { "ts/no-explicit-any": { "count": 4 } }, + "web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + } + }, + "web/app/components/header/account-setting/model-provider-page/model-auth/authorized/credential-item.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/header/account-setting/model-provider-page/model-auth/authorized/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "no-restricted-imports": { "count": 1 } }, + "web/app/components/header/account-setting/model-provider-page/model-auth/config-model.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/header/account-setting/model-provider-page/model-auth/credential-selector.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/header/account-setting/model-provider-page/model-auth/hooks/index.ts": { "no-barrel-files/no-barrel-files": { "count": 6 @@ -2577,16 +3928,35 @@ "count": 2 } }, + "web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/header/account-setting/model-provider-page/model-parameter-modal/model-display.tsx": { "ts/no-explicit-any": { "count": 1 } }, "web/app/components/header/account-setting/model-provider-page/model-parameter-modal/status-indicators.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "ts/no-explicit-any": { "count": 2 } }, + "web/app/components/header/account-setting/model-provider-page/model-provider-page-body.tsx": { + "jsx-a11y/anchor-has-content": { + "count": 1 + } + }, "web/app/components/header/account-setting/model-provider-page/provider-added-card/cooldown-timer.tsx": { "react/set-state-in-effect": { "count": 1 @@ -2597,12 +3967,32 @@ "count": 2 } }, + "web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-configs.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "ts/no-explicit-any": { "count": 5 } }, "web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "react/set-state-in-effect": { "count": 1 }, @@ -2610,6 +4000,14 @@ "count": 3 } }, + "web/app/components/header/account-setting/model-provider-page/provider-added-card/quota-panel.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/header/account-setting/model-provider-page/utils.ts": { "no-barrel-files/no-barrel-files": { "count": 2 @@ -2620,8 +4018,21 @@ "count": 4 } }, - "web/app/components/plugins/card/index.tsx": { - "ts/no-non-null-asserted-optional-chain": { + "web/app/components/header/nav/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/main-nav/components/web-apps-section.tsx": { + "jsx-a11y/no-autofocus": { + "count": 1 + } + }, + "web/app/components/main-nav/components/workspace-switcher.tsx": { + "jsx-a11y/no-autofocus": { "count": 1 } }, @@ -2648,12 +4059,23 @@ "count": 1 } }, + "web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/install-multi.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 3 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 3 + } + }, "web/app/components/plugins/install-plugin/install-bundle/steps/hooks/use-install-multi-state.ts": { "react-hooks/exhaustive-deps": { "count": 1 } }, "web/app/components/plugins/install-plugin/install-from-github/index.tsx": { + "jsx-a11y/no-autofocus": { + "count": 1 + }, "ts/no-explicit-any": { "count": 2 } @@ -2671,6 +4093,38 @@ "count": 1 } }, + "web/app/components/plugins/marketplace/list/list-with-collection.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/plugins/marketplace/plugin-type-switch.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/plugins/marketplace/search-box/__tests__/index.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-autofocus": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + } + }, + "web/app/components/plugins/marketplace/search-box/index.tsx": { + "jsx-a11y/no-autofocus": { + "count": 1 + } + }, "web/app/components/plugins/marketplace/search-box/tags-filter.tsx": { "no-restricted-imports": { "count": 1 @@ -2682,6 +4136,12 @@ } }, "web/app/components/plugins/plugin-auth/authorized/item.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "no-restricted-imports": { "count": 1 } @@ -2712,6 +4172,14 @@ "count": 2 } }, + "web/app/components/plugins/plugin-detail-panel/__tests__/operation-dropdown.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/plugins/plugin-detail-panel/agent-strategy-list.tsx": { "ts/no-explicit-any": { "count": 1 @@ -2760,6 +4228,14 @@ "count": 1 } }, + "web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/index.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 3 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 3 + } + }, "web/app/components/plugins/plugin-detail-panel/model-selector/index.tsx": { "ts/no-explicit-any": { "count": 3 @@ -2770,21 +4246,72 @@ "count": 1 } }, + "web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/plugins/plugin-detail-panel/operation-dropdown.tsx": { + "jsx-a11y/anchor-has-content": { + "count": 1 + } + }, "web/app/components/plugins/plugin-detail-panel/strategy-detail.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "ts/no-explicit-any": { "count": 2 } }, + "web/app/components/plugins/plugin-detail-panel/strategy-item.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/index.spec.tsx": { "ts/no-explicit-any": { "count": 2 } }, + "web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-entry.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/index.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + } + }, "web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.ts": { "erasable-syntax-only/enums": { "count": 1 } }, + "web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/plugins/plugin-detail-panel/subscription-list/create/types.ts": { "erasable-syntax-only/enums": { "count": 1 @@ -2813,12 +4340,26 @@ "count": 1 } }, + "web/app/components/plugins/plugin-detail-panel/tool-selector/__tests__/index.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 4 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 4 + } + }, "web/app/components/plugins/plugin-detail-panel/tool-selector/components/index.ts": { "no-barrel-files/no-barrel-files": { "count": 7 } }, "web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "no-restricted-imports": { "count": 1 } @@ -2828,6 +4369,17 @@ "count": 1 } }, + "web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-item.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 3 + }, + "jsx-a11y/mouse-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 3 + } + }, "web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/index.ts": { "no-barrel-files/no-barrel-files": { "count": 2 @@ -2839,11 +4391,31 @@ } }, "web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "ts/no-explicit-any": { "count": 5 } }, + "web/app/components/plugins/plugin-detail-panel/trigger/event-list.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/plugins/plugin-item/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + }, "ts/no-explicit-any": { "count": 1 } @@ -2853,9 +4425,12 @@ "count": 1 } }, - "web/app/components/plugins/plugin-page/empty/index.tsx": { - "react/set-state-in-effect": { - "count": 2 + "web/app/components/plugins/plugin-page/filter-management/__tests__/index.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 } }, "web/app/components/plugins/plugin-page/filter-management/category-filter.tsx": { @@ -2868,8 +4443,27 @@ "count": 1 } }, - "web/app/components/plugins/plugin-page/filter-management/tag-filter.tsx": { - "no-restricted-imports": { + "web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/index.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-picker.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { "count": 1 } }, @@ -2886,8 +4480,19 @@ "count": 25 } }, - "web/app/components/plugins/update-plugin/from-market-place.tsx": { - "erasable-syntax-only/enums": { + "web/app/components/plugins/update-plugin/plugin-version-picker.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/rag-pipeline/components/__tests__/publish-as-knowledge-pipeline-modal.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { "count": 1 } }, @@ -2937,6 +4542,14 @@ "count": 1 } }, + "web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/index.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + } + }, "web/app/components/rag-pipeline/components/panel/input-field/hooks.ts": { "react/set-state-in-effect": { "count": 1 @@ -2947,6 +4560,14 @@ "count": 2 } }, + "web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/option-card.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/index.tsx": { "ts/no-explicit-any": { "count": 1 @@ -2985,6 +4606,14 @@ "count": 1 } }, + "web/app/components/rag-pipeline/components/publish-toast.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/rag-pipeline/components/rag-pipeline-children.tsx": { "ts/no-explicit-any": { "count": 1 @@ -3115,6 +4744,14 @@ "count": 3 } }, + "web/app/components/share/text-generation/text-generation-result-panel.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/share/text-generation/types.ts": { "erasable-syntax-only/enums": { "count": 1 @@ -3130,6 +4767,30 @@ "count": 4 } }, + "web/app/components/snippet-list/components/snippet-card.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/snippets/components/snippet-run-panel.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 5 + }, + "jsx-a11y/no-autofocus": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 6 + } + }, + "web/app/components/snippets/create-snippet-dialog.tsx": { + "jsx-a11y/no-autofocus": { + "count": 1 + } + }, "web/app/components/snippets/hooks/use-nodes-sync-draft.ts": { "no-restricted-imports": { "count": 1 @@ -3146,6 +4807,12 @@ } }, "web/app/components/tools/edit-custom-collection-modal/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "react/set-state-in-effect": { "count": 4 }, @@ -3154,6 +4821,12 @@ } }, "web/app/components/tools/edit-custom-collection-modal/test-api.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "no-restricted-imports": { "count": 1 }, @@ -3161,6 +4834,14 @@ "count": 1 } }, + "web/app/components/tools/labels/__tests__/selector.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/tools/labels/filter.tsx": { "no-restricted-imports": { "count": 1 @@ -3171,14 +4852,12 @@ "count": 1 } }, - "web/app/components/tools/mcp/create-card.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, - "web/app/components/tools/mcp/headers-input.tsx": { - "no-restricted-imports": { - "count": 1 + "web/app/components/tools/mcp/__tests__/index.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 } }, "web/app/components/tools/mcp/mcp-server-param-item.tsx": { @@ -3186,36 +4865,27 @@ "count": 1 } }, - "web/app/components/tools/mcp/modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/tools/mcp/provider-card.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + }, "ts/no-explicit-any": { "count": 3 } }, - "web/app/components/tools/mcp/sections/authentication-section.tsx": { - "no-restricted-imports": { + "web/app/components/tools/provider/detail.tsx": { + "jsx-a11y/anchor-has-content": { "count": 1 } }, - "web/app/components/tools/mcp/sections/configurations-section.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/tools/provider-list.tsx": { - "no-restricted-imports": { + "web/app/components/tools/provider/tool-item.tsx": { + "jsx-a11y/click-events-have-key-events": { "count": 1 }, - "ts/no-explicit-any": { - "count": 1 - } - }, - "web/app/components/tools/provider/empty.tsx": { - "ts/no-explicit-any": { + "jsx-a11y/no-static-element-interactions": { "count": 1 } }, @@ -3224,6 +4894,14 @@ "count": 3 } }, + "web/app/components/tools/tool-provider-grid.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/tools/types.ts": { "erasable-syntax-only/enums": { "count": 4 @@ -3232,6 +4910,22 @@ "count": 4 } }, + "web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/tools/workflow-tool/configure-button.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/tools/workflow-tool/index.tsx": { "no-restricted-imports": { "count": 1 @@ -3247,6 +4941,14 @@ "count": 3 } }, + "web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-option.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/workflow-app/hooks/index.ts": { "no-barrel-files/no-barrel-files": { "count": 13 @@ -3310,6 +5012,38 @@ "count": 1 } }, + "web/app/components/workflow/block-selector/all-tools.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/workflow/block-selector/blocks.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/workflow/block-selector/featured-tools.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/workflow/block-selector/featured-triggers.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/workflow/block-selector/hooks.ts": { "react/set-state-in-effect": { "count": 1 @@ -3321,6 +5055,15 @@ } }, "web/app/components/workflow/block-selector/main.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-autofocus": { + "count": 4 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "no-restricted-imports": { "count": 2 } @@ -3330,11 +5073,64 @@ "count": 1 } }, + "web/app/components/workflow/block-selector/market-place-plugin/list.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/workflow/block-selector/rag-tool-recommendations/uninstalled-item.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/workflow/block-selector/snippets/index.tsx": { + "jsx-a11y/no-autofocus": { + "count": 1 + } + }, + "web/app/components/workflow/block-selector/tabs.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + } + }, "web/app/components/workflow/block-selector/tool-picker.tsx": { "no-restricted-imports": { "count": 1 } }, + "web/app/components/workflow/block-selector/tool/action-item.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/workflow/block-selector/tool/tool.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + } + }, "web/app/components/workflow/block-selector/types.ts": { "erasable-syntax-only/enums": { "count": 4 @@ -3354,6 +5150,12 @@ "erasable-syntax-only/enums": { "count": 1 }, + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + }, "react-refresh/only-export-components": { "count": 1 } @@ -3363,6 +5165,40 @@ "count": 2 } }, + "web/app/components/workflow/comment/comment-input.tsx": { + "jsx-a11y/no-autofocus": { + "count": 1 + } + }, + "web/app/components/workflow/comment/comment-preview.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/workflow/comment/mention-input.spec.tsx": { + "jsx-a11y/no-autofocus": { + "count": 1 + } + }, + "web/app/components/workflow/comment/mention-input.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 3 + }, + "jsx-a11y/no-autofocus": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/workflow/comment/thread.tsx": { + "jsx-a11y/no-autofocus": { + "count": 2 + } + }, "web/app/components/workflow/context.tsx": { "react-refresh/only-export-components": { "count": 1 @@ -3373,9 +5209,28 @@ "count": 1 } }, - "web/app/components/workflow/header/__tests__/index.spec.tsx": { - "react/static-components": { + "web/app/components/workflow/header/online-users.tsx": { + "jsx-a11y/click-events-have-key-events": { "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + } + }, + "web/app/components/workflow/header/run-and-history.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/workflow/header/scroll-to-selected-node-button.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 } }, "web/app/components/workflow/header/test-run-menu.tsx": { @@ -3386,6 +5241,22 @@ "count": 1 } }, + "web/app/components/workflow/header/view-history.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/workflow/header/view-workflow-history.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 3 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 3 + } + }, "web/app/components/workflow/hooks-store/index.ts": { "no-barrel-files/no-barrel-files": { "count": 2 @@ -3447,16 +5318,6 @@ "count": 1 } }, - "web/app/components/workflow/hooks/use-workflow-canvas-maximize.ts": { - "no-restricted-globals": { - "count": 1 - } - }, - "web/app/components/workflow/hooks/use-workflow-interactions.ts": { - "no-barrel-files/no-barrel-files": { - "count": 5 - } - }, "web/app/components/workflow/hooks/use-workflow-run-event/index.ts": { "no-barrel-files/no-barrel-files": { "count": 19 @@ -3482,6 +5343,27 @@ "count": 1 } }, + "web/app/components/workflow/index.tsx": { + "jsx-a11y/no-autofocus": { + "count": 1 + } + }, + "web/app/components/workflow/nodes/_base/components/__tests__/agent-strategy-selector.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/workflow/nodes/_base/components/__tests__/node-handle.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/workflow/nodes/_base/components/add-variable-popup-with-position.tsx": { "ts/no-explicit-any": { "count": 2 @@ -3496,6 +5378,9 @@ } }, "web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx": { + "jsx-a11y/no-autofocus": { + "count": 3 + }, "no-restricted-imports": { "count": 1 }, @@ -3516,6 +5401,22 @@ "count": 5 } }, + "web/app/components/workflow/nodes/_base/components/before-run-form/panel-wrap.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/workflow/nodes/_base/components/editor/base.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/workflow/nodes/_base/components/editor/code-editor/editor-support-vars.tsx": { "ts/no-explicit-any": { "count": 6 @@ -3553,6 +5454,30 @@ "count": 1 } }, + "web/app/components/workflow/nodes/_base/components/field.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/workflow/nodes/_base/components/file-type-item.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + } + }, + "web/app/components/workflow/nodes/_base/components/form-input-boolean.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + } + }, "web/app/components/workflow/nodes/_base/components/form-input-item.tsx": { "no-restricted-imports": { "count": 1 @@ -3571,6 +5496,14 @@ "count": 1 } }, + "web/app/components/workflow/nodes/_base/components/layout/field-title.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/workflow/nodes/_base/components/layout/index.tsx": { "no-barrel-files/no-barrel-files": { "count": 7 @@ -3592,16 +5525,54 @@ "count": 1 } }, + "web/app/components/workflow/nodes/_base/components/mixed-variable-text-input/__tests__/placeholder.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/workflow/nodes/_base/components/mixed-variable-text-input/index.tsx": { "ts/no-explicit-any": { "count": 1 } }, "web/app/components/workflow/nodes/_base/components/mixed-variable-text-input/placeholder.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + }, "ts/no-explicit-any": { "count": 1 } }, + "web/app/components/workflow/nodes/_base/components/next-step/operator.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + } + }, + "web/app/components/workflow/nodes/_base/components/node-control.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/workflow/nodes/_base/components/option-card.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/workflow/nodes/_base/components/prompt/editor.tsx": { "ts/no-explicit-any": { "count": 4 @@ -3618,6 +5589,12 @@ } }, "web/app/components/workflow/nodes/_base/components/selector.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 3 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 3 + }, "ts/no-explicit-any": { "count": 2 } @@ -3627,11 +5604,40 @@ "count": 1 } }, + "web/app/components/workflow/nodes/_base/components/support-var-input/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/workflow/nodes/_base/components/switch-plugin-version.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/workflow/nodes/_base/components/variable/manage-input-field.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + } + }, "web/app/components/workflow/nodes/_base/components/variable/match-schema-type.ts": { "ts/no-explicit-any": { "count": 8 } }, + "web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker/field.tsx": { + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/workflow/nodes/_base/components/variable/output-var-list.tsx": { "no-restricted-imports": { "count": 1 @@ -3653,7 +5659,24 @@ "count": 1 } }, + "web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.trigger.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 3 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 3 + } + }, "web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-autofocus": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + }, "no-restricted-imports": { "count": 1 } @@ -3663,6 +5686,14 @@ "count": 2 } }, + "web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-label.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/workflow/nodes/_base/components/variable/variable-label/hooks.ts": { "react/no-unnecessary-use-prefix": { "count": 2 @@ -3730,6 +5761,12 @@ } }, "web/app/components/workflow/nodes/_base/node.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "ts/no-explicit-any": { "count": 1 } @@ -3822,6 +5859,15 @@ } }, "web/app/components/workflow/nodes/code/dependency-picker.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-autofocus": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "no-restricted-imports": { "count": 1 } @@ -3918,12 +5964,34 @@ "count": 1 } }, + "web/app/components/workflow/nodes/http/components/authorization/radio-group.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/workflow/nodes/http/components/key-value/bulk-edit/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/workflow/nodes/http/components/key-value/key-value-edit/index.tsx": { "ts/no-explicit-any": { "count": 2 } }, "web/app/components/workflow/nodes/http/components/key-value/key-value-edit/item.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "ts/no-explicit-any": { "count": 1 } @@ -3938,6 +6006,14 @@ "count": 1 } }, + "web/app/components/workflow/nodes/http/panel.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + } + }, "web/app/components/workflow/nodes/http/types.ts": { "erasable-syntax-only/enums": { "count": 5 @@ -3956,17 +6032,71 @@ "count": 5 } }, + "web/app/components/workflow/nodes/human-input/components/button-style-dropdown.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 4 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 4 + } + }, "web/app/components/workflow/nodes/human-input/components/delivery-method/email-configure-modal.tsx": { "no-restricted-imports": { "count": 1 } }, + "web/app/components/workflow/nodes/human-input/components/delivery-method/method-selector.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + } + }, + "web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/email-input.spec.tsx": { + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-input.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-item.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-list.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "no-restricted-imports": { "count": 1 } }, + "web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx": { + "jsx-a11y/no-autofocus": { + "count": 1 + } + }, "web/app/components/workflow/nodes/human-input/components/timeout.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + }, "no-restricted-imports": { "count": 1 } @@ -3986,6 +6116,22 @@ "count": 1 } }, + "web/app/components/workflow/nodes/if-else/components/condition-list/condition-item.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/workflow/nodes/if-else/components/condition-list/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/workflow/nodes/if-else/default.ts": { "ts/no-explicit-any": { "count": 1 @@ -4031,6 +6177,14 @@ "count": 1 } }, + "web/app/components/workflow/nodes/knowledge-base/components/option-card.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/hooks.tsx": { "ts/no-explicit-any": { "count": 4 @@ -4060,11 +6214,31 @@ } }, "web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/add-condition.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "no-restricted-imports": { "count": 1 } }, + "web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-common-variable-selector.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-item.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "ts/no-explicit-any": { "count": 1 } @@ -4074,11 +6248,43 @@ "count": 1 } }, + "web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-operator.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-string.tsx": { "no-restricted-imports": { "count": 1 } }, + "web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-value-method.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-panel.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/workflow/nodes/knowledge-retrieval/default.ts": { "ts/no-explicit-any": { "count": 1 @@ -4201,6 +6407,22 @@ "count": 1 } }, + "web/app/components/workflow/nodes/loop/components/condition-list/condition-item.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/workflow/nodes/loop/components/condition-list/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/workflow/nodes/loop/components/loop-variables/form-item.tsx": { "no-restricted-imports": { "count": 1 @@ -4210,6 +6432,9 @@ } }, "web/app/components/workflow/nodes/loop/components/loop-variables/item.tsx": { + "jsx-a11y/no-autofocus": { + "count": 1 + }, "no-restricted-imports": { "count": 1 }, @@ -4222,6 +6447,14 @@ "count": 1 } }, + "web/app/components/workflow/nodes/loop/panel.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/workflow/nodes/loop/types.ts": { "erasable-syntax-only/enums": { "count": 2 @@ -4250,6 +6483,14 @@ "count": 1 } }, + "web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/item.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + } + }, "web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/update.tsx": { "no-restricted-imports": { "count": 1 @@ -4281,6 +6522,11 @@ "count": 9 } }, + "web/app/components/workflow/nodes/question-classifier/components/class-item.tsx": { + "jsx-a11y/no-autofocus": { + "count": 1 + } + }, "web/app/components/workflow/nodes/question-classifier/use-single-run-form-params.ts": { "ts/no-explicit-any": { "count": 8 @@ -4316,6 +6562,22 @@ "count": 5 } }, + "web/app/components/workflow/nodes/tool/components/__tests__/copy-id.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/workflow/nodes/tool/components/copy-id.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/workflow/nodes/tool/components/input-var-list.tsx": { "ts/no-explicit-any": { "count": 7 @@ -4327,6 +6589,12 @@ } }, "web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + }, "ts/no-explicit-any": { "count": 1 } @@ -4451,6 +6719,15 @@ } }, "web/app/components/workflow/nodes/variable-assigner/components/var-group-item.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-autofocus": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + }, "ts/no-explicit-any": { "count": 1 } @@ -4471,6 +6748,15 @@ } }, "web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/component.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-autofocus": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + }, "no-restricted-imports": { "count": 1 }, @@ -4478,6 +6764,43 @@ "count": 1 } }, + "web/app/components/workflow/note-node/note-editor/toolbar/__tests__/operator.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + } + }, + "web/app/components/workflow/note-node/note-editor/toolbar/color-picker.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/workflow/note-node/note-editor/toolbar/font-size-selector.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/workflow/note-node/note-editor/toolbar/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/workflow/note-node/note-editor/toolbar/operator.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/workflow/note-node/note-editor/utils.ts": { "regexp/no-useless-quantifier": { "count": 1 @@ -4502,11 +6825,23 @@ } }, "web/app/components/workflow/panel/chat-record/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "ts/no-explicit-any": { "count": 8 } }, "web/app/components/workflow/panel/chat-record/user-input.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "ts/no-explicit-any": { "count": 1 } @@ -4540,16 +6875,45 @@ "count": 2 } }, + "web/app/components/workflow/panel/chat-variable-panel/components/variable-item.tsx": { + "jsx-a11y/mouse-events-have-key-events": { + "count": 2 + } + }, "web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.sections.tsx": { "no-restricted-imports": { "count": 1 } }, + "web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/workflow/panel/chat-variable-panel/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/workflow/panel/chat-variable-panel/type.ts": { "erasable-syntax-only/enums": { "count": 1 } }, + "web/app/components/workflow/panel/comments-panel/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 3 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 3 + } + }, "web/app/components/workflow/panel/debug-and-preview/__tests__/hooks.spec.ts": { "no-restricted-imports": { "count": 1 @@ -4573,7 +6937,39 @@ "count": 11 } }, + "web/app/components/workflow/panel/debug-and-preview/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/workflow/panel/debug-and-preview/user-input.tsx": { + "jsx-a11y/no-autofocus": { + "count": 1 + } + }, + "web/app/components/workflow/panel/env-panel/env-item.tsx": { + "jsx-a11y/mouse-events-have-key-events": { + "count": 2 + } + }, + "web/app/components/workflow/panel/env-panel/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/workflow/panel/env-panel/variable-modal.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 4 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 4 + }, "no-restricted-imports": { "count": 1 }, @@ -4584,21 +6980,102 @@ "count": 1 } }, + "web/app/components/workflow/panel/global-variable-panel/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/workflow/panel/human-input-form-list.tsx": { "ts/no-explicit-any": { "count": 1 } }, "web/app/components/workflow/panel/inputs-panel.tsx": { + "jsx-a11y/no-autofocus": { + "count": 1 + }, "ts/no-explicit-any": { "count": 4 } }, + "web/app/components/workflow/panel/version-history-panel/filter/filter-item.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/workflow/panel/version-history-panel/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + } + }, + "web/app/components/workflow/panel/version-history-panel/version-history-item.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/workflow/panel/workflow-preview.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 4 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 5 + }, "ts/no-explicit-any": { "count": 1 } }, + "web/app/components/workflow/run/__tests__/loop-result-panel.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/workflow/run/__tests__/special-result-panel.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/workflow/run/__tests__/tracing-panel.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/workflow/run/agent-log/agent-log-item.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/workflow/run/agent-log/agent-log-trigger.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/workflow/run/agent-log/index.tsx": { "no-barrel-files/no-barrel-files": { "count": 2 @@ -4610,6 +7087,12 @@ } }, "web/app/components/workflow/run/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 3 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 3 + }, "react/set-state-in-effect": { "count": 1 } @@ -4624,7 +7107,21 @@ "count": 1 } }, + "web/app/components/workflow/run/iteration-log/iteration-result-panel.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + } + }, "web/app/components/workflow/run/loop-log/__tests__/loop-log-trigger.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "ts/no-explicit-any": { "count": 3 } @@ -4639,12 +7136,37 @@ "count": 1 } }, + "web/app/components/workflow/run/loop-log/loop-result-panel.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + } + }, + "web/app/components/workflow/run/loop-result-panel.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/workflow/run/node.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "react/set-state-in-effect": { "count": 1 } }, "web/app/components/workflow/run/output-panel.tsx": { + "jsx-a11y/no-noninteractive-tabindex": { + "count": 1 + }, "ts/no-explicit-any": { "count": 3 } @@ -4659,11 +7181,48 @@ "count": 2 } }, + "web/app/components/workflow/run/retry-log/__tests__/retry-log-trigger.spec.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/workflow/run/retry-log/index.tsx": { "no-barrel-files/no-barrel-files": { "count": 2 } }, + "web/app/components/workflow/run/retry-log/retry-result-panel.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/workflow/run/special-result-panel.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/app/components/workflow/run/status.tsx": { + "jsx-a11y/anchor-has-content": { + "count": 1 + } + }, + "web/app/components/workflow/run/tracing-panel.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/workflow/run/utils/format-log/agent/index.ts": { "ts/no-explicit-any": { "count": 11 @@ -4707,11 +7266,6 @@ "count": 2 } }, - "web/app/components/workflow/store/workflow/layout-slice.ts": { - "no-restricted-properties": { - "count": 1 - } - }, "web/app/components/workflow/store/workflow/workflow-draft-slice.ts": { "ts/no-explicit-any": { "count": 1 @@ -4799,16 +7353,34 @@ } }, "web/app/components/workflow/variable-inspect/panel.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "ts/no-explicit-any": { "count": 1 } }, "web/app/components/workflow/variable-inspect/right.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "ts/no-explicit-any": { "count": 3 } }, "web/app/components/workflow/variable-inspect/trigger.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 5 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 5 + }, "ts/no-explicit-any": { "count": 1 } @@ -4837,7 +7409,31 @@ "count": 1 } }, + "web/app/device/components/code-input.tsx": { + "jsx-a11y/no-autofocus": { + "count": 1 + } + }, + "web/app/device/page.tsx": { + "jsx-a11y/no-autofocus": { + "count": 1 + } + }, + "web/app/education-apply/role-selector.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/education-apply/search-input.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "no-restricted-imports": { "count": 1 } @@ -4878,6 +7474,12 @@ } }, "web/app/reset-password/check-code/page.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "no-restricted-imports": { "count": 1 } @@ -4901,6 +7503,12 @@ } }, "web/app/signin/check-code/page.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "no-restricted-imports": { "count": 1 } @@ -4915,17 +7523,34 @@ "count": 1 } }, + "web/app/signin/normal-form.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 2 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 2 + } + }, "web/app/signin/one-more-step.tsx": { "no-restricted-imports": { "count": 1 } }, "web/app/signup/check-code/page.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + }, "no-restricted-imports": { "count": 1 } }, "web/app/signup/components/input-mail.tsx": { + "jsx-a11y/tabindex-no-positive": { + "count": 2 + }, "no-restricted-imports": { "count": 1 } @@ -4958,13 +7583,26 @@ "count": 3 } }, - "web/context/provider-context-provider.tsx": { - "ts/no-explicit-any": { + "web/context/web-app-context.tsx": { + "react-refresh/only-export-components": { "count": 1 } }, - "web/context/web-app-context.tsx": { - "react-refresh/only-export-components": { + "web/features/tag-management/components/dataset-card-tags.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, + "web/features/tag-management/components/tag-item-editor.tsx": { + "jsx-a11y/no-autofocus": { + "count": 1 + } + }, + "web/features/tag-management/components/tag-management-modal.tsx": { + "jsx-a11y/no-autofocus": { "count": 1 } }, @@ -5011,46 +7649,6 @@ "count": 1 } }, - "web/i18n/de-DE/billing.json": { - "no-irregular-whitespace": { - "count": 1 - } - }, - "web/i18n/en-US/app-debug.json": { - "no-irregular-whitespace": { - "count": 1 - } - }, - "web/i18n/fr-FR/app-debug.json": { - "no-irregular-whitespace": { - "count": 1 - } - }, - "web/i18n/fr-FR/plugin-trigger.json": { - "no-irregular-whitespace": { - "count": 1 - } - }, - "web/i18n/fr-FR/tools.json": { - "no-irregular-whitespace": { - "count": 1 - } - }, - "web/i18n/pt-BR/common.json": { - "no-irregular-whitespace": { - "count": 1 - } - }, - "web/i18n/ru-RU/common.json": { - "no-irregular-whitespace": { - "count": 2 - } - }, - "web/i18n/uk-UA/app-debug.json": { - "no-irregular-whitespace": { - "count": 1 - } - }, "web/models/access-control.ts": { "erasable-syntax-only/enums": { "count": 2 @@ -5061,14 +7659,6 @@ "count": 2 } }, - "web/models/common.ts": { - "erasable-syntax-only/enums": { - "count": 2 - }, - "ts/no-explicit-any": { - "count": 3 - } - }, "web/models/datasets.ts": { "erasable-syntax-only/enums": { "count": 7 @@ -5183,7 +7773,7 @@ "count": 1 }, "ts/no-explicit-any": { - "count": 29 + "count": 27 } }, "web/service/datasets.ts": { @@ -5379,20 +7969,6 @@ "count": 4 } }, - "web/service/use-plugins.ts": { - "no-restricted-imports": { - "count": 1 - }, - "react/set-state-in-effect": { - "count": 1 - }, - "regexp/no-unused-capturing-group": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 3 - } - }, "web/service/use-snippet-workflows.ts": { "no-restricted-imports": { "count": 1 diff --git a/package.json b/package.json index 35907075057..b9cb1274a2b 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "dify", "type": "module", "private": true, - "packageManager": "pnpm@11.5.2", + "packageManager": "pnpm@11.6.0", "devEngines": { "runtime": { "name": "node", diff --git a/packages/contracts/generated/api/console/account/orpc.gen.ts b/packages/contracts/generated/api/console/account/orpc.gen.ts index e963f9cd3c2..a9261036675 100644 --- a/packages/contracts/generated/api/console/account/orpc.gen.ts +++ b/packages/contracts/generated/api/console/account/orpc.gen.ts @@ -222,16 +222,8 @@ export const get5 = oc }) .output(zGetAccountEducationResponse) -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post8 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAccountEducation', diff --git a/packages/contracts/generated/api/console/account/types.gen.ts b/packages/contracts/generated/api/console/account/types.gen.ts index 059f86ca350..cdd45925fb2 100644 --- a/packages/contracts/generated/api/console/account/types.gen.ts +++ b/packages/contracts/generated/api/console/account/types.gen.ts @@ -87,6 +87,10 @@ export type EducationActivatePayload = { token: string } +export type EducationActivateResponse = { + [key: string]: unknown +} + export type EducationAutocompleteResponse = { curr_page?: number | null data?: Array @@ -297,9 +301,7 @@ export type PostAccountEducationData = { } export type PostAccountEducationResponses = { - 200: { - [key: string]: unknown - } + 200: EducationActivateResponse } export type PostAccountEducationResponse diff --git a/packages/contracts/generated/api/console/account/zod.gen.ts b/packages/contracts/generated/api/console/account/zod.gen.ts index d1ce84faf5a..9951efc8d9f 100644 --- a/packages/contracts/generated/api/console/account/zod.gen.ts +++ b/packages/contracts/generated/api/console/account/zod.gen.ts @@ -21,7 +21,7 @@ export const zAccountAvatarPayload = z.object({ */ export const zAccount = z.object({ avatar: z.string().nullish(), - avatar_url: z.string().readonly().nullable(), + avatar_url: z.string().nullable(), created_at: z.int().nullish(), email: z.string(), id: z.string(), @@ -127,6 +127,11 @@ export const zEducationActivatePayload = z.object({ token: z.string(), }) +/** + * EducationActivateResponse + */ +export const zEducationActivateResponse = z.record(z.string(), z.unknown()) + /** * EducationAutocompleteResponse */ @@ -296,7 +301,7 @@ export const zPostAccountEducationBody = zEducationActivatePayload /** * Success */ -export const zPostAccountEducationResponse = z.record(z.string(), z.unknown()) +export const zPostAccountEducationResponse = zEducationActivateResponse export const zGetAccountEducationAutocompleteQuery = z.object({ keywords: z.string(), diff --git a/packages/contracts/generated/api/console/activate/types.gen.ts b/packages/contracts/generated/api/console/activate/types.gen.ts index ae12c3e4615..92370b5a75c 100644 --- a/packages/contracts/generated/api/console/activate/types.gen.ts +++ b/packages/contracts/generated/api/console/activate/types.gen.ts @@ -18,7 +18,7 @@ export type ActivationResponse = { } export type ActivationCheckResponse = { - data?: ActivationCheckData + data?: ActivationCheckData | null is_valid: boolean } @@ -36,13 +36,9 @@ export type PostActivateData = { } export type PostActivateErrors = { - 400: { - [key: string]: unknown - } + 400: unknown } -export type PostActivateError = PostActivateErrors[keyof PostActivateErrors] - export type PostActivateResponses = { 200: ActivationResponse } @@ -53,9 +49,9 @@ export type GetActivateCheckData = { body?: never path?: never query: { - email?: string | null + email?: string token: string - workspace_id?: string | null + workspace_id?: string } url: '/activate/check' } diff --git a/packages/contracts/generated/api/console/activate/zod.gen.ts b/packages/contracts/generated/api/console/activate/zod.gen.ts index 573c2d5f4c1..00f85767b7c 100644 --- a/packages/contracts/generated/api/console/activate/zod.gen.ts +++ b/packages/contracts/generated/api/console/activate/zod.gen.ts @@ -34,7 +34,7 @@ export const zActivationCheckData = z.object({ * ActivationCheckResponse */ export const zActivationCheckResponse = z.object({ - data: zActivationCheckData.optional(), + data: zActivationCheckData.nullish(), is_valid: z.boolean(), }) @@ -46,9 +46,9 @@ export const zPostActivateBody = zActivatePayload export const zPostActivateResponse = zActivationResponse export const zGetActivateCheckQuery = z.object({ - email: z.string().nullish(), + email: z.string().optional(), token: z.string(), - workspace_id: z.string().nullish(), + workspace_id: z.string().optional(), }) /** diff --git a/packages/contracts/generated/api/console/agent/orpc.gen.ts b/packages/contracts/generated/api/console/agent/orpc.gen.ts new file mode 100644 index 00000000000..ba01699e2bd --- /dev/null +++ b/packages/contracts/generated/api/console/agent/orpc.gen.ts @@ -0,0 +1,762 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { oc } from '@orpc/contract' +import * as z from 'zod' + +import { + zDeleteAgentByAgentIdFilesPath, + zDeleteAgentByAgentIdFilesQuery, + zDeleteAgentByAgentIdFilesResponse, + zDeleteAgentByAgentIdPath, + zDeleteAgentByAgentIdResponse, + zDeleteAgentByAgentIdSkillsBySlugPath, + zDeleteAgentByAgentIdSkillsBySlugResponse, + zGetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsPath, + zGetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsResponse, + zGetAgentByAgentIdChatMessagesPath, + zGetAgentByAgentIdChatMessagesQuery, + zGetAgentByAgentIdChatMessagesResponse, + zGetAgentByAgentIdComposerCandidatesPath, + zGetAgentByAgentIdComposerCandidatesResponse, + zGetAgentByAgentIdComposerPath, + zGetAgentByAgentIdComposerResponse, + zGetAgentByAgentIdDriveFilesDownloadPath, + zGetAgentByAgentIdDriveFilesDownloadQuery, + zGetAgentByAgentIdDriveFilesDownloadResponse, + zGetAgentByAgentIdDriveFilesPath, + zGetAgentByAgentIdDriveFilesPreviewPath, + zGetAgentByAgentIdDriveFilesPreviewQuery, + zGetAgentByAgentIdDriveFilesPreviewResponse, + zGetAgentByAgentIdDriveFilesQuery, + zGetAgentByAgentIdDriveFilesResponse, + zGetAgentByAgentIdLogsPath, + zGetAgentByAgentIdLogsQuery, + zGetAgentByAgentIdLogsResponse, + zGetAgentByAgentIdMessagesByMessageIdPath, + zGetAgentByAgentIdMessagesByMessageIdResponse, + zGetAgentByAgentIdPath, + zGetAgentByAgentIdReferencingWorkflowsPath, + zGetAgentByAgentIdReferencingWorkflowsResponse, + zGetAgentByAgentIdResponse, + zGetAgentByAgentIdSandboxFilesPath, + zGetAgentByAgentIdSandboxFilesQuery, + zGetAgentByAgentIdSandboxFilesReadPath, + zGetAgentByAgentIdSandboxFilesReadQuery, + zGetAgentByAgentIdSandboxFilesReadResponse, + zGetAgentByAgentIdSandboxFilesResponse, + zGetAgentByAgentIdStatisticsSummaryPath, + zGetAgentByAgentIdStatisticsSummaryQuery, + zGetAgentByAgentIdStatisticsSummaryResponse, + zGetAgentByAgentIdVersionsByVersionIdPath, + zGetAgentByAgentIdVersionsByVersionIdResponse, + zGetAgentByAgentIdVersionsPath, + zGetAgentByAgentIdVersionsResponse, + zGetAgentInviteOptionsQuery, + zGetAgentInviteOptionsResponse, + zGetAgentQuery, + zGetAgentResponse, + zPostAgentBody, + zPostAgentByAgentIdChatMessagesByTaskIdStopPath, + zPostAgentByAgentIdChatMessagesByTaskIdStopResponse, + zPostAgentByAgentIdComposerValidateBody, + zPostAgentByAgentIdComposerValidatePath, + zPostAgentByAgentIdComposerValidateResponse, + zPostAgentByAgentIdFeaturesBody, + zPostAgentByAgentIdFeaturesPath, + zPostAgentByAgentIdFeaturesResponse, + zPostAgentByAgentIdFeedbacksBody, + zPostAgentByAgentIdFeedbacksPath, + zPostAgentByAgentIdFeedbacksResponse, + zPostAgentByAgentIdFilesBody, + zPostAgentByAgentIdFilesPath, + zPostAgentByAgentIdFilesResponse, + zPostAgentByAgentIdSandboxFilesUploadBody, + zPostAgentByAgentIdSandboxFilesUploadPath, + zPostAgentByAgentIdSandboxFilesUploadResponse, + zPostAgentByAgentIdSkillsBySlugInferToolsPath, + zPostAgentByAgentIdSkillsBySlugInferToolsResponse, + zPostAgentByAgentIdSkillsStandardizePath, + zPostAgentByAgentIdSkillsStandardizeResponse, + zPostAgentByAgentIdSkillsUploadPath, + zPostAgentByAgentIdSkillsUploadResponse, + zPostAgentResponse, + zPutAgentByAgentIdBody, + zPutAgentByAgentIdComposerBody, + zPutAgentByAgentIdComposerPath, + zPutAgentByAgentIdComposerResponse, + zPutAgentByAgentIdPath, + zPutAgentByAgentIdResponse, +} from './zod.gen' + +export const get = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentInviteOptions', + path: '/agent/invite-options', + tags: ['console'], + }) + .input(z.object({ query: zGetAgentInviteOptionsQuery.optional() })) + .output(zGetAgentInviteOptionsResponse) + +export const inviteOptions = { + get, +} + +/** + * Get suggested questions for an Agent App message + */ +export const get2 = oc + .route({ + description: 'Get suggested questions for an Agent App message', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdChatMessagesByMessageIdSuggestedQuestions', + path: '/agent/{agent_id}/chat-messages/{message_id}/suggested-questions', + tags: ['console'], + }) + .input(z.object({ params: zGetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsPath })) + .output(zGetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsResponse) + +export const suggestedQuestions = { + get: get2, +} + +export const byMessageId = { + suggestedQuestions, +} + +/** + * Stop a running Agent App chat message generation + */ +export const post = oc + .route({ + description: 'Stop a running Agent App chat message generation', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAgentByAgentIdChatMessagesByTaskIdStop', + path: '/agent/{agent_id}/chat-messages/{task_id}/stop', + tags: ['console'], + }) + .input(z.object({ params: zPostAgentByAgentIdChatMessagesByTaskIdStopPath })) + .output(zPostAgentByAgentIdChatMessagesByTaskIdStopResponse) + +export const stop = { + post, +} + +export const byTaskId = { + stop, +} + +/** + * Get Agent App chat messages for a conversation with pagination + */ +export const get3 = oc + .route({ + description: 'Get Agent App chat messages for a conversation with pagination', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdChatMessages', + path: '/agent/{agent_id}/chat-messages', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAgentByAgentIdChatMessagesPath, + query: zGetAgentByAgentIdChatMessagesQuery, + }), + ) + .output(zGetAgentByAgentIdChatMessagesResponse) + +export const chatMessages = { + get: get3, + byMessageId, + byTaskId, +} + +export const get4 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdComposerCandidates', + path: '/agent/{agent_id}/composer/candidates', + tags: ['console'], + }) + .input(z.object({ params: zGetAgentByAgentIdComposerCandidatesPath })) + .output(zGetAgentByAgentIdComposerCandidatesResponse) + +export const candidates = { + get: get4, +} + +export const post2 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAgentByAgentIdComposerValidate', + path: '/agent/{agent_id}/composer/validate', + tags: ['console'], + }) + .input( + z.object({ + body: zPostAgentByAgentIdComposerValidateBody, + params: zPostAgentByAgentIdComposerValidatePath, + }), + ) + .output(zPostAgentByAgentIdComposerValidateResponse) + +export const validate = { + post: post2, +} + +export const get5 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdComposer', + path: '/agent/{agent_id}/composer', + tags: ['console'], + }) + .input(z.object({ params: zGetAgentByAgentIdComposerPath })) + .output(zGetAgentByAgentIdComposerResponse) + +export const put = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putAgentByAgentIdComposer', + path: '/agent/{agent_id}/composer', + tags: ['console'], + }) + .input(z.object({ body: zPutAgentByAgentIdComposerBody, params: zPutAgentByAgentIdComposerPath })) + .output(zPutAgentByAgentIdComposerResponse) + +export const composer = { + get: get5, + put, + candidates, + validate, +} + +/** + * Time-limited external signed URL for one Agent App drive value + */ +export const get6 = oc + .route({ + description: 'Time-limited external signed URL for one Agent App drive value', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdDriveFilesDownload', + path: '/agent/{agent_id}/drive/files/download', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAgentByAgentIdDriveFilesDownloadPath, + query: zGetAgentByAgentIdDriveFilesDownloadQuery, + }), + ) + .output(zGetAgentByAgentIdDriveFilesDownloadResponse) + +export const download = { + get: get6, +} + +/** + * Truncated text preview of one Agent App drive value + */ +export const get7 = oc + .route({ + description: 'Truncated text preview of one Agent App drive value', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdDriveFilesPreview', + path: '/agent/{agent_id}/drive/files/preview', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAgentByAgentIdDriveFilesPreviewPath, + query: zGetAgentByAgentIdDriveFilesPreviewQuery, + }), + ) + .output(zGetAgentByAgentIdDriveFilesPreviewResponse) + +export const preview = { + get: get7, +} + +/** + * List agent drive entries for an Agent App + */ +export const get8 = oc + .route({ + description: 'List agent drive entries for an Agent App', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdDriveFiles', + path: '/agent/{agent_id}/drive/files', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAgentByAgentIdDriveFilesPath, + query: zGetAgentByAgentIdDriveFilesQuery.optional(), + }), + ) + .output(zGetAgentByAgentIdDriveFilesResponse) + +export const files = { + get: get8, + download, + preview, +} + +export const drive = { + files, +} + +/** + * Update an Agent App's presentation features (opener, follow-up, citations, ...) + */ +export const post3 = oc + .route({ + description: 'Update an Agent App\'s presentation features (opener, follow-up, citations, ...)', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAgentByAgentIdFeatures', + path: '/agent/{agent_id}/features', + tags: ['console'], + }) + .input( + z.object({ body: zPostAgentByAgentIdFeaturesBody, params: zPostAgentByAgentIdFeaturesPath }), + ) + .output(zPostAgentByAgentIdFeaturesResponse) + +export const features = { + post: post3, +} + +/** + * Create or update Agent App message feedback + */ +export const post4 = oc + .route({ + description: 'Create or update Agent App message feedback', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAgentByAgentIdFeedbacks', + path: '/agent/{agent_id}/feedbacks', + tags: ['console'], + }) + .input( + z.object({ body: zPostAgentByAgentIdFeedbacksBody, params: zPostAgentByAgentIdFeedbacksPath }), + ) + .output(zPostAgentByAgentIdFeedbacksResponse) + +export const feedbacks = { + post: post4, +} + +/** + * Delete one Agent App drive file by key + */ +export const delete_ = oc + .route({ + description: 'Delete one Agent App drive file by key', + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteAgentByAgentIdFiles', + path: '/agent/{agent_id}/files', + tags: ['console'], + }) + .input( + z.object({ params: zDeleteAgentByAgentIdFilesPath, query: zDeleteAgentByAgentIdFilesQuery }), + ) + .output(zDeleteAgentByAgentIdFilesResponse) + +/** + * Commit an uploaded file into the Agent App drive under files/ + */ +export const post5 = oc + .route({ + description: 'Commit an uploaded file into the Agent App drive under files/', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAgentByAgentIdFiles', + path: '/agent/{agent_id}/files', + successStatus: 201, + tags: ['console'], + }) + .input(z.object({ body: zPostAgentByAgentIdFilesBody, params: zPostAgentByAgentIdFilesPath })) + .output(zPostAgentByAgentIdFilesResponse) + +export const files2 = { + delete: delete_, + post: post5, +} + +export const get9 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdLogs', + path: '/agent/{agent_id}/logs', + tags: ['console'], + }) + .input( + z.object({ params: zGetAgentByAgentIdLogsPath, query: zGetAgentByAgentIdLogsQuery.optional() }), + ) + .output(zGetAgentByAgentIdLogsResponse) + +export const logs = { + get: get9, +} + +/** + * Get Agent App message details by ID + */ +export const get10 = oc + .route({ + description: 'Get Agent App message details by ID', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdMessagesByMessageId', + path: '/agent/{agent_id}/messages/{message_id}', + tags: ['console'], + }) + .input(z.object({ params: zGetAgentByAgentIdMessagesByMessageIdPath })) + .output(zGetAgentByAgentIdMessagesByMessageIdResponse) + +export const byMessageId2 = { + get: get10, +} + +export const messages = { + byMessageId: byMessageId2, +} + +/** + * List workflow apps that reference this Agent App's bound Agent (read-only) + */ +export const get11 = oc + .route({ + description: 'List workflow apps that reference this Agent App\'s bound Agent (read-only)', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdReferencingWorkflows', + path: '/agent/{agent_id}/referencing-workflows', + tags: ['console'], + }) + .input(z.object({ params: zGetAgentByAgentIdReferencingWorkflowsPath })) + .output(zGetAgentByAgentIdReferencingWorkflowsResponse) + +export const referencingWorkflows = { + get: get11, +} + +/** + * Read a text/binary preview file in an Agent App conversation sandbox + */ +export const get12 = oc + .route({ + description: 'Read a text/binary preview file in an Agent App conversation sandbox', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdSandboxFilesRead', + path: '/agent/{agent_id}/sandbox/files/read', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAgentByAgentIdSandboxFilesReadPath, + query: zGetAgentByAgentIdSandboxFilesReadQuery, + }), + ) + .output(zGetAgentByAgentIdSandboxFilesReadResponse) + +export const read = { + get: get12, +} + +/** + * Upload one Agent App sandbox file as a Dify ToolFile mapping + */ +export const post6 = oc + .route({ + description: 'Upload one Agent App sandbox file as a Dify ToolFile mapping', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAgentByAgentIdSandboxFilesUpload', + path: '/agent/{agent_id}/sandbox/files/upload', + tags: ['console'], + }) + .input( + z.object({ + body: zPostAgentByAgentIdSandboxFilesUploadBody, + params: zPostAgentByAgentIdSandboxFilesUploadPath, + }), + ) + .output(zPostAgentByAgentIdSandboxFilesUploadResponse) + +export const upload = { + post: post6, +} + +/** + * List a directory in an Agent App conversation sandbox + */ +export const get13 = oc + .route({ + description: 'List a directory in an Agent App conversation sandbox', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdSandboxFiles', + path: '/agent/{agent_id}/sandbox/files', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAgentByAgentIdSandboxFilesPath, + query: zGetAgentByAgentIdSandboxFilesQuery, + }), + ) + .output(zGetAgentByAgentIdSandboxFilesResponse) + +export const files3 = { + get: get13, + read, + upload, +} + +export const sandbox = { + files: files3, +} + +/** + * Validate + standardize a Skill into an Agent App drive + */ +export const post7 = oc + .route({ + description: 'Validate + standardize a Skill into an Agent App drive', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAgentByAgentIdSkillsStandardize', + path: '/agent/{agent_id}/skills/standardize', + successStatus: 201, + tags: ['console'], + }) + .input(z.object({ params: zPostAgentByAgentIdSkillsStandardizePath })) + .output(zPostAgentByAgentIdSkillsStandardizeResponse) + +export const standardize = { + post: post7, +} + +/** + * Upload + validate a Skill package for an Agent App + */ +export const post8 = oc + .route({ + description: 'Upload + validate a Skill package for an Agent App', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAgentByAgentIdSkillsUpload', + path: '/agent/{agent_id}/skills/upload', + successStatus: 201, + tags: ['console'], + }) + .input(z.object({ params: zPostAgentByAgentIdSkillsUploadPath })) + .output(zPostAgentByAgentIdSkillsUploadResponse) + +export const upload2 = { + post: post8, +} + +/** + * Infer CLI tool + ENV suggestions from a standardized Agent App skill + */ +export const post9 = oc + .route({ + description: 'Infer CLI tool + ENV suggestions from a standardized Agent App skill', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAgentByAgentIdSkillsBySlugInferTools', + path: '/agent/{agent_id}/skills/{slug}/infer-tools', + tags: ['console'], + }) + .input(z.object({ params: zPostAgentByAgentIdSkillsBySlugInferToolsPath })) + .output(zPostAgentByAgentIdSkillsBySlugInferToolsResponse) + +export const inferTools = { + post: post9, +} + +/** + * Delete a standardized skill from an Agent App drive + */ +export const delete2 = oc + .route({ + description: 'Delete a standardized skill from an Agent App drive', + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteAgentByAgentIdSkillsBySlug', + path: '/agent/{agent_id}/skills/{slug}', + tags: ['console'], + }) + .input(z.object({ params: zDeleteAgentByAgentIdSkillsBySlugPath })) + .output(zDeleteAgentByAgentIdSkillsBySlugResponse) + +export const bySlug = { + delete: delete2, + inferTools, +} + +export const skills = { + standardize, + upload: upload2, + bySlug, +} + +export const get14 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdStatisticsSummary', + path: '/agent/{agent_id}/statistics/summary', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAgentByAgentIdStatisticsSummaryPath, + query: zGetAgentByAgentIdStatisticsSummaryQuery.optional(), + }), + ) + .output(zGetAgentByAgentIdStatisticsSummaryResponse) + +export const summary = { + get: get14, +} + +export const statistics = { + summary, +} + +export const get15 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdVersionsByVersionId', + path: '/agent/{agent_id}/versions/{version_id}', + tags: ['console'], + }) + .input(z.object({ params: zGetAgentByAgentIdVersionsByVersionIdPath })) + .output(zGetAgentByAgentIdVersionsByVersionIdResponse) + +export const byVersionId = { + get: get15, +} + +export const get16 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdVersions', + path: '/agent/{agent_id}/versions', + tags: ['console'], + }) + .input(z.object({ params: zGetAgentByAgentIdVersionsPath })) + .output(zGetAgentByAgentIdVersionsResponse) + +export const versions = { + get: get16, + byVersionId, +} + +export const delete3 = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteAgentByAgentId', + path: '/agent/{agent_id}', + successStatus: 204, + tags: ['console'], + }) + .input(z.object({ params: zDeleteAgentByAgentIdPath })) + .output(zDeleteAgentByAgentIdResponse) + +export const get17 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentId', + path: '/agent/{agent_id}', + tags: ['console'], + }) + .input(z.object({ params: zGetAgentByAgentIdPath })) + .output(zGetAgentByAgentIdResponse) + +export const put2 = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putAgentByAgentId', + path: '/agent/{agent_id}', + tags: ['console'], + }) + .input(z.object({ body: zPutAgentByAgentIdBody, params: zPutAgentByAgentIdPath })) + .output(zPutAgentByAgentIdResponse) + +export const byAgentId = { + delete: delete3, + get: get17, + put: put2, + chatMessages, + composer, + drive, + features, + feedbacks, + files: files2, + logs, + messages, + referencingWorkflows, + sandbox, + skills, + statistics, + versions, +} + +export const get18 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgent', + path: '/agent', + tags: ['console'], + }) + .input(z.object({ query: zGetAgentQuery.optional() })) + .output(zGetAgentResponse) + +export const post10 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAgent', + path: '/agent', + successStatus: 201, + tags: ['console'], + }) + .input(z.object({ body: zPostAgentBody })) + .output(zPostAgentResponse) + +export const agent = { + get: get18, + post: post10, + inviteOptions, + byAgentId, +} + +export const contract = { + agent, +} diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts new file mode 100644 index 00000000000..77b203fb958 --- /dev/null +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -0,0 +1,2079 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: `${string}://${string}/console/api` | (string & {}) +} + +export type AgentAppPagination = { + data: Array + has_more: boolean + limit: number + page: number + total: number +} + +export type AgentAppCreatePayload = { + description?: string | null + icon?: string | null + icon_background?: string | null + icon_type?: IconType | null + name: string + role?: string +} + +export type AppDetailWithSite = { + access_mode?: string | null + active_config_is_published?: boolean + api_base_url?: string | null + app_id?: string | null + bound_agent_id?: string | null + created_at?: number | null + created_by?: string | null + deleted_tools?: Array + description?: string | null + enable_api: boolean + enable_site: boolean + icon?: string | null + icon_background?: string | null + icon_type?: string | null + readonly icon_url: string | null + id: string + max_active_requests?: number | null + mode: string + model_config?: ModelConfig | null + name: string + role?: string | null + site?: Site | null + tags?: Array + tracing?: JsonValue | null + updated_at?: number | null + updated_by?: string | null + use_icon_as_answer_icon?: boolean | null + workflow?: WorkflowPartial | null +} + +export type AgentInviteOptionsResponse = { + data: Array + has_more: boolean + limit: number + page: number + total: number +} + +export type AgentAppUpdatePayload = { + description?: string | null + icon?: string | null + icon_background?: string | null + icon_type?: IconType | null + max_active_requests?: number | null + name: string + role?: string | null + use_icon_as_answer_icon?: boolean | null +} + +export type MessageInfiniteScrollPaginationResponse = { + data: Array + has_more: boolean + limit: number +} + +export type SuggestedQuestionsResponse = { + data: Array +} + +export type SimpleResultResponse = { + result: string +} + +export type AgentAppComposerResponse = { + active_config_snapshot: AgentConfigSnapshotSummaryResponse + agent: AgentComposerAgentResponse + agent_soul: AgentSoulConfig + save_options: Array + validation?: ComposerValidationFindingsResponse | null + variant: 'agent_app' +} + +export type ComposerSavePayload = { + agent_soul?: AgentSoulConfig | null + binding?: ComposerBindingPayload | null + client_revision_id?: string | null + idempotency_key?: string | null + new_agent_name?: string | null + node_job?: WorkflowNodeJobConfig | null + save_strategy: ComposerSaveStrategy + soul_lock?: ComposerSoulLockPayload + variant: ComposerVariant + version_note?: string | null +} + +export type AgentComposerCandidatesResponse = { + allowed_node_job_candidates?: AgentComposerNodeJobCandidatesResponse + allowed_soul_candidates?: AgentComposerSoulCandidatesResponse + capabilities?: ComposerCandidateCapabilities + truncated?: boolean + variant: ComposerVariant +} + +export type AgentComposerValidateResponse = { + errors?: Array + knowledge_retrieval_placeholder?: Array + result: 'success' + warnings?: Array +} + +export type AgentDriveListResponse = { + items?: Array +} + +export type AgentDriveDownloadResponse = { + url: string +} + +export type AgentDrivePreviewResponse = { + binary: boolean + key: string + size?: number | null + text?: string | null + truncated: boolean +} + +export type AgentAppFeaturesPayload = { + opening_statement?: string | null + retriever_resource?: AgentFeatureToggleConfig | null + sensitive_word_avoidance?: AgentSensitiveWordAvoidanceFeatureConfig | null + speech_to_text?: AgentFeatureToggleConfig | null + suggested_questions?: Array | null + suggested_questions_after_answer?: AgentSuggestedQuestionsAfterAnswerFeatureConfig | null + text_to_speech?: AgentTextToSpeechFeatureConfig | null +} + +export type MessageFeedbackPayload = { + content?: string | null + message_id: string + rating?: 'dislike' | 'like' | null +} + +export type AgentDriveDeleteResponse = { + config_version_id?: string | null + removed_keys?: Array + result: string +} + +export type AgentDriveFilePayload = { + upload_file_id: string +} + +export type AgentDriveFileCommitResponse = { + config_version_id?: string | null + file: AgentDriveFileResponse +} + +export type AgentLogListResponse = { + data: Array + has_more: boolean + limit: number + page: number + total: number +} + +export type MessageDetailResponse = { + agent_thoughts?: Array + annotation?: ConversationAnnotation | null + annotation_hit_history?: ConversationAnnotationHitHistory | null + answer_tokens?: number | null + conversation_id: string + created_at?: number | null + error?: string | null + extra_contents?: Array + feedbacks?: Array + from_account_id?: string | null + from_end_user_id?: string | null + from_source: string + id: string + inputs: { + [key: string]: JsonValue + } + message?: JsonValue | null + message_files?: Array + message_metadata_dict?: JsonValue | null + message_tokens?: number | null + parent_message_id?: string | null + provider_response_latency?: number | null + query: string + re_sign_file_url_answer: string + status: string + workflow_run_id?: string | null +} + +export type AgentReferencingWorkflowsResponse = { + data?: Array +} + +export type SandboxListResponse = { + entries?: Array + path: string + truncated?: boolean +} + +export type SandboxReadResponse = { + binary: boolean + path: string + size?: number | null + text?: string | null + truncated: boolean +} + +export type AgentSandboxUploadPayload = { + conversation_id: string + path: string +} + +export type SandboxUploadResponse = { + file: SandboxToolFileResponse + path: string +} + +export type AgentSkillStandardizeResponse = { + manifest: SkillManifest + skill: AgentSkillRefConfig +} + +export type AgentSkillUploadResponse = { + manifest: SkillManifest + skill: AgentSkillRefConfig +} + +export type SkillToolInferenceResult = { + cli_tools?: Array + inferable: boolean + reason?: string | null +} + +export type AgentStatisticSummaryEnvelopeResponse = { + charts: AgentStatisticChartsResponse + source: string + summary: AgentStatisticSummaryResponse +} + +export type AgentConfigSnapshotListResponse = { + data: Array +} + +export type AgentConfigSnapshotDetailResponse = { + agent_id?: string | null + config_snapshot: AgentSoulConfig + created_at?: number | null + created_by?: string | null + id: string + revisions?: Array + summary?: string | null + version: number + version_note?: string | null +} + +export type AgentAppPartial = { + access_mode?: string | null + active_config_is_published?: boolean + app_id?: string | null + author_name?: string | null + bound_agent_id?: string | null + create_user_name?: string | null + created_at?: number | null + created_by?: string | null + description?: string | null + has_draft_trigger?: boolean | null + icon?: string | null + icon_background?: string | null + icon_type?: string | null + readonly icon_url: string | null + id: string + is_starred?: boolean + max_active_requests?: number | null + mode: string + model_config?: ModelConfigPartial | null + name: string + published_reference_count?: number + published_references?: Array + role?: string | null + tags?: Array + updated_at?: number | null + updated_by?: string | null + use_icon_as_answer_icon?: boolean | null + workflow?: WorkflowPartial | null +} + +export type IconType = 'emoji' | 'image' | 'link' + +export type DeletedTool = { + provider_id: string + tool_name: string + type: string +} + +export type ModelConfig = { + completion_params?: { + [key: string]: unknown + } + mode: LlmMode + name: string + provider: string +} + +export type Site = { + chat_color_theme?: string | null + chat_color_theme_inverted: boolean + copyright?: string | null + custom_disclaimer?: string | null + default_language: string + description?: string | null + icon?: string | null + icon_background?: string | null + icon_type?: string | null + readonly icon_url: string | null + privacy_policy?: string | null + show_workflow_steps: boolean + title: string + use_icon_as_answer_icon: boolean +} + +export type Tag = { + id: string + name: string + 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 + id: string + updated_at?: number | null + updated_by?: string | null +} + +export type AgentInviteOptionResponse = { + active_config_is_published?: boolean + active_config_snapshot?: AgentConfigSnapshotSummaryResponse | null + active_config_snapshot_id?: string | null + agent_kind: AgentKind + app_id?: string | null + archived_at?: number | null + archived_by?: string | null + created_at?: number | null + created_by?: string | null + description: string + existing_node_ids?: Array + icon?: string | null + icon_background?: string | null + icon_type?: AgentIconType | null + id: string + in_current_workflow_count?: number + is_in_current_workflow?: boolean + name: string + published_node_reference_count?: number + published_reference_count?: number + published_references?: Array + role?: string + scope: AgentScope + source: AgentSource + status: AgentStatus + updated_at?: number | null + updated_by?: string | null + workflow_id?: string | null + workflow_node_id?: string | null +} + +export type AgentConfigSnapshotSummaryResponse = { + agent_id?: string | null + created_at?: number | null + created_by?: string | null + id: string + summary?: string | null + version: number + version_note?: string | null +} + +export type AgentComposerAgentResponse = { + active_config_snapshot_id?: string | null + description: string + id: string + name: string + scope: AgentScope + status: AgentStatus +} + +export type AgentSoulConfig = { + app_features?: AgentSoulAppFeaturesConfig + app_variables?: Array + env?: AgentSoulEnvConfig + human?: AgentSoulHumanConfig + knowledge?: AgentSoulKnowledgeConfig + memory?: AgentSoulMemoryConfig + misc_legacy?: AgentSoulAppFeaturesConfig + model?: AgentSoulModelConfig | null + prompt?: AgentSoulPromptConfig + sandbox?: AgentSoulSandboxConfig + schema_version?: number + skills_files?: AgentSoulSkillsFilesConfig + tools?: AgentSoulToolsConfig +} + +export type ComposerSaveStrategy + = | 'node_job_only' + | 'save_as_new_agent' + | 'save_as_new_version' + | 'save_to_current_version' + | 'save_to_roster' + +export type ComposerValidationFindingsResponse = { + knowledge_retrieval_placeholder?: Array + warnings?: Array +} + +export type ComposerBindingPayload = { + agent_id?: string | null + binding_type: 'inline_agent' | 'roster_agent' + current_snapshot_id?: string | null +} + +export type WorkflowNodeJobConfig = { + declared_outputs?: Array + human_contacts?: Array + metadata?: WorkflowNodeJobMetadata + mode?: WorkflowNodeJobMode + previous_node_output_refs?: Array + schema_version?: number + workflow_prompt?: string +} + +export type ComposerSoulLockPayload = { + locked?: boolean + unlocked_from_version_id?: string | null +} + +export type ComposerVariant = 'agent_app' | 'workflow' + +export type AgentComposerNodeJobCandidatesResponse = { + declare_output_types?: Array + human_contacts?: Array + previous_node_outputs?: Array +} + +export type AgentComposerSoulCandidatesResponse = { + cli_tools?: Array + dify_tools?: Array + human_contacts?: Array + knowledge_datasets?: Array + skills_files?: Array< + | ({ + kind: 'skill' + } & AgentComposerSkillCandidateResponse) + | ({ + kind: 'file' + } & AgentComposerFileCandidateResponse) + > +} + +export type ComposerCandidateCapabilities = { + human_roster_available?: boolean +} + +export type ComposerKnowledgePlaceholderResponse = { + id: string + placeholder_name: string +} + +export type ComposerValidationWarningResponse = { + code: string + id?: string | null + kind?: string | null + message?: string | null + surface?: string | null +} + +export type AgentDriveItemResponse = { + created_at?: number | null + file_kind: string + hash?: string | null + key: string + mime_type?: string | null + size?: number | null +} + +export type AgentFeatureToggleConfig = { + enabled?: boolean + [key: string]: unknown +} + +export type AgentSensitiveWordAvoidanceFeatureConfig = { + config?: AgentModerationProviderConfig | null + enabled?: boolean + type?: string | null + [key: string]: unknown +} + +export type AgentSuggestedQuestionsAfterAnswerFeatureConfig = { + enabled?: boolean + model?: AgentSoulModelConfig | null + prompt?: string | null + [key: string]: unknown +} + +export type AgentTextToSpeechFeatureConfig = { + autoPlay?: string | null + enabled?: boolean + language?: string | null + voice?: string | null + [key: string]: unknown +} + +export type AgentDriveFileResponse = { + drive_key: string + file_id: string + mime_type?: string | null + name: string + size?: number | null +} + +export type AgentLogItemResponse = { + answer: string + answer_tokens: number + conversation_id: string + conversation_name?: string | null + created_at?: number | null + currency: string + error?: string | null + from_account_id?: string | null + from_end_user_id?: string | null + from_source?: string | null + id: string + latency: number + message_id: string + message_tokens: number + query: string + source?: string | null + status: string + total_price: string + total_tokens: number + updated_at?: number | null +} + +export type AgentThought = { + chain_id?: string | null + created_at?: number | null + files: Array + id: string + message_chain_id?: string | null + message_id: string + observation?: string | null + position: number + thought?: string | null + tool?: string | null + tool_input?: string | null + tool_labels: JsonValue +} + +export type ConversationAnnotation = { + account?: SimpleAccount | null + content: string + created_at?: number | null + id: string + question?: string | null +} + +export type ConversationAnnotationHitHistory = { + annotation_create_account?: SimpleAccount | null + created_at?: number | null + id: string +} + +export type HumanInputContent = { + form_definition?: HumanInputFormDefinition | null + form_submission_data?: HumanInputFormSubmissionData | null + submitted: boolean + type?: ExecutionContentType + workflow_run_id: string +} + +export type Feedback = { + content?: string | null + from_account?: SimpleAccount | null + from_end_user_id?: string | null + from_source: string + rating: string +} + +export type MessageFile = { + belongs_to?: string | null + filename: string + id: string + mime_type?: string | null + size?: number | null + transfer_method: string + type: string + upload_file_id?: string | null + url?: string | null +} + +export type AgentReferencingWorkflowResponse = { + app_icon?: string | null + app_icon_background?: string | null + app_icon_type?: string | null + app_id: string + app_mode: string + app_name: string + app_updated_at?: number | null + node_ids?: Array + workflow_id: string + workflow_version: string +} + +export type SandboxFileEntryResponse = { + mtime?: number | null + name: string + size?: number | null + type: 'dir' | 'file' | 'other' | 'symlink' +} + +export type SandboxToolFileResponse = { + reference: string + transfer_method?: 'tool_file' +} + +export type SkillManifest = { + description: string + entry_path: string + files: Array + hash: string + name: string + size: number +} + +export type AgentSkillRefConfig = { + description?: string | null + file_id?: string | null + full_archive_file_id?: string | null + full_archive_key?: string | null + id?: string | null + manifest_files?: Array | null + name?: string | null + path?: string | null + skill_md_file_id?: string | null + skill_md_key?: string | null + [key: string]: unknown +} + +export type CliToolSuggestion = { + command?: string + description?: string + env_suggestions?: Array + inferred_from?: string + install_commands?: Array + name: string +} + +export type AgentStatisticChartsResponse = { + average_response_time?: Array + average_session_interactions?: Array + daily_conversations?: Array + daily_end_users?: Array + daily_messages?: Array + token_usage?: Array + tokens_per_second?: Array + user_satisfaction_rate?: Array +} + +export type AgentStatisticSummaryResponse = { + average_response_time: number + average_session_interactions: number + currency: string + tokens_per_second: number + total_conversations: number + total_end_users: number + total_messages: number + total_price: string + total_tokens: number + user_satisfaction_rate: number +} + +export type AgentConfigRevisionResponse = { + created_at?: number | null + created_by?: string | null + current_snapshot_id: string + id: string + operation: AgentConfigRevisionOperation + previous_snapshot_id?: string | null + revision: number + summary?: string | null + version_note?: string | null +} + +export type ModelConfigPartial = { + created_at?: number | null + created_by?: string | null + model?: JsonValue | null + pre_prompt?: string | null + updated_at?: number | null + updated_by?: string | null +} + +export type AgentAppPublishedReferenceResponse = { + app_icon?: string | null + app_icon_background?: string | null + app_icon_type?: string | null + app_id: string + app_name: string +} + +export type LlmMode = 'chat' | 'completion' + +export type AgentKind = 'dify_agent' + +export type AgentIconType = 'emoji' | 'image' | 'link' + +export type AgentPublishedReferenceResponse = { + app_icon?: string | null + app_icon_background?: string | null + app_icon_type?: string | null + app_id: string + app_mode: string + app_name: string + app_updated_at?: number | null + node_ids?: Array + workflow_id: string + workflow_version: string +} + +export type AgentScope = 'roster' | 'workflow_only' + +export type AgentSource = 'agent_app' | 'imported' | 'roster' | 'system' | 'workflow' + +export type AgentStatus = 'active' | 'archived' + +export type AgentSoulAppFeaturesConfig = { + opening_statement?: string | null + retriever_resource?: AgentFeatureToggleConfig | null + sensitive_word_avoidance?: AgentSensitiveWordAvoidanceFeatureConfig | null + speech_to_text?: AgentFeatureToggleConfig | null + suggested_questions?: Array | null + suggested_questions_after_answer?: AgentSuggestedQuestionsAfterAnswerFeatureConfig | null + text_to_speech?: AgentTextToSpeechFeatureConfig | null + [key: string]: unknown +} + +export type AppVariableConfig = { + default?: unknown + name: string + required?: boolean + type: string +} + +export type AgentSoulEnvConfig = { + secret_refs?: Array + variables?: Array +} + +export type AgentSoulHumanConfig = { + contacts?: Array + tools?: Array +} + +export type AgentSoulKnowledgeConfig = { + datasets?: Array + query_config?: AgentKnowledgeQueryConfig + query_mode?: AgentKnowledgeQueryMode | null +} + +export type AgentSoulMemoryConfig = { + artifacts?: Array + budget?: string | null + scope?: string | null +} + +export type AgentSoulModelConfig = { + credential_ref?: AgentSoulModelCredentialRef | null + model: string + model_provider: string + model_settings?: AgentSoulModelSettings + plugin_id: string +} + +export type AgentSoulPromptConfig = { + system_prompt?: string +} + +export type AgentSoulSandboxConfig = { + config?: AgentSandboxProviderConfig + provider?: string | null +} + +export type AgentSoulSkillsFilesConfig = { + files?: Array + skills?: Array +} + +export type AgentSoulToolsConfig = { + cli_tools?: Array + dify_tools?: Array +} + +export type DeclaredOutputConfig = { + array_item?: DeclaredArrayItem | null + check?: DeclaredOutputCheckConfig | null + children?: Array<{ + array_item?: { + children?: Array<{ + [key: string]: unknown + }> + description?: string | null + type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' + [key: string]: unknown + } + children?: Array<{ + [key: string]: unknown + }> + description?: string | null + file?: { + [key: string]: unknown + } + name: string + required?: boolean + type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' + }> + description?: string | null + failure_strategy?: DeclaredOutputFailureStrategy + file?: DeclaredOutputFileConfig | null + id?: string | null + name: string + required?: boolean + type: DeclaredOutputType +} + +export type AgentHumanContactConfig = { + channel?: string | null + contact_id?: string | null + contact_method?: string | null + email?: string | null + human_id?: string | null + id?: string | null + method?: string | null + name?: string | null + tenant_id?: string | null + [key: string]: unknown +} + +export type WorkflowNodeJobMetadata = { + agent_soul?: { + [key: string]: unknown + } | null + file_refs?: Array | null +} + +export type WorkflowNodeJobMode = 'let_agent_figure_it_out' | 'tell_agent_what_to_do' + +export type WorkflowPreviousNodeOutputRef = { + key?: string | null + name?: string | null + node_id?: string | null + output?: string | null + selector?: Array | null + value_selector?: Array | null + variable?: string | null + variable_selector?: Array | null + [key: string]: unknown +} + +export type DeclaredOutputType = 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' + +export type AgentCliToolConfig = { + approved?: boolean + authorization_status?: AgentCliToolAuthorizationStatus | null + command?: string | null + dangerous?: boolean + dangerous_accepted?: boolean + dangerous_acknowledged?: boolean + dangerous_command?: boolean + description?: string | null + enabled?: boolean + env?: AgentCliToolEnvConfig + id?: string | null + inferred_from?: string | null + install?: string | null + install_command?: string | null + install_commands?: Array + invoke_metadata?: { + [key: string]: unknown + } + label?: string | null + name?: string | null + permission?: AgentPermissionConfig | null + pre_authorized?: boolean | null + requires_confirmation?: boolean + risk_accepted?: boolean + risk_level?: AgentCliToolRiskLevel | null + setup_command?: string | null + tool_name?: string | null + [key: string]: unknown +} + +export type AgentComposerDifyToolCandidateResponse = { + description?: string | null + granularity?: string | null + id?: string | null + name?: string | null + plugin_id?: string | null + provider?: string | null + provider_id?: string | null + tools_count?: number | null +} + +export type AgentKnowledgeDatasetConfig = { + description?: string | null + id?: string | null + name?: string | null + [key: string]: unknown +} + +export type AgentComposerSkillCandidateResponse = { + description?: string | null + file_id?: string | null + full_archive_file_id?: string | null + full_archive_key?: string | null + id?: string | null + kind?: 'skill' + manifest_files?: Array | null + name?: string | null + path?: string | null + skill_md_file_id?: string | null + skill_md_key?: string | null + [key: string]: unknown +} + +export type AgentComposerFileCandidateResponse = { + drive_key?: string | null + file_id?: string | null + id?: string | null + kind?: 'file' + name?: string | null + reference?: string | null + remote_url?: string | null + tenant_id?: string | null + transfer_method?: string | null + type?: string | null + upload_file_id?: string | null + url?: string | null + [key: string]: unknown +} + +export type AgentModerationProviderConfig = { + api_based_extension_id?: string | null + inputs_config?: AgentModerationIoConfig | null + keywords?: string | null + outputs_config?: AgentModerationIoConfig | null + [key: string]: unknown +} + +export type SimpleAccount = { + email: string + id: string + name: string +} + +export type HumanInputFormDefinition = { + actions?: Array + display_in_ui?: boolean + expiration_time: number + form_content: string + form_id: string + form_token?: string | null + inputs?: Array + node_id: string + node_title: string + resolved_default_values?: { + [key: string]: unknown + } +} + +export type HumanInputFormSubmissionData = { + action_id: string + action_text: string + node_id: string + node_title: string + rendered_content: string + submitted_data?: { + [key: string]: JsonValue2 + } | null +} + +export type ExecutionContentType = 'human_input' + +export type EnvSuggestion = { + key: string + reason?: string + secret_likely?: boolean +} + +export type AgentAverageResponseTimeStatisticResponse = { + date: string + latency: number +} + +export type AgentAverageSessionInteractionStatisticResponse = { + date: string + interactions: number +} + +export type AgentDailyConversationStatisticResponse = { + conversation_count: number + date: string +} + +export type AgentDailyEndUserStatisticResponse = { + date: string + terminal_count: number +} + +export type AgentDailyMessageStatisticResponse = { + date: string + message_count: number +} + +export type AgentTokenUsageStatisticResponse = { + currency: string + date: string + token_count: number + total_price: string +} + +export type AgentTokensPerSecondStatisticResponse = { + date: string + tps: number +} + +export type AgentUserSatisfactionRateStatisticResponse = { + date: string + rate: number +} + +export type AgentConfigRevisionOperation + = | 'create_version' + | 'save_current_version' + | 'save_new_agent' + | 'save_new_version' + | 'save_to_roster' + +export type AgentSecretRefConfig = { + credential_id?: string | null + env_name?: string | null + id?: string | null + key?: string | null + name?: string | null + permission?: AgentPermissionConfig | null + permission_status?: string | null + provider?: string | null + provider_credential_id?: string | null + ref?: string | null + type?: string | null + value?: string | null + variable?: string | null + [key: string]: unknown +} + +export type AgentEnvVariableConfig = { + default?: + | string + | number + | number + | boolean + | Array + | Array + | Array + | Array + | null + env_name?: string | null + key?: string | null + name?: string | null + required?: boolean + type?: string | null + value?: + | string + | number + | number + | boolean + | Array + | Array + | Array + | Array + | null + variable?: string | null + [key: string]: unknown +} + +export type AgentHumanToolConfig = { + description?: string | null + enabled?: boolean + name?: string | null + [key: string]: unknown +} + +export type AgentKnowledgeQueryConfig = { + query?: string | null + score_threshold?: number | null + score_threshold_enabled?: boolean | null + top_k?: number | null + [key: string]: unknown +} + +export type AgentKnowledgeQueryMode = 'generated_query' | 'user_query' + +export type AgentMemoryArtifactConfig = { + id?: string | null + name?: string | null + type?: string | null + url?: string | null + [key: string]: unknown +} + +export type AgentSoulModelCredentialRef = { + id?: string | null + provider?: string | null + type: string +} + +export type AgentSoulModelSettings = { + frequency_penalty?: number | null + max_tokens?: number | null + presence_penalty?: number | null + response_format?: AgentModelResponseFormatConfig | null + stop?: Array | null + temperature?: number | null + top_p?: number | null +} + +export type AgentSandboxProviderConfig = { + cpu?: number | null + env?: Array + image?: string | null + working_dir?: string | null + [key: string]: unknown +} + +export type AgentFileRefConfig = { + drive_key?: string | null + file_id?: string | null + id?: string | null + name?: string | null + reference?: string | null + remote_url?: string | null + tenant_id?: string | null + transfer_method?: string | null + type?: string | null + upload_file_id?: string | null + url?: string | null + [key: string]: unknown +} + +export type AgentSoulDifyToolConfig = { + credential_ref?: AgentSoulDifyToolCredentialRef | null + credential_type?: 'api-key' | 'oauth2' | 'unauthorized' + description?: string | null + enabled?: boolean + name?: string | null + plugin_id?: string | null + provider?: string | null + provider_id?: string | null + provider_type?: string + runtime_parameters?: { + [key: string]: + | string + | number + | number + | boolean + | Array + | Array + | Array + | Array + | null + } + tool_name?: string | null +} + +export type DeclaredArrayItem = { + children?: Array<{ + array_item?: { + children?: Array<{ + [key: string]: unknown + }> + description?: string | null + type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' + [key: string]: unknown + } + children?: Array<{ + [key: string]: unknown + }> + description?: string | null + file?: { + [key: string]: unknown + } + name: string + required?: boolean + type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' + }> + description?: string | null + type: DeclaredOutputType +} + +export type DeclaredOutputCheckConfig = { + benchmark_file_ref?: AgentFileRefConfig | null + enabled?: boolean + model_ref?: AgentSoulModelConfig | null + prompt?: string | null +} + +export type DeclaredOutputFailureStrategy = { + default_value?: unknown + on_failure?: OutputErrorStrategy + retry?: DeclaredOutputRetryConfig +} + +export type DeclaredOutputFileConfig = { + extensions?: Array + mime_types?: Array +} + +export type AgentCliToolAuthorizationStatus + = | 'allowed' + | 'authorized' + | 'denied' + | 'forbidden' + | 'not_required' + | 'pending' + | 'pre_authorized' + | 'unauthorized' + +export type AgentCliToolEnvConfig = { + secret_refs?: Array + variables?: Array +} + +export type AgentPermissionConfig = { + allowed?: boolean | null + state?: string | null + status?: string | null +} + +export type AgentCliToolRiskLevel = 'dangerous' | 'safe' | 'unknown' + +export type AgentModerationIoConfig = { + enabled?: boolean + preset_response?: string | null + [key: string]: unknown +} + +export type UserActionConfig = { + button_style?: ButtonStyle + id: string + title: string +} + +export type FormInputConfig + = | ({ + type: 'paragraph' + } & ParagraphInputConfig) + | ({ + type: 'select' + } & SelectInputConfig) + | ({ + type: 'file' + } & FileInputConfig) + | ({ + type: 'file-list' + } & FileListInputConfig) + +export type JsonValue2 = unknown + +export type AgentModelResponseFormatConfig = { + type?: string | null + [key: string]: unknown +} + +export type AgentSoulDifyToolCredentialRef = { + id?: string | null + provider?: string | null + type?: 'provider' | 'tool' +} + +export type OutputErrorStrategy = 'default_value' | 'fail_branch' | 'stop' + +export type DeclaredOutputRetryConfig = { + enabled?: boolean + max_retries?: number + retry_interval_ms?: number +} + +export type ButtonStyle = 'accent' | 'default' | 'ghost' | 'primary' + +export type ParagraphInputConfig = { + default?: StringSource | null + output_variable_name: string + type?: 'paragraph' +} + +export type SelectInputConfig = { + option_source: StringListSource + output_variable_name: string + type?: 'select' +} + +export type FileInputConfig = { + allowed_file_extensions?: Array + allowed_file_types?: Array + allowed_file_upload_methods?: Array + output_variable_name: string + type?: 'file' +} + +export type FileListInputConfig = { + allowed_file_extensions?: Array + allowed_file_types?: Array + allowed_file_upload_methods?: Array + number_limits?: number + output_variable_name: string + type?: 'file-list' +} + +export type StringSource = { + selector?: Array + type: ValueSourceType + value?: string +} + +export type StringListSource = { + selector?: Array + type: ValueSourceType + value?: Array +} + +export type FileType = 'audio' | 'custom' | 'document' | 'image' | 'video' + +export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url' | 'tool_file' + +export type ValueSourceType = 'constant' | 'variable' + +export type AgentAppPaginationWritable = { + data: Array + has_more: boolean + limit: number + page: number + total: number +} + +export type AppDetailWithSiteWritable = { + access_mode?: string | null + active_config_is_published?: boolean + api_base_url?: string | null + app_id?: string | null + bound_agent_id?: string | null + created_at?: number | null + created_by?: string | null + deleted_tools?: Array + description?: string | null + enable_api: boolean + enable_site: boolean + icon?: string | null + icon_background?: string | null + icon_type?: string | null + id: string + max_active_requests?: number | null + mode: string + model_config?: ModelConfig | null + name: string + role?: string | null + site?: SiteWritable | null + tags?: Array + tracing?: JsonValue | null + updated_at?: number | null + updated_by?: string | null + use_icon_as_answer_icon?: boolean | null + workflow?: WorkflowPartial | null +} + +export type AgentAppPartialWritable = { + access_mode?: string | null + active_config_is_published?: boolean + app_id?: string | null + author_name?: string | null + bound_agent_id?: string | null + create_user_name?: string | null + created_at?: number | null + created_by?: string | null + description?: string | null + has_draft_trigger?: boolean | null + icon?: string | null + icon_background?: string | null + icon_type?: string | null + id: string + is_starred?: boolean + max_active_requests?: number | null + mode: string + model_config?: ModelConfigPartial | null + name: string + published_reference_count?: number + published_references?: Array + role?: string | null + tags?: Array + updated_at?: number | null + updated_by?: string | null + use_icon_as_answer_icon?: boolean | null + workflow?: WorkflowPartial | null +} + +export type SiteWritable = { + chat_color_theme?: string | null + chat_color_theme_inverted: boolean + copyright?: string | null + custom_disclaimer?: string | null + default_language: string + description?: string | null + icon?: string | null + icon_background?: string | null + icon_type?: string | null + privacy_policy?: string | null + show_workflow_steps: boolean + title: string + use_icon_as_answer_icon: boolean +} + +export type GetAgentData = { + body?: never + path?: never + query?: { + creator_ids?: Array + is_created_by_me?: boolean + limit?: number + mode?: + | 'advanced-chat' + | 'agent' + | 'agent-chat' + | 'all' + | 'channel' + | 'chat' + | 'completion' + | 'workflow' + name?: string + page?: number + sort_by?: 'earliest_created' | 'last_modified' | 'recently_created' + tag_ids?: Array + } + url: '/agent' +} + +export type GetAgentResponses = { + 200: AgentAppPagination +} + +export type GetAgentResponse = GetAgentResponses[keyof GetAgentResponses] + +export type PostAgentData = { + body: AgentAppCreatePayload + path?: never + query?: never + url: '/agent' +} + +export type PostAgentErrors = { + 400: unknown + 403: unknown +} + +export type PostAgentResponses = { + 201: AppDetailWithSite +} + +export type PostAgentResponse = PostAgentResponses[keyof PostAgentResponses] + +export type GetAgentInviteOptionsData = { + body?: never + path?: never + query?: { + app_id?: string + keyword?: string + limit?: number + page?: number + } + url: '/agent/invite-options' +} + +export type GetAgentInviteOptionsResponses = { + 200: AgentInviteOptionsResponse +} + +export type GetAgentInviteOptionsResponse + = GetAgentInviteOptionsResponses[keyof GetAgentInviteOptionsResponses] + +export type DeleteAgentByAgentIdData = { + body?: never + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}' +} + +export type DeleteAgentByAgentIdErrors = { + 403: unknown +} + +export type DeleteAgentByAgentIdResponses = { + 204: void +} + +export type DeleteAgentByAgentIdResponse + = DeleteAgentByAgentIdResponses[keyof DeleteAgentByAgentIdResponses] + +export type GetAgentByAgentIdData = { + body?: never + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}' +} + +export type GetAgentByAgentIdResponses = { + 200: AppDetailWithSite +} + +export type GetAgentByAgentIdResponse = GetAgentByAgentIdResponses[keyof GetAgentByAgentIdResponses] + +export type PutAgentByAgentIdData = { + body: AgentAppUpdatePayload + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}' +} + +export type PutAgentByAgentIdErrors = { + 400: unknown + 403: unknown +} + +export type PutAgentByAgentIdResponses = { + 200: AppDetailWithSite +} + +export type PutAgentByAgentIdResponse = PutAgentByAgentIdResponses[keyof PutAgentByAgentIdResponses] + +export type GetAgentByAgentIdChatMessagesData = { + body?: never + path: { + agent_id: string + } + query: { + conversation_id: string + first_id?: string + limit?: number + } + url: '/agent/{agent_id}/chat-messages' +} + +export type GetAgentByAgentIdChatMessagesErrors = { + 404: unknown +} + +export type GetAgentByAgentIdChatMessagesResponses = { + 200: MessageInfiniteScrollPaginationResponse +} + +export type GetAgentByAgentIdChatMessagesResponse + = GetAgentByAgentIdChatMessagesResponses[keyof GetAgentByAgentIdChatMessagesResponses] + +export type GetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsData = { + body?: never + path: { + agent_id: string + message_id: string + } + query?: never + url: '/agent/{agent_id}/chat-messages/{message_id}/suggested-questions' +} + +export type GetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsErrors = { + 404: unknown +} + +export type GetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsResponses = { + 200: SuggestedQuestionsResponse +} + +export type GetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsResponse + = GetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsResponses[keyof GetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsResponses] + +export type PostAgentByAgentIdChatMessagesByTaskIdStopData = { + body?: never + path: { + agent_id: string + task_id: string + } + query?: never + url: '/agent/{agent_id}/chat-messages/{task_id}/stop' +} + +export type PostAgentByAgentIdChatMessagesByTaskIdStopResponses = { + 200: SimpleResultResponse +} + +export type PostAgentByAgentIdChatMessagesByTaskIdStopResponse + = PostAgentByAgentIdChatMessagesByTaskIdStopResponses[keyof PostAgentByAgentIdChatMessagesByTaskIdStopResponses] + +export type GetAgentByAgentIdComposerData = { + body?: never + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/composer' +} + +export type GetAgentByAgentIdComposerResponses = { + 200: AgentAppComposerResponse +} + +export type GetAgentByAgentIdComposerResponse + = GetAgentByAgentIdComposerResponses[keyof GetAgentByAgentIdComposerResponses] + +export type PutAgentByAgentIdComposerData = { + body: ComposerSavePayload + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/composer' +} + +export type PutAgentByAgentIdComposerResponses = { + 200: AgentAppComposerResponse +} + +export type PutAgentByAgentIdComposerResponse + = PutAgentByAgentIdComposerResponses[keyof PutAgentByAgentIdComposerResponses] + +export type GetAgentByAgentIdComposerCandidatesData = { + body?: never + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/composer/candidates' +} + +export type GetAgentByAgentIdComposerCandidatesResponses = { + 200: AgentComposerCandidatesResponse +} + +export type GetAgentByAgentIdComposerCandidatesResponse + = GetAgentByAgentIdComposerCandidatesResponses[keyof GetAgentByAgentIdComposerCandidatesResponses] + +export type PostAgentByAgentIdComposerValidateData = { + body: ComposerSavePayload + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/composer/validate' +} + +export type PostAgentByAgentIdComposerValidateResponses = { + 200: AgentComposerValidateResponse +} + +export type PostAgentByAgentIdComposerValidateResponse + = PostAgentByAgentIdComposerValidateResponses[keyof PostAgentByAgentIdComposerValidateResponses] + +export type GetAgentByAgentIdDriveFilesData = { + body?: never + path: { + agent_id: string + } + query?: { + prefix?: string + } + url: '/agent/{agent_id}/drive/files' +} + +export type GetAgentByAgentIdDriveFilesResponses = { + 200: AgentDriveListResponse +} + +export type GetAgentByAgentIdDriveFilesResponse + = GetAgentByAgentIdDriveFilesResponses[keyof GetAgentByAgentIdDriveFilesResponses] + +export type GetAgentByAgentIdDriveFilesDownloadData = { + body?: never + path: { + agent_id: string + } + query: { + key: string + } + url: '/agent/{agent_id}/drive/files/download' +} + +export type GetAgentByAgentIdDriveFilesDownloadResponses = { + 200: AgentDriveDownloadResponse +} + +export type GetAgentByAgentIdDriveFilesDownloadResponse + = GetAgentByAgentIdDriveFilesDownloadResponses[keyof GetAgentByAgentIdDriveFilesDownloadResponses] + +export type GetAgentByAgentIdDriveFilesPreviewData = { + body?: never + path: { + agent_id: string + } + query: { + key: string + } + url: '/agent/{agent_id}/drive/files/preview' +} + +export type GetAgentByAgentIdDriveFilesPreviewResponses = { + 200: AgentDrivePreviewResponse +} + +export type GetAgentByAgentIdDriveFilesPreviewResponse + = GetAgentByAgentIdDriveFilesPreviewResponses[keyof GetAgentByAgentIdDriveFilesPreviewResponses] + +export type PostAgentByAgentIdFeaturesData = { + body: AgentAppFeaturesPayload + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/features' +} + +export type PostAgentByAgentIdFeaturesErrors = { + 400: unknown + 404: unknown +} + +export type PostAgentByAgentIdFeaturesResponses = { + 200: SimpleResultResponse +} + +export type PostAgentByAgentIdFeaturesResponse + = PostAgentByAgentIdFeaturesResponses[keyof PostAgentByAgentIdFeaturesResponses] + +export type PostAgentByAgentIdFeedbacksData = { + body: MessageFeedbackPayload + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/feedbacks' +} + +export type PostAgentByAgentIdFeedbacksErrors = { + 404: unknown +} + +export type PostAgentByAgentIdFeedbacksResponses = { + 200: SimpleResultResponse +} + +export type PostAgentByAgentIdFeedbacksResponse + = PostAgentByAgentIdFeedbacksResponses[keyof PostAgentByAgentIdFeedbacksResponses] + +export type DeleteAgentByAgentIdFilesData = { + body?: never + path: { + agent_id: string + } + query: { + key: string + } + url: '/agent/{agent_id}/files' +} + +export type DeleteAgentByAgentIdFilesResponses = { + 200: AgentDriveDeleteResponse +} + +export type DeleteAgentByAgentIdFilesResponse + = DeleteAgentByAgentIdFilesResponses[keyof DeleteAgentByAgentIdFilesResponses] + +export type PostAgentByAgentIdFilesData = { + body: AgentDriveFilePayload + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/files' +} + +export type PostAgentByAgentIdFilesResponses = { + 201: AgentDriveFileCommitResponse +} + +export type PostAgentByAgentIdFilesResponse + = PostAgentByAgentIdFilesResponses[keyof PostAgentByAgentIdFilesResponses] + +export type GetAgentByAgentIdLogsData = { + body?: never + path: { + agent_id: string + } + query?: { + end?: string + keyword?: string + limit?: number + page?: number + source?: string + start?: string + status?: string + } + url: '/agent/{agent_id}/logs' +} + +export type GetAgentByAgentIdLogsResponses = { + 200: AgentLogListResponse +} + +export type GetAgentByAgentIdLogsResponse + = GetAgentByAgentIdLogsResponses[keyof GetAgentByAgentIdLogsResponses] + +export type GetAgentByAgentIdMessagesByMessageIdData = { + body?: never + path: { + agent_id: string + message_id: string + } + query?: never + url: '/agent/{agent_id}/messages/{message_id}' +} + +export type GetAgentByAgentIdMessagesByMessageIdErrors = { + 404: unknown +} + +export type GetAgentByAgentIdMessagesByMessageIdResponses = { + 200: MessageDetailResponse +} + +export type GetAgentByAgentIdMessagesByMessageIdResponse + = GetAgentByAgentIdMessagesByMessageIdResponses[keyof GetAgentByAgentIdMessagesByMessageIdResponses] + +export type GetAgentByAgentIdReferencingWorkflowsData = { + body?: never + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/referencing-workflows' +} + +export type GetAgentByAgentIdReferencingWorkflowsErrors = { + 404: unknown +} + +export type GetAgentByAgentIdReferencingWorkflowsResponses = { + 200: AgentReferencingWorkflowsResponse +} + +export type GetAgentByAgentIdReferencingWorkflowsResponse + = GetAgentByAgentIdReferencingWorkflowsResponses[keyof GetAgentByAgentIdReferencingWorkflowsResponses] + +export type GetAgentByAgentIdSandboxFilesData = { + body?: never + path: { + agent_id: string + } + query: { + conversation_id: string + path?: string + } + url: '/agent/{agent_id}/sandbox/files' +} + +export type GetAgentByAgentIdSandboxFilesResponses = { + 200: SandboxListResponse +} + +export type GetAgentByAgentIdSandboxFilesResponse + = GetAgentByAgentIdSandboxFilesResponses[keyof GetAgentByAgentIdSandboxFilesResponses] + +export type GetAgentByAgentIdSandboxFilesReadData = { + body?: never + path: { + agent_id: string + } + query: { + conversation_id: string + path: string + } + url: '/agent/{agent_id}/sandbox/files/read' +} + +export type GetAgentByAgentIdSandboxFilesReadResponses = { + 200: SandboxReadResponse +} + +export type GetAgentByAgentIdSandboxFilesReadResponse + = GetAgentByAgentIdSandboxFilesReadResponses[keyof GetAgentByAgentIdSandboxFilesReadResponses] + +export type PostAgentByAgentIdSandboxFilesUploadData = { + body: AgentSandboxUploadPayload + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/sandbox/files/upload' +} + +export type PostAgentByAgentIdSandboxFilesUploadResponses = { + 200: SandboxUploadResponse +} + +export type PostAgentByAgentIdSandboxFilesUploadResponse + = PostAgentByAgentIdSandboxFilesUploadResponses[keyof PostAgentByAgentIdSandboxFilesUploadResponses] + +export type PostAgentByAgentIdSkillsStandardizeData = { + body?: never + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/skills/standardize' +} + +export type PostAgentByAgentIdSkillsStandardizeErrors = { + 400: unknown +} + +export type PostAgentByAgentIdSkillsStandardizeResponses = { + 201: AgentSkillStandardizeResponse +} + +export type PostAgentByAgentIdSkillsStandardizeResponse + = PostAgentByAgentIdSkillsStandardizeResponses[keyof PostAgentByAgentIdSkillsStandardizeResponses] + +export type PostAgentByAgentIdSkillsUploadData = { + body?: never + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/skills/upload' +} + +export type PostAgentByAgentIdSkillsUploadErrors = { + 400: unknown +} + +export type PostAgentByAgentIdSkillsUploadResponses = { + 201: AgentSkillUploadResponse +} + +export type PostAgentByAgentIdSkillsUploadResponse + = PostAgentByAgentIdSkillsUploadResponses[keyof PostAgentByAgentIdSkillsUploadResponses] + +export type DeleteAgentByAgentIdSkillsBySlugData = { + body?: never + path: { + agent_id: string + slug: string + } + query?: never + url: '/agent/{agent_id}/skills/{slug}' +} + +export type DeleteAgentByAgentIdSkillsBySlugResponses = { + 200: AgentDriveDeleteResponse +} + +export type DeleteAgentByAgentIdSkillsBySlugResponse + = DeleteAgentByAgentIdSkillsBySlugResponses[keyof DeleteAgentByAgentIdSkillsBySlugResponses] + +export type PostAgentByAgentIdSkillsBySlugInferToolsData = { + body?: never + path: { + agent_id: string + slug: string + } + query?: never + url: '/agent/{agent_id}/skills/{slug}/infer-tools' +} + +export type PostAgentByAgentIdSkillsBySlugInferToolsResponses = { + 200: SkillToolInferenceResult +} + +export type PostAgentByAgentIdSkillsBySlugInferToolsResponse + = PostAgentByAgentIdSkillsBySlugInferToolsResponses[keyof PostAgentByAgentIdSkillsBySlugInferToolsResponses] + +export type GetAgentByAgentIdStatisticsSummaryData = { + body?: never + path: { + agent_id: string + } + query?: { + end?: string + source?: string + start?: string + } + url: '/agent/{agent_id}/statistics/summary' +} + +export type GetAgentByAgentIdStatisticsSummaryResponses = { + 200: AgentStatisticSummaryEnvelopeResponse +} + +export type GetAgentByAgentIdStatisticsSummaryResponse + = GetAgentByAgentIdStatisticsSummaryResponses[keyof GetAgentByAgentIdStatisticsSummaryResponses] + +export type GetAgentByAgentIdVersionsData = { + body?: never + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/versions' +} + +export type GetAgentByAgentIdVersionsResponses = { + 200: AgentConfigSnapshotListResponse +} + +export type GetAgentByAgentIdVersionsResponse + = GetAgentByAgentIdVersionsResponses[keyof GetAgentByAgentIdVersionsResponses] + +export type GetAgentByAgentIdVersionsByVersionIdData = { + body?: never + path: { + agent_id: string + version_id: string + } + query?: never + url: '/agent/{agent_id}/versions/{version_id}' +} + +export type GetAgentByAgentIdVersionsByVersionIdResponses = { + 200: AgentConfigSnapshotDetailResponse +} + +export type GetAgentByAgentIdVersionsByVersionIdResponse + = GetAgentByAgentIdVersionsByVersionIdResponses[keyof GetAgentByAgentIdVersionsByVersionIdResponses] diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts new file mode 100644 index 00000000000..7ea55838189 --- /dev/null +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -0,0 +1,2415 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod' + +/** + * SuggestedQuestionsResponse + */ +export const zSuggestedQuestionsResponse = z.object({ + data: z.array(z.string()), +}) + +/** + * SimpleResultResponse + */ +export const zSimpleResultResponse = z.object({ + result: z.string(), +}) + +/** + * AgentDriveDownloadResponse + */ +export const zAgentDriveDownloadResponse = z.object({ + url: z.string(), +}) + +/** + * AgentDrivePreviewResponse + */ +export const zAgentDrivePreviewResponse = z.object({ + binary: z.boolean(), + key: z.string(), + size: z.int().nullish(), + text: z.string().nullish(), + truncated: z.boolean(), +}) + +/** + * MessageFeedbackPayload + */ +export const zMessageFeedbackPayload = z.object({ + content: z.string().nullish(), + message_id: z.string(), + rating: z.enum(['dislike', 'like']).nullish(), +}) + +/** + * AgentDriveDeleteResponse + */ +export const zAgentDriveDeleteResponse = z.object({ + config_version_id: z.string().nullish(), + removed_keys: z.array(z.string()).optional(), + result: z.string(), +}) + +/** + * AgentDriveFilePayload + */ +export const zAgentDriveFilePayload = z.object({ + upload_file_id: z.string(), +}) + +/** + * SandboxReadResponse + */ +export const zSandboxReadResponse = z.object({ + binary: z.boolean(), + path: z.string(), + size: z.int().nullish(), + text: z.string().nullish(), + truncated: z.boolean(), +}) + +/** + * AgentSandboxUploadPayload + */ +export const zAgentSandboxUploadPayload = z.object({ + conversation_id: z.string().min(1), + path: z.string().min(1), +}) + +/** + * IconType + */ +export const zIconType = z.enum(['emoji', 'image', 'link']) + +/** + * AgentAppCreatePayload + */ +export const zAgentAppCreatePayload = z.object({ + description: z.string().max(400).nullish(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: zIconType.nullish(), + name: z.string().min(1), + role: z.string().max(255).optional().default(''), +}) + +/** + * AgentAppUpdatePayload + */ +export const zAgentAppUpdatePayload = z.object({ + description: z.string().max(400).nullish(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: zIconType.nullish(), + max_active_requests: z.int().nullish(), + name: z.string().min(1), + role: z.string().max(255).nullish(), + use_icon_as_answer_icon: z.boolean().nullish(), +}) + +/** + * DeletedTool + */ +export const zDeletedTool = z.object({ + provider_id: z.string(), + tool_name: z.string(), + type: z.string(), +}) + +/** + * Site + */ +export const zSite = z.object({ + chat_color_theme: z.string().nullish(), + chat_color_theme_inverted: z.boolean(), + copyright: z.string().nullish(), + custom_disclaimer: z.string().nullish(), + default_language: z.string(), + description: z.string().nullish(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.string().nullish(), + icon_url: z.string().nullable(), + privacy_policy: z.string().nullish(), + show_workflow_steps: z.boolean(), + title: z.string(), + use_icon_as_answer_icon: z.boolean(), +}) + +/** + * Tag + */ +export const zTag = z.object({ + id: z.string(), + name: z.string(), + 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 + */ +export const zWorkflowPartial = z.object({ + created_at: z.int().nullish(), + created_by: z.string().nullish(), + id: z.string(), + updated_at: z.int().nullish(), + updated_by: z.string().nullish(), +}) + +/** + * AgentConfigSnapshotSummaryResponse + */ +export const zAgentConfigSnapshotSummaryResponse = z.object({ + agent_id: z.string().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + id: z.string(), + summary: z.string().nullish(), + version: z.int(), + version_note: z.string().nullish(), +}) + +/** + * AgentConfigSnapshotListResponse + */ +export const zAgentConfigSnapshotListResponse = z.object({ + data: z.array(zAgentConfigSnapshotSummaryResponse), +}) + +/** + * ComposerSaveStrategy + */ +export const zComposerSaveStrategy = z.enum([ + 'node_job_only', + 'save_as_new_agent', + 'save_as_new_version', + 'save_to_current_version', + 'save_to_roster', +]) + +/** + * ComposerBindingPayload + */ +export const zComposerBindingPayload = z.object({ + agent_id: z.string().nullish(), + binding_type: z.enum(['inline_agent', 'roster_agent']), + current_snapshot_id: z.string().nullish(), +}) + +/** + * ComposerSoulLockPayload + */ +export const zComposerSoulLockPayload = z.object({ + locked: z.boolean().optional().default(true), + unlocked_from_version_id: z.string().nullish(), +}) + +/** + * ComposerVariant + */ +export const zComposerVariant = z.enum(['agent_app', 'workflow']) + +/** + * ComposerCandidateCapabilities + */ +export const zComposerCandidateCapabilities = z.object({ + human_roster_available: z.boolean().optional().default(false), +}) + +/** + * ComposerKnowledgePlaceholderResponse + */ +export const zComposerKnowledgePlaceholderResponse = z.object({ + id: z.string(), + placeholder_name: z.string(), +}) + +/** + * ComposerValidationWarningResponse + */ +export const zComposerValidationWarningResponse = z.object({ + code: z.string(), + id: z.string().nullish(), + kind: z.string().nullish(), + message: z.string().nullish(), + surface: z.string().nullish(), +}) + +/** + * AgentComposerValidateResponse + */ +export const zAgentComposerValidateResponse = z.object({ + errors: z.array(z.string()).optional(), + knowledge_retrieval_placeholder: z.array(zComposerKnowledgePlaceholderResponse).optional(), + result: z.literal('success'), + warnings: z.array(zComposerValidationWarningResponse).optional(), +}) + +/** + * ComposerValidationFindingsResponse + */ +export const zComposerValidationFindingsResponse = z.object({ + knowledge_retrieval_placeholder: z.array(zComposerKnowledgePlaceholderResponse).optional(), + warnings: z.array(zComposerValidationWarningResponse).optional(), +}) + +/** + * AgentDriveItemResponse + */ +export const zAgentDriveItemResponse = z.object({ + created_at: z.int().nullish(), + file_kind: z.string(), + hash: z.string().nullish(), + key: z.string(), + mime_type: z.string().nullish(), + size: z.int().nullish(), +}) + +/** + * AgentDriveListResponse + */ +export const zAgentDriveListResponse = z.object({ + items: z.array(zAgentDriveItemResponse).optional(), +}) + +/** + * AgentFeatureToggleConfig + */ +export const zAgentFeatureToggleConfig = z.object({ + enabled: z.boolean().optional().default(false), +}) + +/** + * AgentTextToSpeechFeatureConfig + */ +export const zAgentTextToSpeechFeatureConfig = z.object({ + autoPlay: z.string().nullish(), + enabled: z.boolean().optional().default(false), + language: z.string().nullish(), + voice: z.string().nullish(), +}) + +/** + * AgentDriveFileResponse + */ +export const zAgentDriveFileResponse = z.object({ + drive_key: z.string(), + file_id: z.string(), + mime_type: z.string().nullish(), + name: z.string(), + size: z.int().nullish(), +}) + +/** + * AgentDriveFileCommitResponse + */ +export const zAgentDriveFileCommitResponse = z.object({ + config_version_id: z.string().nullish(), + file: zAgentDriveFileResponse, +}) + +/** + * AgentLogItemResponse + */ +export const zAgentLogItemResponse = z.object({ + answer: z.string(), + answer_tokens: z.int(), + conversation_id: z.string(), + conversation_name: z.string().nullish(), + created_at: z.int().nullish(), + currency: z.string(), + error: z.string().nullish(), + from_account_id: z.string().nullish(), + from_end_user_id: z.string().nullish(), + from_source: z.string().nullish(), + id: z.string(), + latency: z.number(), + message_id: z.string(), + message_tokens: z.int(), + query: z.string(), + source: z.string().nullish(), + status: z.string(), + total_price: z.string(), + total_tokens: z.int(), + updated_at: z.int().nullish(), +}) + +/** + * AgentLogListResponse + */ +export const zAgentLogListResponse = z.object({ + data: z.array(zAgentLogItemResponse), + has_more: z.boolean(), + limit: z.int(), + page: z.int(), + total: z.int(), +}) + +/** + * AgentThought + */ +export const zAgentThought = z.object({ + chain_id: z.string().nullish(), + 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(), + thought: z.string().nullish(), + tool: z.string().nullish(), + tool_input: z.string().nullish(), + tool_labels: zJsonValue, +}) + +/** + * MessageFile + */ +export const zMessageFile = z.object({ + belongs_to: z.string().nullish(), + filename: z.string(), + id: z.string(), + mime_type: z.string().nullish(), + size: z.int().nullish(), + transfer_method: z.string(), + type: z.string(), + upload_file_id: z.string().nullish(), + url: z.string().nullish(), +}) + +/** + * AgentReferencingWorkflowResponse + */ +export const zAgentReferencingWorkflowResponse = z.object({ + app_icon: z.string().nullish(), + app_icon_background: z.string().nullish(), + app_icon_type: z.string().nullish(), + app_id: z.string(), + app_mode: z.string(), + app_name: z.string(), + app_updated_at: z.int().nullish(), + node_ids: z.array(z.string()).optional(), + workflow_id: z.string(), + workflow_version: z.string(), +}) + +/** + * AgentReferencingWorkflowsResponse + */ +export const zAgentReferencingWorkflowsResponse = z.object({ + data: z.array(zAgentReferencingWorkflowResponse).optional(), +}) + +/** + * SandboxFileEntryResponse + */ +export const zSandboxFileEntryResponse = z.object({ + mtime: z.int().nullish(), + name: z.string(), + size: z.int().nullish(), + type: z.enum(['dir', 'file', 'other', 'symlink']), +}) + +/** + * SandboxListResponse + */ +export const zSandboxListResponse = z.object({ + entries: z.array(zSandboxFileEntryResponse).optional(), + path: z.string(), + truncated: z.boolean().optional().default(false), +}) + +/** + * SandboxToolFileResponse + */ +export const zSandboxToolFileResponse = z.object({ + reference: z.string(), + transfer_method: z.literal('tool_file').optional().default('tool_file'), +}) + +/** + * SandboxUploadResponse + */ +export const zSandboxUploadResponse = z.object({ + file: zSandboxToolFileResponse, + path: z.string(), +}) + +/** + * SkillManifest + * + * Validated metadata extracted from a Skill package. + */ +export const zSkillManifest = z.object({ + description: z.string(), + entry_path: z.string(), + files: z.array(z.string()), + hash: z.string(), + name: z.string(), + size: z.int(), +}) + +/** + * AgentSkillRefConfig + */ +export const zAgentSkillRefConfig = z.object({ + description: z.string().nullish(), + file_id: z.string().max(255).nullish(), + full_archive_file_id: z.string().max(255).nullish(), + full_archive_key: z.string().max(512).nullish(), + id: z.string().max(255).nullish(), + manifest_files: z.array(z.string()).nullish(), + name: z.string().max(255).nullish(), + path: z.string().nullish(), + skill_md_file_id: z.string().max(255).nullish(), + skill_md_key: z.string().max(512).nullish(), +}) + +/** + * AgentSkillStandardizeResponse + */ +export const zAgentSkillStandardizeResponse = z.object({ + manifest: zSkillManifest, + skill: zAgentSkillRefConfig, +}) + +/** + * AgentSkillUploadResponse + */ +export const zAgentSkillUploadResponse = z.object({ + manifest: zSkillManifest, + skill: zAgentSkillRefConfig, +}) + +/** + * AgentStatisticSummaryResponse + */ +export const zAgentStatisticSummaryResponse = z.object({ + average_response_time: z.number(), + average_session_interactions: z.number(), + currency: z.string(), + tokens_per_second: z.number(), + total_conversations: z.int(), + total_end_users: z.int(), + total_messages: z.int(), + total_price: z.string(), + total_tokens: z.int(), + user_satisfaction_rate: z.number(), +}) + +/** + * ModelConfigPartial + */ +export const zModelConfigPartial = z.object({ + created_at: z.int().nullish(), + created_by: z.string().nullish(), + model: zJsonValue.nullish(), + pre_prompt: z.string().nullish(), + updated_at: z.int().nullish(), + updated_by: z.string().nullish(), +}) + +/** + * AgentAppPublishedReferenceResponse + */ +export const zAgentAppPublishedReferenceResponse = z.object({ + app_icon: z.string().nullish(), + app_icon_background: z.string().nullish(), + app_icon_type: z.string().nullish(), + app_id: z.string(), + app_name: z.string(), +}) + +/** + * AgentAppPartial + */ +export const zAgentAppPartial = z.object({ + access_mode: z.string().nullish(), + active_config_is_published: z.boolean().optional().default(false), + app_id: z.string().nullish(), + author_name: z.string().nullish(), + bound_agent_id: z.string().nullish(), + create_user_name: z.string().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + description: z.string().nullish(), + has_draft_trigger: z.boolean().nullish(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.string().nullish(), + icon_url: z.string().nullable(), + id: z.string(), + is_starred: z.boolean().optional().default(false), + max_active_requests: z.int().nullish(), + mode: z.string(), + model_config: zModelConfigPartial.nullish(), + name: z.string(), + published_reference_count: z.int().optional().default(0), + published_references: z.array(zAgentAppPublishedReferenceResponse).optional(), + role: z.string().nullish(), + tags: z.array(zTag).optional(), + updated_at: z.int().nullish(), + updated_by: z.string().nullish(), + use_icon_as_answer_icon: z.boolean().nullish(), + workflow: zWorkflowPartial.nullish(), +}) + +/** + * AgentAppPagination + */ +export const zAgentAppPagination = z.object({ + data: z.array(zAgentAppPartial), + has_more: z.boolean(), + limit: z.int(), + page: z.int(), + 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(), +}) + +/** + * AppDetailWithSite + */ +export const zAppDetailWithSite = 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(), + 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(), + max_active_requests: z.int().nullish(), + mode: z.string(), + model_config: zModelConfig.nullish(), + name: z.string(), + 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 + * + * Agent implementation family. + * + * This leaves room for future non-Dify agent implementations while keeping + * the current roster/workflow APIs scoped to Dify Agent. + */ +export const zAgentKind = z.enum(['dify_agent']) + +/** + * AgentIconType + * + * Supported icon storage formats for Agent roster entries. + */ +export const zAgentIconType = z.enum(['emoji', 'image', 'link']) + +/** + * AgentPublishedReferenceResponse + */ +export const zAgentPublishedReferenceResponse = z.object({ + app_icon: z.string().nullish(), + app_icon_background: z.string().nullish(), + app_icon_type: z.string().nullish(), + app_id: z.string(), + app_mode: z.string(), + app_name: z.string(), + app_updated_at: z.int().nullish(), + node_ids: z.array(z.string()).optional(), + workflow_id: z.string(), + workflow_version: z.string(), +}) + +/** + * AgentScope + * + * Visibility and lifecycle scope of an Agent record. + */ +export const zAgentScope = z.enum(['roster', 'workflow_only']) + +/** + * AgentSource + * + * Origin that created or imported the Agent. + */ +export const zAgentSource = z.enum(['agent_app', 'imported', 'roster', 'system', 'workflow']) + +/** + * AgentStatus + * + * Soft lifecycle state for Agent records. + */ +export const zAgentStatus = z.enum(['active', 'archived']) + +/** + * AgentInviteOptionResponse + */ +export const zAgentInviteOptionResponse = z.object({ + active_config_is_published: z.boolean().optional().default(false), + active_config_snapshot: zAgentConfigSnapshotSummaryResponse.nullish(), + active_config_snapshot_id: z.string().nullish(), + agent_kind: zAgentKind, + app_id: z.string().nullish(), + archived_at: z.int().nullish(), + archived_by: z.string().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + description: z.string(), + existing_node_ids: z.array(z.string()).optional(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: zAgentIconType.nullish(), + id: z.string(), + in_current_workflow_count: z.int().optional().default(0), + is_in_current_workflow: z.boolean().optional().default(false), + name: z.string(), + published_node_reference_count: z.int().optional().default(0), + published_reference_count: z.int().optional().default(0), + published_references: z.array(zAgentPublishedReferenceResponse).optional(), + role: z.string().optional().default(''), + scope: zAgentScope, + source: zAgentSource, + status: zAgentStatus, + updated_at: z.int().nullish(), + updated_by: z.string().nullish(), + workflow_id: z.string().nullish(), + workflow_node_id: z.string().nullish(), +}) + +/** + * AgentInviteOptionsResponse + */ +export const zAgentInviteOptionsResponse = z.object({ + data: z.array(zAgentInviteOptionResponse), + has_more: z.boolean(), + limit: z.int(), + page: z.int(), + total: z.int(), +}) + +/** + * AgentComposerAgentResponse + */ +export const zAgentComposerAgentResponse = z.object({ + active_config_snapshot_id: z.string().nullish(), + description: z.string(), + id: z.string(), + name: z.string(), + scope: zAgentScope, + status: zAgentStatus, +}) + +/** + * AppVariableConfig + */ +export const zAppVariableConfig = z.object({ + default: z.unknown().optional(), + name: z.string().min(1).max(255), + required: z.boolean().optional().default(false), + type: z.string().min(1).max(64), +}) + +/** + * AgentSoulPromptConfig + */ +export const zAgentSoulPromptConfig = z.object({ + system_prompt: z.string().optional().default(''), +}) + +/** + * AgentHumanContactConfig + */ +export const zAgentHumanContactConfig = z.object({ + channel: z.string().max(64).nullish(), + contact_id: z.string().max(255).nullish(), + contact_method: z.string().max(64).nullish(), + email: z.string().max(255).nullish(), + human_id: z.string().max(255).nullish(), + id: z.string().max(255).nullish(), + method: z.string().max(64).nullish(), + name: z.string().max(255).nullish(), + tenant_id: z.string().max(255).nullish(), +}) + +/** + * WorkflowNodeJobMode + */ +export const zWorkflowNodeJobMode = z.enum(['let_agent_figure_it_out', 'tell_agent_what_to_do']) + +/** + * WorkflowPreviousNodeOutputRef + */ +export const zWorkflowPreviousNodeOutputRef = z.object({ + key: z.string().max(255).nullish(), + name: z.string().max(255).nullish(), + node_id: z.string().max(255).nullish(), + output: z.string().max(255).nullish(), + selector: z.array(z.union([z.string(), z.int(), z.number(), z.boolean(), z.null()])).nullish(), + value_selector: z + .array(z.union([z.string(), z.int(), z.number(), z.boolean(), z.null()])) + .nullish(), + variable: z.string().max(255).nullish(), + variable_selector: z + .array(z.union([z.string(), z.int(), z.number(), z.boolean(), z.null()])) + .nullish(), +}) + +/** + * DeclaredOutputType + */ +export const zDeclaredOutputType = z.enum([ + 'array', + 'boolean', + 'file', + 'number', + 'object', + 'string', +]) + +/** + * AgentComposerNodeJobCandidatesResponse + */ +export const zAgentComposerNodeJobCandidatesResponse = z.object({ + declare_output_types: z.array(zDeclaredOutputType).optional(), + human_contacts: z.array(zAgentHumanContactConfig).optional(), + previous_node_outputs: z.array(zWorkflowPreviousNodeOutputRef).optional(), +}) + +/** + * AgentComposerDifyToolCandidateResponse + */ +export const zAgentComposerDifyToolCandidateResponse = z.object({ + description: z.string().nullish(), + granularity: z.string().nullish(), + id: z.string().nullish(), + name: z.string().nullish(), + plugin_id: z.string().nullish(), + provider: z.string().nullish(), + provider_id: z.string().nullish(), + tools_count: z.int().nullish(), +}) + +/** + * AgentKnowledgeDatasetConfig + */ +export const zAgentKnowledgeDatasetConfig = z.object({ + description: z.string().nullish(), + id: z.string().max(255).nullish(), + name: z.string().max(255).nullish(), +}) + +/** + * AgentComposerSkillCandidateResponse + */ +export const zAgentComposerSkillCandidateResponse = z.object({ + description: z.string().nullish(), + file_id: z.string().max(255).nullish(), + full_archive_file_id: z.string().max(255).nullish(), + full_archive_key: z.string().max(512).nullish(), + id: z.string().max(255).nullish(), + kind: z.literal('skill').optional().default('skill'), + manifest_files: z.array(z.string()).nullish(), + name: z.string().max(255).nullish(), + path: z.string().nullish(), + skill_md_file_id: z.string().max(255).nullish(), + skill_md_key: z.string().max(512).nullish(), +}) + +/** + * AgentComposerFileCandidateResponse + */ +export const zAgentComposerFileCandidateResponse = z.object({ + drive_key: z.string().max(512).nullish(), + file_id: z.string().max(255).nullish(), + id: z.string().max(255).nullish(), + kind: z.literal('file').optional().default('file'), + name: z.string().max(255).nullish(), + reference: z.string().max(255).nullish(), + remote_url: z.string().nullish(), + tenant_id: z.string().max(255).nullish(), + transfer_method: z.string().max(64).nullish(), + type: z.string().max(64).nullish(), + upload_file_id: z.string().max(255).nullish(), + url: z.string().nullish(), +}) + +/** + * SimpleAccount + */ +export const zSimpleAccount = z.object({ + email: z.string(), + id: z.string(), + name: z.string(), +}) + +/** + * ConversationAnnotation + */ +export const zConversationAnnotation = z.object({ + account: zSimpleAccount.nullish(), + content: z.string(), + created_at: z.int().nullish(), + id: z.string(), + question: z.string().nullish(), +}) + +/** + * ConversationAnnotationHitHistory + */ +export const zConversationAnnotationHitHistory = z.object({ + annotation_create_account: zSimpleAccount.nullish(), + created_at: z.int().nullish(), + id: z.string(), +}) + +/** + * Feedback + */ +export const zFeedback = z.object({ + content: z.string().nullish(), + from_account: zSimpleAccount.nullish(), + from_end_user_id: z.string().nullish(), + from_source: z.string(), + rating: z.string(), +}) + +/** + * ExecutionContentType + */ +export const zExecutionContentType = z.enum(['human_input']) + +/** + * EnvSuggestion + */ +export const zEnvSuggestion = z.object({ + key: z.string(), + reason: z.string().optional().default(''), + secret_likely: z.boolean().optional().default(false), +}) + +/** + * CliToolSuggestion + */ +export const zCliToolSuggestion = z.object({ + command: z.string().optional().default(''), + description: z.string().optional().default(''), + env_suggestions: z.array(zEnvSuggestion).optional(), + inferred_from: z.string().optional().default(''), + install_commands: z.array(z.string()).optional(), + name: z.string(), +}) + +/** + * SkillToolInferenceResult + */ +export const zSkillToolInferenceResult = z.object({ + cli_tools: z.array(zCliToolSuggestion).optional(), + inferable: z.boolean(), + reason: z.string().nullish(), +}) + +/** + * AgentAverageResponseTimeStatisticResponse + */ +export const zAgentAverageResponseTimeStatisticResponse = z.object({ + date: z.string(), + latency: z.number(), +}) + +/** + * AgentAverageSessionInteractionStatisticResponse + */ +export const zAgentAverageSessionInteractionStatisticResponse = z.object({ + date: z.string(), + interactions: z.number(), +}) + +/** + * AgentDailyConversationStatisticResponse + */ +export const zAgentDailyConversationStatisticResponse = z.object({ + conversation_count: z.int(), + date: z.string(), +}) + +/** + * AgentDailyEndUserStatisticResponse + */ +export const zAgentDailyEndUserStatisticResponse = z.object({ + date: z.string(), + terminal_count: z.int(), +}) + +/** + * AgentDailyMessageStatisticResponse + */ +export const zAgentDailyMessageStatisticResponse = z.object({ + date: z.string(), + message_count: z.int(), +}) + +/** + * AgentTokenUsageStatisticResponse + */ +export const zAgentTokenUsageStatisticResponse = z.object({ + currency: z.string(), + date: z.string(), + token_count: z.int(), + total_price: z.string(), +}) + +/** + * AgentTokensPerSecondStatisticResponse + */ +export const zAgentTokensPerSecondStatisticResponse = z.object({ + date: z.string(), + tps: z.number(), +}) + +/** + * AgentUserSatisfactionRateStatisticResponse + */ +export const zAgentUserSatisfactionRateStatisticResponse = z.object({ + date: z.string(), + rate: z.number(), +}) + +/** + * AgentStatisticChartsResponse + */ +export const zAgentStatisticChartsResponse = z.object({ + average_response_time: z.array(zAgentAverageResponseTimeStatisticResponse).optional(), + average_session_interactions: z + .array(zAgentAverageSessionInteractionStatisticResponse) + .optional(), + daily_conversations: z.array(zAgentDailyConversationStatisticResponse).optional(), + daily_end_users: z.array(zAgentDailyEndUserStatisticResponse).optional(), + daily_messages: z.array(zAgentDailyMessageStatisticResponse).optional(), + token_usage: z.array(zAgentTokenUsageStatisticResponse).optional(), + tokens_per_second: z.array(zAgentTokensPerSecondStatisticResponse).optional(), + user_satisfaction_rate: z.array(zAgentUserSatisfactionRateStatisticResponse).optional(), +}) + +/** + * AgentStatisticSummaryEnvelopeResponse + */ +export const zAgentStatisticSummaryEnvelopeResponse = z.object({ + charts: zAgentStatisticChartsResponse, + source: z.string(), + summary: zAgentStatisticSummaryResponse, +}) + +/** + * AgentConfigRevisionOperation + * + * Audit operation recorded for Agent Soul version/revision changes. + */ +export const zAgentConfigRevisionOperation = z.enum([ + 'create_version', + 'save_current_version', + 'save_new_agent', + 'save_new_version', + 'save_to_roster', +]) + +/** + * AgentConfigRevisionResponse + */ +export const zAgentConfigRevisionResponse = z.object({ + created_at: z.int().nullish(), + created_by: z.string().nullish(), + current_snapshot_id: z.string(), + id: z.string(), + operation: zAgentConfigRevisionOperation, + previous_snapshot_id: z.string().nullish(), + revision: z.int(), + summary: z.string().nullish(), + version_note: z.string().nullish(), +}) + +/** + * AgentEnvVariableConfig + */ +export const zAgentEnvVariableConfig = z.object({ + default: z + .union([ + z.string(), + z.int(), + z.number(), + z.boolean(), + z.array(z.string()), + z.array(z.int()), + z.array(z.number()), + z.array(z.boolean()), + ]) + .nullish(), + env_name: z.string().max(255).nullish(), + key: z.string().max(255).nullish(), + name: z.string().max(255).nullish(), + required: z.boolean().optional().default(false), + type: z.string().max(64).nullish(), + value: z + .union([ + z.string(), + z.int(), + z.number(), + z.boolean(), + z.array(z.string()), + z.array(z.int()), + z.array(z.number()), + z.array(z.boolean()), + ]) + .nullish(), + variable: z.string().max(255).nullish(), +}) + +/** + * AgentHumanToolConfig + */ +export const zAgentHumanToolConfig = z.object({ + description: z.string().nullish(), + enabled: z.boolean().optional().default(true), + name: z.string().max(255).nullish(), +}) + +/** + * AgentSoulHumanConfig + */ +export const zAgentSoulHumanConfig = z.object({ + contacts: z.array(zAgentHumanContactConfig).optional(), + tools: z.array(zAgentHumanToolConfig).optional(), +}) + +/** + * AgentKnowledgeQueryConfig + */ +export const zAgentKnowledgeQueryConfig = z.object({ + query: z.string().nullish(), + score_threshold: z.number().gte(0).lte(1).nullish(), + score_threshold_enabled: z.boolean().nullish(), + top_k: z.int().gte(1).nullish(), +}) + +/** + * AgentKnowledgeQueryMode + */ +export const zAgentKnowledgeQueryMode = z.enum(['generated_query', 'user_query']) + +/** + * AgentSoulKnowledgeConfig + */ +export const zAgentSoulKnowledgeConfig = z.object({ + datasets: z.array(zAgentKnowledgeDatasetConfig).optional(), + query_config: zAgentKnowledgeQueryConfig.optional(), + query_mode: zAgentKnowledgeQueryMode.nullish(), +}) + +/** + * AgentMemoryArtifactConfig + */ +export const zAgentMemoryArtifactConfig = z.object({ + id: z.string().max(255).nullish(), + name: z.string().max(255).nullish(), + type: z.string().max(64).nullish(), + url: z.string().nullish(), +}) + +/** + * AgentSoulMemoryConfig + */ +export const zAgentSoulMemoryConfig = z.object({ + artifacts: z.array(zAgentMemoryArtifactConfig).optional(), + budget: z.string().nullish(), + scope: z.string().nullish(), +}) + +/** + * AgentSoulModelCredentialRef + * + * Reference to model credentials resolved only at runtime. + */ +export const zAgentSoulModelCredentialRef = z.object({ + id: z.string().max(255).nullish(), + provider: z.string().max(255).nullish(), + type: z.string().min(1).max(64), +}) + +/** + * AgentSandboxProviderConfig + */ +export const zAgentSandboxProviderConfig = z.object({ + cpu: z.int().gte(1).nullish(), + env: z.array(zAgentEnvVariableConfig).optional(), + image: z.string().nullish(), + working_dir: z.string().nullish(), +}) + +/** + * AgentSoulSandboxConfig + */ +export const zAgentSoulSandboxConfig = z.object({ + config: zAgentSandboxProviderConfig.optional(), + provider: z.string().nullish(), +}) + +/** + * AgentFileRefConfig + */ +export const zAgentFileRefConfig = z.object({ + drive_key: z.string().max(512).nullish(), + file_id: z.string().max(255).nullish(), + id: z.string().max(255).nullish(), + name: z.string().max(255).nullish(), + reference: z.string().max(255).nullish(), + remote_url: z.string().nullish(), + tenant_id: z.string().max(255).nullish(), + transfer_method: z.string().max(64).nullish(), + type: z.string().max(64).nullish(), + upload_file_id: z.string().max(255).nullish(), + url: z.string().nullish(), +}) + +/** + * AgentSoulSkillsFilesConfig + */ +export const zAgentSoulSkillsFilesConfig = z.object({ + files: z.array(zAgentFileRefConfig).optional(), + skills: z.array(zAgentSkillRefConfig).optional(), +}) + +/** + * WorkflowNodeJobMetadata + */ +export const zWorkflowNodeJobMetadata = z.object({ + agent_soul: z.record(z.string(), z.unknown()).nullish(), + file_refs: z.array(zAgentFileRefConfig).nullish(), +}) + +/** + * DeclaredArrayItem + * + * Per-item shape for an ``array``-typed declared output. + * + * PRD §OUTPUT 配置框 keeps arrays one level deep on first version; nested arrays + * are rejected so the runtime type checker and JSON Schema stay easy to reason + * about. Stage 4 §4.2. + */ +export const zDeclaredArrayItem = z.object({ + children: z + .array( + z.object({ + array_item: z + .object({ + children: z.array(z.record(z.string(), z.unknown())).optional(), + description: z.string().nullish(), + type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(), + }) + .optional(), + children: z.array(z.record(z.string(), z.unknown())).optional(), + description: z.string().nullish(), + file: z.record(z.string(), z.unknown()).optional(), + name: z.string(), + required: z.boolean().optional(), + type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']), + }), + ) + .optional(), + description: z.string().nullish(), + type: zDeclaredOutputType, +}) + +/** + * DeclaredOutputFileConfig + * + * File-type output metadata. Both lists empty means "any file accepted". + */ +export const zDeclaredOutputFileConfig = z.object({ + extensions: z.array(z.string()).optional(), + mime_types: z.array(z.string()).optional(), +}) + +/** + * AgentCliToolAuthorizationStatus + * + * Authorization state for Agent-scoped CLI tools. + * + * Missing status keeps backward compatibility with draft rows and CLI tools that + * do not need pre-authorization. Explicit denied-like states are blocked by the + * composer/publish validators and skipped by runtime request builders. + */ +export const zAgentCliToolAuthorizationStatus = z.enum([ + 'allowed', + 'authorized', + 'denied', + 'forbidden', + 'not_required', + 'pending', + 'pre_authorized', + 'unauthorized', +]) + +/** + * AgentPermissionConfig + */ +export const zAgentPermissionConfig = z.object({ + allowed: z.boolean().nullish(), + state: z.string().max(64).nullish(), + status: z.string().max(64).nullish(), +}) + +/** + * AgentSecretRefConfig + */ +export const zAgentSecretRefConfig = z.object({ + credential_id: z.string().max(255).nullish(), + env_name: z.string().max(255).nullish(), + id: z.string().max(255).nullish(), + key: z.string().max(255).nullish(), + name: z.string().max(255).nullish(), + permission: zAgentPermissionConfig.nullish(), + permission_status: z.string().max(64).nullish(), + provider: z.string().max(255).nullish(), + provider_credential_id: z.string().max(255).nullish(), + ref: z.string().max(255).nullish(), + type: z.string().max(64).nullish(), + value: z.string().max(255).nullish(), + variable: z.string().max(255).nullish(), +}) + +/** + * AgentSoulEnvConfig + */ +export const zAgentSoulEnvConfig = z.object({ + secret_refs: z.array(zAgentSecretRefConfig).optional(), + variables: z.array(zAgentEnvVariableConfig).optional(), +}) + +/** + * AgentCliToolEnvConfig + */ +export const zAgentCliToolEnvConfig = z.object({ + secret_refs: z.array(zAgentSecretRefConfig).optional(), + variables: z.array(zAgentEnvVariableConfig).optional(), +}) + +/** + * AgentCliToolRiskLevel + * + * Risk marker for CLI tool bootstrap commands. + */ +export const zAgentCliToolRiskLevel = z.enum(['dangerous', 'safe', 'unknown']) + +/** + * AgentCliToolConfig + */ +export const zAgentCliToolConfig = z.object({ + approved: z.boolean().optional().default(false), + authorization_status: zAgentCliToolAuthorizationStatus.nullish(), + command: z.string().nullish(), + dangerous: z.boolean().optional().default(false), + dangerous_accepted: z.boolean().optional().default(false), + dangerous_acknowledged: z.boolean().optional().default(false), + dangerous_command: z.boolean().optional().default(false), + description: z.string().nullish(), + enabled: z.boolean().optional().default(true), + env: zAgentCliToolEnvConfig.optional(), + id: z.string().max(255).nullish(), + inferred_from: z.string().max(255).nullish(), + install: z.string().nullish(), + install_command: z.string().nullish(), + install_commands: z.array(z.string()).optional(), + invoke_metadata: z.record(z.string(), z.unknown()).optional(), + label: z.string().max(255).nullish(), + name: z.string().max(255).nullish(), + permission: zAgentPermissionConfig.nullish(), + pre_authorized: z.boolean().nullish(), + requires_confirmation: z.boolean().optional().default(false), + risk_accepted: z.boolean().optional().default(false), + risk_level: zAgentCliToolRiskLevel.nullish(), + setup_command: z.string().nullish(), + tool_name: z.string().max(255).nullish(), +}) + +/** + * AgentComposerSoulCandidatesResponse + */ +export const zAgentComposerSoulCandidatesResponse = z.object({ + cli_tools: z.array(zAgentCliToolConfig).optional(), + dify_tools: z.array(zAgentComposerDifyToolCandidateResponse).optional(), + human_contacts: z.array(zAgentHumanContactConfig).optional(), + knowledge_datasets: z.array(zAgentKnowledgeDatasetConfig).optional(), + skills_files: z + .array( + z.union([ + z + .object({ + kind: z.literal('skill'), + }) + .and(zAgentComposerSkillCandidateResponse), + z + .object({ + kind: z.literal('file'), + }) + .and(zAgentComposerFileCandidateResponse), + ]), + ) + .optional(), +}) + +/** + * AgentComposerCandidatesResponse + */ +export const zAgentComposerCandidatesResponse = z.object({ + allowed_node_job_candidates: zAgentComposerNodeJobCandidatesResponse.optional(), + allowed_soul_candidates: zAgentComposerSoulCandidatesResponse.optional(), + capabilities: zComposerCandidateCapabilities.optional(), + truncated: z.boolean().optional().default(false), + variant: zComposerVariant, +}) + +/** + * AgentModerationIOConfig + */ +export const zAgentModerationIoConfig = z.object({ + enabled: z.boolean().optional().default(false), + preset_response: z.string().nullish(), +}) + +/** + * AgentModerationProviderConfig + */ +export const zAgentModerationProviderConfig = z.object({ + api_based_extension_id: z.string().nullish(), + inputs_config: zAgentModerationIoConfig.nullish(), + keywords: z.string().nullish(), + outputs_config: zAgentModerationIoConfig.nullish(), +}) + +/** + * AgentSensitiveWordAvoidanceFeatureConfig + */ +export const zAgentSensitiveWordAvoidanceFeatureConfig = z.object({ + config: zAgentModerationProviderConfig.nullish(), + enabled: z.boolean().optional().default(false), + type: z.string().nullish(), +}) + +export const zJsonValue2 = z.unknown() + +/** + * HumanInputFormSubmissionData + */ +export const zHumanInputFormSubmissionData = z.object({ + action_id: z.string(), + action_text: z.string(), + node_id: z.string(), + node_title: z.string(), + rendered_content: z.string(), + submitted_data: z.record(z.string(), zJsonValue2).nullish(), +}) + +/** + * AgentModelResponseFormatConfig + */ +export const zAgentModelResponseFormatConfig = z.object({ + type: z.string().max(64).nullish(), +}) + +/** + * AgentSoulModelSettings + */ +export const zAgentSoulModelSettings = z.object({ + frequency_penalty: z.number().nullish(), + max_tokens: z.int().nullish(), + presence_penalty: z.number().nullish(), + response_format: zAgentModelResponseFormatConfig.nullish(), + stop: z.array(z.string()).nullish(), + temperature: z.number().nullish(), + top_p: z.number().nullish(), +}) + +/** + * AgentSoulModelConfig + * + * Stable model selection for Agent runtime without storing secret values. + */ +export const zAgentSoulModelConfig = z.object({ + credential_ref: zAgentSoulModelCredentialRef.nullish(), + model: z.string().min(1).max(255), + model_provider: z.string().min(1).max(255), + model_settings: zAgentSoulModelSettings.optional(), + plugin_id: z.string().min(1).max(255), +}) + +/** + * AgentSuggestedQuestionsAfterAnswerFeatureConfig + */ +export const zAgentSuggestedQuestionsAfterAnswerFeatureConfig = z.object({ + enabled: z.boolean().optional().default(false), + model: zAgentSoulModelConfig.nullish(), + prompt: z.string().nullish(), +}) + +/** + * AgentAppFeaturesPayload + * + * Presentation features configurable on an Agent App. + * + * All fields are optional; an omitted field is reset to its disabled/empty + * default (the config form sends the full desired feature state on save). + */ +export const zAgentAppFeaturesPayload = z.object({ + opening_statement: z.string().nullish(), + retriever_resource: zAgentFeatureToggleConfig.nullish(), + sensitive_word_avoidance: zAgentSensitiveWordAvoidanceFeatureConfig.nullish(), + speech_to_text: zAgentFeatureToggleConfig.nullish(), + suggested_questions: z.array(z.string()).nullish(), + suggested_questions_after_answer: zAgentSuggestedQuestionsAfterAnswerFeatureConfig.nullish(), + text_to_speech: zAgentTextToSpeechFeatureConfig.nullish(), +}) + +/** + * AgentSoulAppFeaturesConfig + */ +export const zAgentSoulAppFeaturesConfig = z.object({ + opening_statement: z.string().nullish(), + retriever_resource: zAgentFeatureToggleConfig.nullish(), + sensitive_word_avoidance: zAgentSensitiveWordAvoidanceFeatureConfig.nullish(), + speech_to_text: zAgentFeatureToggleConfig.nullish(), + suggested_questions: z.array(z.string()).nullish(), + suggested_questions_after_answer: zAgentSuggestedQuestionsAfterAnswerFeatureConfig.nullish(), + text_to_speech: zAgentTextToSpeechFeatureConfig.nullish(), +}) + +/** + * DeclaredOutputCheckConfig + * + * File-output content check via a model-based comparison against a benchmark file. + * + * Per PRD §OUTPUT 配置框, output check is **file-only** and optional. Stage 4 §4.3. + */ +export const zDeclaredOutputCheckConfig = z.object({ + benchmark_file_ref: zAgentFileRefConfig.nullish(), + enabled: z.boolean().optional().default(false), + model_ref: zAgentSoulModelConfig.nullish(), + prompt: z.string().nullish(), +}) + +/** + * AgentSoulDifyToolCredentialRef + * + * Reference to a stored Dify Plugin Tool credential. + * + * Secret values are resolved only at runtime. The legacy ``credential_id`` + * field is accepted by :class:`AgentSoulDifyToolConfig` and normalized here so + * old Agent tool payloads can be read while new payloads stay explicit. + */ +export const zAgentSoulDifyToolCredentialRef = z.object({ + id: z.string().max(255).nullish(), + provider: z.string().max(255).nullish(), + type: z.enum(['provider', 'tool']).optional().default('tool'), +}) + +/** + * AgentSoulDifyToolConfig + * + * One Dify Plugin Tool configured on Agent Soul. + * + * The API backend prepares this persisted product shape into + * ``DifyPluginToolConfig`` before sending a run request to Agent backend. + * ``provider_id`` keeps compatibility with existing Agent tool config payloads; + * new callers should send ``plugin_id`` + ``provider`` when available. + */ +export const zAgentSoulDifyToolConfig = z.object({ + credential_ref: zAgentSoulDifyToolCredentialRef.nullish(), + credential_type: z.enum(['api-key', 'oauth2', 'unauthorized']).optional().default('api-key'), + description: z.string().nullish(), + enabled: z.boolean().optional().default(true), + name: z.string().max(255).nullish(), + plugin_id: z.string().max(255).nullish(), + provider: z.string().max(255).nullish(), + provider_id: z.string().max(255).nullish(), + provider_type: z.string().optional().default('plugin'), + runtime_parameters: z + .record( + z.string(), + z + .union([ + z.string(), + z.int(), + z.number(), + z.boolean(), + z.array(z.string()), + z.array(z.int()), + z.array(z.number()), + z.array(z.boolean()), + ]) + .nullable(), + ) + .optional(), + tool_name: z.string().min(1).max(255).nullish(), +}) + +/** + * AgentSoulToolsConfig + */ +export const zAgentSoulToolsConfig = z.object({ + cli_tools: z.array(zAgentCliToolConfig).optional(), + dify_tools: z.array(zAgentSoulDifyToolConfig).optional(), +}) + +/** + * AgentSoulConfig + */ +export const zAgentSoulConfig = z.object({ + app_features: zAgentSoulAppFeaturesConfig.optional(), + app_variables: z.array(zAppVariableConfig).optional(), + env: zAgentSoulEnvConfig.optional(), + human: zAgentSoulHumanConfig.optional(), + knowledge: zAgentSoulKnowledgeConfig.optional(), + memory: zAgentSoulMemoryConfig.optional(), + misc_legacy: zAgentSoulAppFeaturesConfig.optional(), + model: zAgentSoulModelConfig.nullish(), + prompt: zAgentSoulPromptConfig.optional(), + sandbox: zAgentSoulSandboxConfig.optional(), + schema_version: z.int().optional().default(1), + skills_files: zAgentSoulSkillsFilesConfig.optional(), + tools: zAgentSoulToolsConfig.optional(), +}) + +/** + * AgentAppComposerResponse + */ +export const zAgentAppComposerResponse = z.object({ + active_config_snapshot: zAgentConfigSnapshotSummaryResponse, + agent: zAgentComposerAgentResponse, + agent_soul: zAgentSoulConfig, + save_options: z.array(zComposerSaveStrategy), + validation: zComposerValidationFindingsResponse.nullish(), + variant: z.literal('agent_app'), +}) + +/** + * AgentConfigSnapshotDetailResponse + */ +export const zAgentConfigSnapshotDetailResponse = z.object({ + agent_id: z.string().nullish(), + config_snapshot: zAgentSoulConfig, + created_at: z.int().nullish(), + created_by: z.string().nullish(), + id: z.string(), + revisions: z.array(zAgentConfigRevisionResponse).optional(), + summary: z.string().nullish(), + version: z.int(), + version_note: z.string().nullish(), +}) + +/** + * OutputErrorStrategy + * + * Per-output failure handling strategy. + * + * Mirrors ``graphon.ErrorStrategy`` but scoped to a single declared output of + * a Workflow Agent Node. The runtime applies the strategy after type check or + * output check fails and any configured retry attempts have been exhausted. + */ +export const zOutputErrorStrategy = z.enum(['default_value', 'fail_branch', 'stop']) + +/** + * DeclaredOutputRetryConfig + * + * Per-output retry configuration that mirrors ``graphon.RetryConfig`` shape. + */ +export const zDeclaredOutputRetryConfig = z.object({ + enabled: z.boolean().optional().default(false), + max_retries: z.int().gte(0).lte(10).optional().default(0), + retry_interval_ms: z.int().gte(0).lte(60000).optional().default(0), +}) + +/** + * DeclaredOutputFailureStrategy + * + * Per-output failure handling. + * + * A single strategy applies to both ``type_check`` and ``output_check`` failures + * (PRD does not distinguish them at the UX level). Stage 4 §4.4. + */ +export const zDeclaredOutputFailureStrategy = z.object({ + default_value: z.unknown().optional(), + on_failure: zOutputErrorStrategy.optional().default('stop'), + retry: zDeclaredOutputRetryConfig.optional(), +}) + +/** + * DeclaredOutputConfig + * + * One declared output of a Workflow Agent Node. + * + * Stage 4 normalizes the shape: ``check`` is singular (was ``checks: list`` in + * stage 3), and ``failure_strategy`` defaults to a populated value so runtime + * code can call ``output.failure_strategy.on_failure`` without None-guards. + */ +export const zDeclaredOutputConfig = z.object({ + array_item: zDeclaredArrayItem.nullish(), + check: zDeclaredOutputCheckConfig.nullish(), + children: z + .array( + z.object({ + array_item: z + .object({ + children: z.array(z.record(z.string(), z.unknown())).optional(), + description: z.string().nullish(), + type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(), + }) + .optional(), + children: z.array(z.record(z.string(), z.unknown())).optional(), + description: z.string().nullish(), + file: z.record(z.string(), z.unknown()).optional(), + name: z.string(), + required: z.boolean().optional(), + type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']), + }), + ) + .optional(), + description: z.string().nullish(), + failure_strategy: zDeclaredOutputFailureStrategy.optional(), + file: zDeclaredOutputFileConfig.nullish(), + id: z.string().nullish(), + name: z.string().min(1).max(255), + required: z.boolean().optional().default(true), + type: zDeclaredOutputType, +}) + +/** + * WorkflowNodeJobConfig + */ +export const zWorkflowNodeJobConfig = z.object({ + declared_outputs: z.array(zDeclaredOutputConfig).optional(), + human_contacts: z.array(zAgentHumanContactConfig).optional(), + metadata: zWorkflowNodeJobMetadata.optional(), + mode: zWorkflowNodeJobMode.optional().default('tell_agent_what_to_do'), + previous_node_output_refs: z.array(zWorkflowPreviousNodeOutputRef).optional(), + schema_version: z.int().optional().default(1), + workflow_prompt: z.string().optional().default(''), +}) + +/** + * ComposerSavePayload + */ +export const zComposerSavePayload = z.object({ + agent_soul: zAgentSoulConfig.nullish(), + binding: zComposerBindingPayload.nullish(), + client_revision_id: z.string().nullish(), + idempotency_key: z.string().nullish(), + new_agent_name: z.string().min(1).max(255).nullish(), + node_job: zWorkflowNodeJobConfig.nullish(), + save_strategy: zComposerSaveStrategy, + soul_lock: zComposerSoulLockPayload.optional(), + variant: zComposerVariant, + version_note: z.string().nullish(), +}) + +/** + * ButtonStyle + * + * Button styles for user actions. + */ +export const zButtonStyle = z.enum(['accent', 'default', 'ghost', 'primary']) + +/** + * UserActionConfig + * + * User action configuration. + */ +export const zUserActionConfig = z.object({ + button_style: zButtonStyle.optional().default('default'), + id: z.string().max(20), + title: z.string().max(100), +}) + +/** + * FileType + */ +export const zFileType = z.enum(['audio', 'custom', 'document', 'image', 'video']) + +/** + * FileTransferMethod + */ +export const zFileTransferMethod = z.enum([ + 'datasource_file', + 'local_file', + 'remote_url', + 'tool_file', +]) + +/** + * FileInputConfig + */ +export const zFileInputConfig = z.object({ + allowed_file_extensions: z.array(z.string()).optional(), + allowed_file_types: z.array(zFileType).optional(), + allowed_file_upload_methods: z.array(zFileTransferMethod).optional(), + output_variable_name: z.string(), + type: z.literal('file').optional().default('file'), +}) + +/** + * FileListInputConfig + */ +export const zFileListInputConfig = z.object({ + allowed_file_extensions: z.array(z.string()).optional(), + allowed_file_types: z.array(zFileType).optional(), + allowed_file_upload_methods: z.array(zFileTransferMethod).optional(), + number_limits: z.int().gte(0).optional().default(0), + output_variable_name: z.string(), + type: z.literal('file-list').optional().default('file-list'), +}) + +/** + * ValueSourceType + * + * ValueSourceType records whether the value comes from a static setting + * in form definiton, or a variable while the workflow is running. + */ +export const zValueSourceType = z.enum(['constant', 'variable']) + +/** + * StringSource + * + * Default configuration for form inputs. + */ +export const zStringSource = z.object({ + selector: z.array(z.string()).optional(), + type: zValueSourceType, + value: z.string().optional().default(''), +}) + +/** + * ParagraphInputConfig + * + * Form input definition. + */ +export const zParagraphInputConfig = z.object({ + default: zStringSource.nullish(), + output_variable_name: z.string(), + type: z.literal('paragraph').optional().default('paragraph'), +}) + +/** + * StringListSource + */ +export const zStringListSource = z.object({ + selector: z.array(z.string()).optional(), + type: zValueSourceType, + value: z.array(z.string()).optional(), +}) + +/** + * SelectInputConfig + */ +export const zSelectInputConfig = z.object({ + option_source: zStringListSource, + output_variable_name: z.string(), + type: z.literal('select').optional().default('select'), +}) + +export const zFormInputConfig = z.discriminatedUnion('type', [ + zParagraphInputConfig.extend({ type: z.literal('paragraph') }), + zSelectInputConfig.extend({ type: z.literal('select') }), + zFileInputConfig.extend({ type: z.literal('file') }), + zFileListInputConfig.extend({ type: z.literal('file-list') }), +]) + +/** + * HumanInputFormDefinition + */ +export const zHumanInputFormDefinition = z.object({ + actions: z.array(zUserActionConfig).optional(), + display_in_ui: z.boolean().optional().default(false), + expiration_time: z.int(), + form_content: z.string(), + form_id: z.string(), + form_token: z.string().nullish(), + inputs: z.array(zFormInputConfig).optional(), + node_id: z.string(), + node_title: z.string(), + resolved_default_values: z.record(z.string(), z.unknown()).optional(), +}) + +/** + * HumanInputContent + */ +export const zHumanInputContent = z.object({ + form_definition: zHumanInputFormDefinition.nullish(), + form_submission_data: zHumanInputFormSubmissionData.nullish(), + submitted: z.boolean(), + type: zExecutionContentType.optional().default('human_input'), + workflow_run_id: z.string(), +}) + +/** + * MessageDetailResponse + */ +export const zMessageDetailResponse = z.object({ + agent_thoughts: z.array(zAgentThought).optional(), + annotation: zConversationAnnotation.nullish(), + annotation_hit_history: zConversationAnnotationHitHistory.nullish(), + answer_tokens: z.int().nullish(), + conversation_id: z.string(), + created_at: z.int().nullish(), + error: z.string().nullish(), + extra_contents: z.array(zHumanInputContent).optional(), + feedbacks: z.array(zFeedback).optional(), + 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(), + parent_message_id: z.string().nullish(), + provider_response_latency: z.number().nullish(), + query: z.string(), + re_sign_file_url_answer: z.string(), + status: z.string(), + workflow_run_id: z.string().nullish(), +}) + +/** + * MessageInfiniteScrollPaginationResponse + */ +export const zMessageInfiniteScrollPaginationResponse = z.object({ + data: z.array(zMessageDetailResponse), + has_more: z.boolean(), + limit: z.int(), +}) + +/** + * AgentAppPartial + */ +export const zAgentAppPartialWritable = z.object({ + access_mode: z.string().nullish(), + active_config_is_published: z.boolean().optional().default(false), + app_id: z.string().nullish(), + author_name: z.string().nullish(), + bound_agent_id: z.string().nullish(), + create_user_name: z.string().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + description: z.string().nullish(), + has_draft_trigger: z.boolean().nullish(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.string().nullish(), + id: z.string(), + is_starred: z.boolean().optional().default(false), + max_active_requests: z.int().nullish(), + mode: z.string(), + model_config: zModelConfigPartial.nullish(), + name: z.string(), + published_reference_count: z.int().optional().default(0), + published_references: z.array(zAgentAppPublishedReferenceResponse).optional(), + role: z.string().nullish(), + tags: z.array(zTag).optional(), + updated_at: z.int().nullish(), + updated_by: z.string().nullish(), + use_icon_as_answer_icon: z.boolean().nullish(), + workflow: zWorkflowPartial.nullish(), +}) + +/** + * AgentAppPagination + */ +export const zAgentAppPaginationWritable = z.object({ + data: z.array(zAgentAppPartialWritable), + has_more: z.boolean(), + limit: z.int(), + page: z.int(), + total: z.int(), +}) + +/** + * Site + */ +export const zSiteWritable = z.object({ + chat_color_theme: z.string().nullish(), + chat_color_theme_inverted: z.boolean(), + copyright: z.string().nullish(), + custom_disclaimer: z.string().nullish(), + default_language: z.string(), + description: z.string().nullish(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.string().nullish(), + privacy_policy: z.string().nullish(), + show_workflow_steps: z.boolean(), + title: z.string(), + use_icon_as_answer_icon: z.boolean(), +}) + +/** + * AppDetailWithSite + */ +export const zAppDetailWithSiteWritable = 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(), + 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(), + id: z.string(), + max_active_requests: z.int().nullish(), + mode: z.string(), + model_config: zModelConfig.nullish(), + name: z.string(), + role: z.string().nullish(), + site: zSiteWritable.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(), +}) + +export const zGetAgentQuery = z.object({ + creator_ids: z.array(z.string()).optional(), + is_created_by_me: z.boolean().optional(), + limit: z.int().gte(1).lte(100).optional().default(20), + mode: z + .enum([ + 'advanced-chat', + 'agent', + 'agent-chat', + 'all', + 'channel', + 'chat', + 'completion', + 'workflow', + ]) + .optional() + .default('all'), + name: z.string().optional(), + page: z.int().gte(1).lte(99999).optional().default(1), + sort_by: z + .enum(['earliest_created', 'last_modified', 'recently_created']) + .optional() + .default('last_modified'), + tag_ids: z.array(z.string()).optional(), +}) + +/** + * Agent app list + */ +export const zGetAgentResponse = zAgentAppPagination + +export const zPostAgentBody = zAgentAppCreatePayload + +/** + * Agent app created successfully + */ +export const zPostAgentResponse = zAppDetailWithSite + +export const zGetAgentInviteOptionsQuery = z.object({ + app_id: z.string().optional(), + keyword: z.string().optional(), + limit: z.int().gte(1).lte(100).optional().default(20), + page: z.int().gte(1).optional().default(1), +}) + +/** + * Agent invite options + */ +export const zGetAgentInviteOptionsResponse = zAgentInviteOptionsResponse + +export const zDeleteAgentByAgentIdPath = z.object({ + agent_id: z.string(), +}) + +/** + * Agent app deleted successfully + */ +export const zDeleteAgentByAgentIdResponse = z.void() + +export const zGetAgentByAgentIdPath = z.object({ + agent_id: z.string(), +}) + +/** + * Agent app detail + */ +export const zGetAgentByAgentIdResponse = zAppDetailWithSite + +export const zPutAgentByAgentIdBody = zAgentAppUpdatePayload + +export const zPutAgentByAgentIdPath = z.object({ + agent_id: z.string(), +}) + +/** + * Agent app updated successfully + */ +export const zPutAgentByAgentIdResponse = zAppDetailWithSite + +export const zGetAgentByAgentIdChatMessagesPath = z.object({ + agent_id: z.string(), +}) + +export const zGetAgentByAgentIdChatMessagesQuery = z.object({ + conversation_id: z.string(), + first_id: z.string().optional(), + limit: z.int().gte(1).lte(100).optional().default(20), +}) + +/** + * Success + */ +export const zGetAgentByAgentIdChatMessagesResponse = zMessageInfiniteScrollPaginationResponse + +export const zGetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsPath = z.object({ + agent_id: z.string(), + message_id: z.string(), +}) + +/** + * Suggested questions retrieved successfully + */ +export const zGetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsResponse + = zSuggestedQuestionsResponse + +export const zPostAgentByAgentIdChatMessagesByTaskIdStopPath = z.object({ + agent_id: z.string(), + task_id: z.string(), +}) + +/** + * Task stopped successfully + */ +export const zPostAgentByAgentIdChatMessagesByTaskIdStopResponse = zSimpleResultResponse + +export const zGetAgentByAgentIdComposerPath = z.object({ + agent_id: z.string(), +}) + +/** + * Agent app composer state + */ +export const zGetAgentByAgentIdComposerResponse = zAgentAppComposerResponse + +export const zPutAgentByAgentIdComposerBody = zComposerSavePayload + +export const zPutAgentByAgentIdComposerPath = z.object({ + agent_id: z.string(), +}) + +/** + * Agent app composer saved + */ +export const zPutAgentByAgentIdComposerResponse = zAgentAppComposerResponse + +export const zGetAgentByAgentIdComposerCandidatesPath = z.object({ + agent_id: z.string(), +}) + +/** + * Agent app composer candidates + */ +export const zGetAgentByAgentIdComposerCandidatesResponse = zAgentComposerCandidatesResponse + +export const zPostAgentByAgentIdComposerValidateBody = zComposerSavePayload + +export const zPostAgentByAgentIdComposerValidatePath = z.object({ + agent_id: z.string(), +}) + +/** + * Agent app composer validation result + */ +export const zPostAgentByAgentIdComposerValidateResponse = zAgentComposerValidateResponse + +export const zGetAgentByAgentIdDriveFilesPath = z.object({ + agent_id: z.string(), +}) + +export const zGetAgentByAgentIdDriveFilesQuery = z.object({ + prefix: z.string().optional().default(''), +}) + +/** + * Drive entries + */ +export const zGetAgentByAgentIdDriveFilesResponse = zAgentDriveListResponse + +export const zGetAgentByAgentIdDriveFilesDownloadPath = z.object({ + agent_id: z.string(), +}) + +export const zGetAgentByAgentIdDriveFilesDownloadQuery = z.object({ + key: z.string().min(1), +}) + +/** + * Signed URL + */ +export const zGetAgentByAgentIdDriveFilesDownloadResponse = zAgentDriveDownloadResponse + +export const zGetAgentByAgentIdDriveFilesPreviewPath = z.object({ + agent_id: z.string(), +}) + +export const zGetAgentByAgentIdDriveFilesPreviewQuery = z.object({ + key: z.string().min(1), +}) + +/** + * Preview + */ +export const zGetAgentByAgentIdDriveFilesPreviewResponse = zAgentDrivePreviewResponse + +export const zPostAgentByAgentIdFeaturesBody = zAgentAppFeaturesPayload + +export const zPostAgentByAgentIdFeaturesPath = z.object({ + agent_id: z.string(), +}) + +/** + * Features updated successfully + */ +export const zPostAgentByAgentIdFeaturesResponse = zSimpleResultResponse + +export const zPostAgentByAgentIdFeedbacksBody = zMessageFeedbackPayload + +export const zPostAgentByAgentIdFeedbacksPath = z.object({ + agent_id: z.string(), +}) + +/** + * Feedback updated successfully + */ +export const zPostAgentByAgentIdFeedbacksResponse = zSimpleResultResponse + +export const zDeleteAgentByAgentIdFilesPath = z.object({ + agent_id: z.string(), +}) + +export const zDeleteAgentByAgentIdFilesQuery = z.object({ + key: z.string().min(1), +}) + +/** + * File removed + */ +export const zDeleteAgentByAgentIdFilesResponse = zAgentDriveDeleteResponse + +export const zPostAgentByAgentIdFilesBody = zAgentDriveFilePayload + +export const zPostAgentByAgentIdFilesPath = z.object({ + agent_id: z.string(), +}) + +/** + * File committed into the agent drive + */ +export const zPostAgentByAgentIdFilesResponse = zAgentDriveFileCommitResponse + +export const zGetAgentByAgentIdLogsPath = z.object({ + agent_id: z.string(), +}) + +export const zGetAgentByAgentIdLogsQuery = z.object({ + end: z.string().optional(), + keyword: z.string().optional(), + limit: z.int().gte(1).lte(100).optional().default(20), + page: z.int().gte(1).optional().default(1), + source: z.string().optional(), + start: z.string().optional(), + status: z.string().optional(), +}) + +/** + * Agent logs + */ +export const zGetAgentByAgentIdLogsResponse = zAgentLogListResponse + +export const zGetAgentByAgentIdMessagesByMessageIdPath = z.object({ + agent_id: z.string(), + message_id: z.string(), +}) + +/** + * Message retrieved successfully + */ +export const zGetAgentByAgentIdMessagesByMessageIdResponse = zMessageDetailResponse + +export const zGetAgentByAgentIdReferencingWorkflowsPath = z.object({ + agent_id: z.string(), +}) + +/** + * Referencing workflows listed successfully + */ +export const zGetAgentByAgentIdReferencingWorkflowsResponse = zAgentReferencingWorkflowsResponse + +export const zGetAgentByAgentIdSandboxFilesPath = z.object({ + agent_id: z.string(), +}) + +export const zGetAgentByAgentIdSandboxFilesQuery = z.object({ + conversation_id: z.string().min(1), + path: z.string().optional().default('.'), +}) + +/** + * Listing returned + */ +export const zGetAgentByAgentIdSandboxFilesResponse = zSandboxListResponse + +export const zGetAgentByAgentIdSandboxFilesReadPath = z.object({ + agent_id: z.string(), +}) + +export const zGetAgentByAgentIdSandboxFilesReadQuery = z.object({ + conversation_id: z.string().min(1), + path: z.string().min(1), +}) + +/** + * Preview returned + */ +export const zGetAgentByAgentIdSandboxFilesReadResponse = zSandboxReadResponse + +export const zPostAgentByAgentIdSandboxFilesUploadBody = zAgentSandboxUploadPayload + +export const zPostAgentByAgentIdSandboxFilesUploadPath = z.object({ + agent_id: z.string(), +}) + +/** + * Uploaded + */ +export const zPostAgentByAgentIdSandboxFilesUploadResponse = zSandboxUploadResponse + +export const zPostAgentByAgentIdSkillsStandardizePath = z.object({ + agent_id: z.string(), +}) + +/** + * Skill standardized into drive + */ +export const zPostAgentByAgentIdSkillsStandardizeResponse = zAgentSkillStandardizeResponse + +export const zPostAgentByAgentIdSkillsUploadPath = z.object({ + agent_id: z.string(), +}) + +/** + * Skill validated + */ +export const zPostAgentByAgentIdSkillsUploadResponse = zAgentSkillUploadResponse + +export const zDeleteAgentByAgentIdSkillsBySlugPath = z.object({ + agent_id: z.string(), + slug: z.string(), +}) + +/** + * Skill removed + */ +export const zDeleteAgentByAgentIdSkillsBySlugResponse = zAgentDriveDeleteResponse + +export const zPostAgentByAgentIdSkillsBySlugInferToolsPath = z.object({ + agent_id: z.string(), + slug: z.string(), +}) + +/** + * Inference result (draft suggestions, nothing persisted) + */ +export const zPostAgentByAgentIdSkillsBySlugInferToolsResponse = zSkillToolInferenceResult + +export const zGetAgentByAgentIdStatisticsSummaryPath = z.object({ + agent_id: z.string(), +}) + +export const zGetAgentByAgentIdStatisticsSummaryQuery = z.object({ + end: z.string().optional(), + source: z.string().optional(), + start: z.string().optional(), +}) + +/** + * Agent monitoring summary and chart data + */ +export const zGetAgentByAgentIdStatisticsSummaryResponse = zAgentStatisticSummaryEnvelopeResponse + +export const zGetAgentByAgentIdVersionsPath = z.object({ + agent_id: z.string(), +}) + +/** + * Agent versions + */ +export const zGetAgentByAgentIdVersionsResponse = zAgentConfigSnapshotListResponse + +export const zGetAgentByAgentIdVersionsByVersionIdPath = z.object({ + agent_id: z.string(), + version_id: z.string(), +}) + +/** + * Agent version detail + */ +export const zGetAgentByAgentIdVersionsByVersionIdResponse = zAgentConfigSnapshotDetailResponse diff --git a/packages/contracts/generated/api/console/agents/orpc.gen.ts b/packages/contracts/generated/api/console/agents/orpc.gen.ts deleted file mode 100644 index 25c1ee7cc73..00000000000 --- a/packages/contracts/generated/api/console/agents/orpc.gen.ts +++ /dev/null @@ -1,145 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import { oc } from '@orpc/contract' -import * as z from 'zod' - -import { - zDeleteAgentsByAgentIdPath, - zDeleteAgentsByAgentIdResponse, - zGetAgentsByAgentIdPath, - zGetAgentsByAgentIdResponse, - zGetAgentsByAgentIdVersionsByVersionIdPath, - zGetAgentsByAgentIdVersionsByVersionIdResponse, - zGetAgentsByAgentIdVersionsPath, - zGetAgentsByAgentIdVersionsResponse, - zGetAgentsInviteOptionsQuery, - zGetAgentsInviteOptionsResponse, - zGetAgentsQuery, - zGetAgentsResponse, - zPatchAgentsByAgentIdBody, - zPatchAgentsByAgentIdPath, - zPatchAgentsByAgentIdResponse, - zPostAgentsBody, - zPostAgentsResponse, -} from './zod.gen' - -export const get = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'getAgentsInviteOptions', - path: '/agents/invite-options', - tags: ['console'], - }) - .input(z.object({ query: zGetAgentsInviteOptionsQuery.optional() })) - .output(zGetAgentsInviteOptionsResponse) - -export const inviteOptions = { - get, -} - -export const get2 = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'getAgentsByAgentIdVersionsByVersionId', - path: '/agents/{agent_id}/versions/{version_id}', - tags: ['console'], - }) - .input(z.object({ params: zGetAgentsByAgentIdVersionsByVersionIdPath })) - .output(zGetAgentsByAgentIdVersionsByVersionIdResponse) - -export const byVersionId = { - get: get2, -} - -export const get3 = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'getAgentsByAgentIdVersions', - path: '/agents/{agent_id}/versions', - tags: ['console'], - }) - .input(z.object({ params: zGetAgentsByAgentIdVersionsPath })) - .output(zGetAgentsByAgentIdVersionsResponse) - -export const versions = { - get: get3, - byVersionId, -} - -export const delete_ = oc - .route({ - inputStructure: 'detailed', - method: 'DELETE', - operationId: 'deleteAgentsByAgentId', - path: '/agents/{agent_id}', - successStatus: 204, - tags: ['console'], - }) - .input(z.object({ params: zDeleteAgentsByAgentIdPath })) - .output(zDeleteAgentsByAgentIdResponse) - -export const get4 = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'getAgentsByAgentId', - path: '/agents/{agent_id}', - tags: ['console'], - }) - .input(z.object({ params: zGetAgentsByAgentIdPath })) - .output(zGetAgentsByAgentIdResponse) - -export const patch = oc - .route({ - inputStructure: 'detailed', - method: 'PATCH', - operationId: 'patchAgentsByAgentId', - path: '/agents/{agent_id}', - tags: ['console'], - }) - .input(z.object({ body: zPatchAgentsByAgentIdBody, params: zPatchAgentsByAgentIdPath })) - .output(zPatchAgentsByAgentIdResponse) - -export const byAgentId = { - delete: delete_, - get: get4, - patch, - versions, -} - -export const get5 = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'getAgents', - path: '/agents', - tags: ['console'], - }) - .input(z.object({ query: zGetAgentsQuery.optional() })) - .output(zGetAgentsResponse) - -export const post = oc - .route({ - inputStructure: 'detailed', - method: 'POST', - operationId: 'postAgents', - path: '/agents', - successStatus: 201, - tags: ['console'], - }) - .input(z.object({ body: zPostAgentsBody })) - .output(zPostAgentsResponse) - -export const agents = { - get: get5, - post, - inviteOptions, - byAgentId, -} - -export const contract = { - agents, -} diff --git a/packages/contracts/generated/api/console/agents/types.gen.ts b/packages/contracts/generated/api/console/agents/types.gen.ts deleted file mode 100644 index dc2b712fe54..00000000000 --- a/packages/contracts/generated/api/console/agents/types.gen.ts +++ /dev/null @@ -1,594 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -export type ClientOptions = { - baseUrl: `${string}://${string}/console/api` | (string & {}) -} - -export type AgentRosterListResponse = { - data: Array - has_more: boolean - limit: number - page: number - total: number -} - -export type RosterAgentCreatePayload = { - agent_soul?: AgentSoulConfig - description?: string - icon?: string | null - icon_background?: string | null - icon_type?: AgentIconType - name: string - version_note?: string | null -} - -export type AgentRosterResponse = { - active_config_snapshot?: AgentConfigSnapshotSummaryResponse - active_config_snapshot_id?: string | null - agent_kind: AgentKind - app_id?: string | null - archived_at?: number | null - archived_by?: string | null - created_at?: number | null - created_by?: string | null - description: string - icon?: string | null - icon_background?: string | null - icon_type?: AgentIconType - id: string - name: string - scope: AgentScope - source: AgentSource - status: AgentStatus - updated_at?: number | null - updated_by?: string | null - workflow_id?: string | null - workflow_node_id?: string | null -} - -export type AgentInviteOptionsResponse = { - data: Array - has_more: boolean - limit: number - page: number - total: number -} - -export type RosterAgentUpdatePayload = { - description?: string | null - icon?: string | null - icon_background?: string | null - icon_type?: AgentIconType - name?: string | null -} - -export type AgentConfigSnapshotListResponse = { - data: Array -} - -export type AgentConfigSnapshotDetailResponse = { - agent_id?: string | null - config_snapshot: AgentSoulConfig - created_at?: number | null - created_by?: string | null - id: string - revisions?: Array - summary?: string | null - version: number - version_note?: string | null -} - -export type AgentSoulConfig = { - app_features?: AgentSoulAppFeaturesConfig - app_variables?: Array - env?: AgentSoulEnvConfig - human?: AgentSoulHumanConfig - knowledge?: AgentSoulKnowledgeConfig - memory?: AgentSoulMemoryConfig - misc_legacy?: AgentSoulAppFeaturesConfig - model?: AgentSoulModelConfig - prompt?: AgentSoulPromptConfig - sandbox?: AgentSoulSandboxConfig - schema_version?: number - skills_files?: AgentSoulSkillsFilesConfig - tools?: AgentSoulToolsConfig -} - -export type AgentIconType = 'emoji' | 'image' | 'link' - -export type AgentConfigSnapshotSummaryResponse = { - agent_id?: string | null - created_at?: number | null - created_by?: string | null - id: string - summary?: string | null - version: number - version_note?: string | null -} - -export type AgentKind = 'dify_agent' - -export type AgentScope = 'roster' | 'workflow_only' - -export type AgentSource = 'agent_app' | 'imported' | 'system' | 'workflow' - -export type AgentStatus = 'active' | 'archived' - -export type AgentInviteOptionResponse = { - active_config_snapshot?: AgentConfigSnapshotSummaryResponse - active_config_snapshot_id?: string | null - agent_kind: AgentKind - app_id?: string | null - archived_at?: number | null - archived_by?: string | null - created_at?: number | null - created_by?: string | null - description: string - existing_node_ids?: Array - icon?: string | null - icon_background?: string | null - icon_type?: AgentIconType - id: string - in_current_workflow_count?: number - is_in_current_workflow?: boolean - name: string - scope: AgentScope - source: AgentSource - status: AgentStatus - updated_at?: number | null - updated_by?: string | null - workflow_id?: string | null - workflow_node_id?: string | null -} - -export type AgentConfigRevisionResponse = { - created_at?: number | null - created_by?: string | null - current_snapshot_id: string - id: string - operation: AgentConfigRevisionOperation - previous_snapshot_id?: string | null - revision: number - summary?: string | null - version_note?: string | null -} - -export type AgentSoulAppFeaturesConfig = { - opening_statement?: string | null - retriever_resource?: AgentFeatureToggleConfig - sensitive_word_avoidance?: AgentSensitiveWordAvoidanceFeatureConfig - speech_to_text?: AgentFeatureToggleConfig - suggested_questions?: Array | null - suggested_questions_after_answer?: AgentSuggestedQuestionsAfterAnswerFeatureConfig - text_to_speech?: AgentTextToSpeechFeatureConfig - [key: string]: unknown -} - -export type AppVariableConfig = { - default?: unknown - name: string - required?: boolean - type: string -} - -export type AgentSoulEnvConfig = { - secret_refs?: Array - variables?: Array -} - -export type AgentSoulHumanConfig = { - contacts?: Array - tools?: Array -} - -export type AgentSoulKnowledgeConfig = { - datasets?: Array - query_config?: AgentKnowledgeQueryConfig - query_mode?: AgentKnowledgeQueryMode -} - -export type AgentSoulMemoryConfig = { - artifacts?: Array - budget?: string | null - scope?: string | null -} - -export type AgentSoulModelConfig = { - credential_ref?: AgentSoulModelCredentialRef - model: string - model_provider: string - model_settings?: AgentSoulModelSettings - plugin_id: string -} - -export type AgentSoulPromptConfig = { - system_prompt?: string -} - -export type AgentSoulSandboxConfig = { - config?: AgentSandboxProviderConfig - provider?: string | null -} - -export type AgentSoulSkillsFilesConfig = { - files?: Array - skills?: Array -} - -export type AgentSoulToolsConfig = { - cli_tools?: Array - dify_tools?: Array -} - -export type AgentConfigRevisionOperation - = | 'create_version' - | 'save_current_version' - | 'save_new_agent' - | 'save_new_version' - | 'save_to_roster' - -export type AgentFeatureToggleConfig = { - enabled?: boolean - [key: string]: unknown -} - -export type AgentSensitiveWordAvoidanceFeatureConfig = { - config?: AgentModerationProviderConfig - enabled?: boolean - type?: string | null - [key: string]: unknown -} - -export type AgentSuggestedQuestionsAfterAnswerFeatureConfig = { - enabled?: boolean - model?: AgentSoulModelConfig - prompt?: string | null - [key: string]: unknown -} - -export type AgentTextToSpeechFeatureConfig = { - autoPlay?: string | null - enabled?: boolean - language?: string | null - voice?: string | null - [key: string]: unknown -} - -export type AgentSecretRefConfig = { - credential_id?: string | null - env_name?: string | null - id?: string | null - key?: string | null - name?: string | null - permission?: AgentPermissionConfig - permission_status?: string | null - provider?: string | null - provider_credential_id?: string | null - ref?: string | null - type?: string | null - variable?: string | null - [key: string]: unknown -} - -export type AgentEnvVariableConfig = { - default?: unknown - env_name?: string | null - key?: string | null - name?: string | null - required?: boolean - type?: string | null - value?: unknown - variable?: string | null - [key: string]: unknown -} - -export type AgentHumanContactConfig = { - channel?: string | null - contact_id?: string | null - contact_method?: string | null - email?: string | null - human_id?: string | null - id?: string | null - method?: string | null - name?: string | null - tenant_id?: string | null - [key: string]: unknown -} - -export type AgentHumanToolConfig = { - description?: string | null - enabled?: boolean - name?: string | null - [key: string]: unknown -} - -export type AgentKnowledgeDatasetConfig = { - description?: string | null - id?: string | null - name?: string | null - [key: string]: unknown -} - -export type AgentKnowledgeQueryConfig = { - query?: string | null - score_threshold?: number | null - score_threshold_enabled?: boolean | null - top_k?: number | null - [key: string]: unknown -} - -export type AgentKnowledgeQueryMode = 'generated_query' | 'user_query' - -export type AgentMemoryArtifactConfig = { - id?: string | null - name?: string | null - type?: string | null - url?: string | null - [key: string]: unknown -} - -export type AgentSoulModelCredentialRef = { - id?: string | null - provider?: string | null - type: string -} - -export type AgentSoulModelSettings = { - frequency_penalty?: number | null - max_tokens?: number | null - presence_penalty?: number | null - response_format?: AgentModelResponseFormatConfig - stop?: Array | null - temperature?: number | null - top_p?: number | null -} - -export type AgentSandboxProviderConfig = { - cpu?: number | null - env?: Array - image?: string | null - working_dir?: string | null - [key: string]: unknown -} - -export type AgentFileRefConfig = { - file_id?: string | null - id?: string | null - name?: string | null - reference?: string | null - remote_url?: string | null - tenant_id?: string | null - transfer_method?: string | null - type?: string | null - upload_file_id?: string | null - url?: string | null - [key: string]: unknown -} - -export type AgentSkillRefConfig = { - description?: string | null - file_id?: string | null - id?: string | null - name?: string | null - path?: string | null - [key: string]: unknown -} - -export type AgentCliToolConfig = { - approved?: boolean - authorization_status?: AgentCliToolAuthorizationStatus - command?: string | null - dangerous?: boolean - dangerous_accepted?: boolean - dangerous_acknowledged?: boolean - dangerous_command?: boolean - description?: string | null - enabled?: boolean - install?: string | null - install_command?: string | null - install_commands?: Array - invoke_metadata?: { - [key: string]: unknown - } - label?: string | null - name?: string | null - permission?: AgentPermissionConfig - pre_authorized?: boolean | null - requires_confirmation?: boolean - risk_accepted?: boolean - risk_level?: AgentCliToolRiskLevel - setup_command?: string | null - tool_name?: string | null - [key: string]: unknown -} - -export type AgentSoulDifyToolConfig = { - credential_ref?: AgentSoulDifyToolCredentialRef - credential_type?: 'api-key' | 'oauth2' | 'unauthorized' - description?: string | null - enabled?: boolean - name?: string | null - plugin_id?: string | null - provider?: string | null - provider_id?: string | null - provider_type?: string - runtime_parameters?: { - [key: string]: unknown - } - tool_name: string -} - -export type AgentModerationProviderConfig = { - api_based_extension_id?: string | null - inputs_config?: AgentModerationIoConfig - keywords?: string | null - outputs_config?: AgentModerationIoConfig - [key: string]: unknown -} - -export type AgentPermissionConfig = { - allowed?: boolean | null - state?: string | null - status?: string | null -} - -export type AgentModelResponseFormatConfig = { - type?: string | null - [key: string]: unknown -} - -export type AgentCliToolAuthorizationStatus - = | 'allowed' - | 'authorized' - | 'denied' - | 'forbidden' - | 'not_required' - | 'pending' - | 'pre_authorized' - | 'unauthorized' - -export type AgentCliToolRiskLevel = 'dangerous' | 'safe' | 'unknown' - -export type AgentSoulDifyToolCredentialRef = { - id?: string | null - provider?: string | null - type?: 'provider' | 'tool' -} - -export type AgentModerationIoConfig = { - enabled?: boolean - preset_response?: string | null - [key: string]: unknown -} - -export type GetAgentsData = { - body?: never - path?: never - query?: { - keyword?: string - limit?: number - page?: number - } - url: '/agents' -} - -export type GetAgentsResponses = { - 200: AgentRosterListResponse -} - -export type GetAgentsResponse = GetAgentsResponses[keyof GetAgentsResponses] - -export type PostAgentsData = { - body: RosterAgentCreatePayload - path?: never - query?: never - url: '/agents' -} - -export type PostAgentsResponses = { - 201: AgentRosterResponse -} - -export type PostAgentsResponse = PostAgentsResponses[keyof PostAgentsResponses] - -export type GetAgentsInviteOptionsData = { - body?: never - path?: never - query?: { - app_id?: string - keyword?: string - limit?: number - page?: number - } - url: '/agents/invite-options' -} - -export type GetAgentsInviteOptionsResponses = { - 200: AgentInviteOptionsResponse -} - -export type GetAgentsInviteOptionsResponse - = GetAgentsInviteOptionsResponses[keyof GetAgentsInviteOptionsResponses] - -export type DeleteAgentsByAgentIdData = { - body?: never - path: { - agent_id: string - } - query?: never - url: '/agents/{agent_id}' -} - -export type DeleteAgentsByAgentIdResponses = { - 204: { - [key: string]: never - } -} - -export type DeleteAgentsByAgentIdResponse - = DeleteAgentsByAgentIdResponses[keyof DeleteAgentsByAgentIdResponses] - -export type GetAgentsByAgentIdData = { - body?: never - path: { - agent_id: string - } - query?: never - url: '/agents/{agent_id}' -} - -export type GetAgentsByAgentIdResponses = { - 200: AgentRosterResponse -} - -export type GetAgentsByAgentIdResponse - = GetAgentsByAgentIdResponses[keyof GetAgentsByAgentIdResponses] - -export type PatchAgentsByAgentIdData = { - body: RosterAgentUpdatePayload - path: { - agent_id: string - } - query?: never - url: '/agents/{agent_id}' -} - -export type PatchAgentsByAgentIdResponses = { - 200: AgentRosterResponse -} - -export type PatchAgentsByAgentIdResponse - = PatchAgentsByAgentIdResponses[keyof PatchAgentsByAgentIdResponses] - -export type GetAgentsByAgentIdVersionsData = { - body?: never - path: { - agent_id: string - } - query?: never - url: '/agents/{agent_id}/versions' -} - -export type GetAgentsByAgentIdVersionsResponses = { - 200: AgentConfigSnapshotListResponse -} - -export type GetAgentsByAgentIdVersionsResponse - = GetAgentsByAgentIdVersionsResponses[keyof GetAgentsByAgentIdVersionsResponses] - -export type GetAgentsByAgentIdVersionsByVersionIdData = { - body?: never - path: { - agent_id: string - version_id: string - } - query?: never - url: '/agents/{agent_id}/versions/{version_id}' -} - -export type GetAgentsByAgentIdVersionsByVersionIdResponses = { - 200: AgentConfigSnapshotDetailResponse -} - -export type GetAgentsByAgentIdVersionsByVersionIdResponse - = GetAgentsByAgentIdVersionsByVersionIdResponses[keyof GetAgentsByAgentIdVersionsByVersionIdResponses] diff --git a/packages/contracts/generated/api/console/agents/zod.gen.ts b/packages/contracts/generated/api/console/agents/zod.gen.ts deleted file mode 100644 index b777fd35b9b..00000000000 --- a/packages/contracts/generated/api/console/agents/zod.gen.ts +++ /dev/null @@ -1,719 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import * as z from 'zod' - -/** - * AgentIconType - * - * Supported icon storage formats for Agent roster entries. - */ -export const zAgentIconType = z.enum(['emoji', 'image', 'link']) - -/** - * RosterAgentUpdatePayload - */ -export const zRosterAgentUpdatePayload = z.object({ - description: z.string().nullish(), - icon: z.string().max(255).nullish(), - icon_background: z.string().max(255).nullish(), - icon_type: zAgentIconType.optional(), - name: z.string().min(1).max(255).nullish(), -}) - -/** - * AgentConfigSnapshotSummaryResponse - */ -export const zAgentConfigSnapshotSummaryResponse = z.object({ - agent_id: z.string().nullish(), - created_at: z.int().nullish(), - created_by: z.string().nullish(), - id: z.string(), - summary: z.string().nullish(), - version: z.int(), - version_note: z.string().nullish(), -}) - -/** - * AgentConfigSnapshotListResponse - */ -export const zAgentConfigSnapshotListResponse = z.object({ - data: z.array(zAgentConfigSnapshotSummaryResponse), -}) - -/** - * AgentKind - * - * Agent implementation family. - * - * This leaves room for future non-Dify agent implementations while keeping - * the current roster/workflow APIs scoped to Dify Agent. - */ -export const zAgentKind = z.enum(['dify_agent']) - -/** - * AgentScope - * - * Visibility and lifecycle scope of an Agent record. - */ -export const zAgentScope = z.enum(['roster', 'workflow_only']) - -/** - * AgentSource - * - * Origin that created or imported the Agent. - */ -export const zAgentSource = z.enum(['agent_app', 'imported', 'system', 'workflow']) - -/** - * AgentStatus - * - * Soft lifecycle state for Agent records. - */ -export const zAgentStatus = z.enum(['active', 'archived']) - -/** - * AgentRosterResponse - */ -export const zAgentRosterResponse = z.object({ - active_config_snapshot: zAgentConfigSnapshotSummaryResponse.optional(), - active_config_snapshot_id: z.string().nullish(), - agent_kind: zAgentKind, - app_id: z.string().nullish(), - archived_at: z.int().nullish(), - archived_by: z.string().nullish(), - created_at: z.int().nullish(), - created_by: z.string().nullish(), - description: z.string(), - icon: z.string().nullish(), - icon_background: z.string().nullish(), - icon_type: zAgentIconType.optional(), - id: z.string(), - name: z.string(), - scope: zAgentScope, - source: zAgentSource, - status: zAgentStatus, - updated_at: z.int().nullish(), - updated_by: z.string().nullish(), - workflow_id: z.string().nullish(), - workflow_node_id: z.string().nullish(), -}) - -/** - * AgentRosterListResponse - */ -export const zAgentRosterListResponse = z.object({ - data: z.array(zAgentRosterResponse), - has_more: z.boolean(), - limit: z.int(), - page: z.int(), - total: z.int(), -}) - -/** - * AgentInviteOptionResponse - */ -export const zAgentInviteOptionResponse = z.object({ - active_config_snapshot: zAgentConfigSnapshotSummaryResponse.optional(), - active_config_snapshot_id: z.string().nullish(), - agent_kind: zAgentKind, - app_id: z.string().nullish(), - archived_at: z.int().nullish(), - archived_by: z.string().nullish(), - created_at: z.int().nullish(), - created_by: z.string().nullish(), - description: z.string(), - existing_node_ids: z.array(z.string()).optional(), - icon: z.string().nullish(), - icon_background: z.string().nullish(), - icon_type: zAgentIconType.optional(), - id: z.string(), - in_current_workflow_count: z.int().optional().default(0), - is_in_current_workflow: z.boolean().optional().default(false), - name: z.string(), - scope: zAgentScope, - source: zAgentSource, - status: zAgentStatus, - updated_at: z.int().nullish(), - updated_by: z.string().nullish(), - workflow_id: z.string().nullish(), - workflow_node_id: z.string().nullish(), -}) - -/** - * AgentInviteOptionsResponse - */ -export const zAgentInviteOptionsResponse = z.object({ - data: z.array(zAgentInviteOptionResponse), - has_more: z.boolean(), - limit: z.int(), - page: z.int(), - total: z.int(), -}) - -/** - * AppVariableConfig - */ -export const zAppVariableConfig = z.object({ - default: z.unknown().optional(), - name: z.string().min(1).max(255), - required: z.boolean().optional().default(false), - type: z.string().min(1).max(64), -}) - -/** - * AgentSoulPromptConfig - */ -export const zAgentSoulPromptConfig = z.object({ - system_prompt: z.string().optional().default(''), -}) - -/** - * AgentConfigRevisionOperation - * - * Audit operation recorded for Agent Soul version/revision changes. - */ -export const zAgentConfigRevisionOperation = z.enum([ - 'create_version', - 'save_current_version', - 'save_new_agent', - 'save_new_version', - 'save_to_roster', -]) - -/** - * AgentConfigRevisionResponse - */ -export const zAgentConfigRevisionResponse = z.object({ - created_at: z.int().nullish(), - created_by: z.string().nullish(), - current_snapshot_id: z.string(), - id: z.string(), - operation: zAgentConfigRevisionOperation, - previous_snapshot_id: z.string().nullish(), - revision: z.int(), - summary: z.string().nullish(), - version_note: z.string().nullish(), -}) - -/** - * AgentFeatureToggleConfig - */ -export const zAgentFeatureToggleConfig = z.object({ - enabled: z.boolean().optional().default(false), -}) - -/** - * AgentTextToSpeechFeatureConfig - */ -export const zAgentTextToSpeechFeatureConfig = z.object({ - autoPlay: z.string().nullish(), - enabled: z.boolean().optional().default(false), - language: z.string().nullish(), - voice: z.string().nullish(), -}) - -/** - * AgentEnvVariableConfig - */ -export const zAgentEnvVariableConfig = z.object({ - default: z.unknown().optional(), - env_name: z.string().max(255).nullish(), - key: z.string().max(255).nullish(), - name: z.string().max(255).nullish(), - required: z.boolean().optional().default(false), - type: z.string().max(64).nullish(), - value: z.unknown().optional(), - variable: z.string().max(255).nullish(), -}) - -/** - * AgentHumanContactConfig - */ -export const zAgentHumanContactConfig = z.object({ - channel: z.string().max(64).nullish(), - contact_id: z.string().max(255).nullish(), - contact_method: z.string().max(64).nullish(), - email: z.string().max(255).nullish(), - human_id: z.string().max(255).nullish(), - id: z.string().max(255).nullish(), - method: z.string().max(64).nullish(), - name: z.string().max(255).nullish(), - tenant_id: z.string().max(255).nullish(), -}) - -/** - * AgentHumanToolConfig - */ -export const zAgentHumanToolConfig = z.object({ - description: z.string().nullish(), - enabled: z.boolean().optional().default(true), - name: z.string().max(255).nullish(), -}) - -/** - * AgentSoulHumanConfig - */ -export const zAgentSoulHumanConfig = z.object({ - contacts: z.array(zAgentHumanContactConfig).optional(), - tools: z.array(zAgentHumanToolConfig).optional(), -}) - -/** - * AgentKnowledgeDatasetConfig - */ -export const zAgentKnowledgeDatasetConfig = z.object({ - description: z.string().nullish(), - id: z.string().max(255).nullish(), - name: z.string().max(255).nullish(), -}) - -/** - * AgentKnowledgeQueryConfig - */ -export const zAgentKnowledgeQueryConfig = z.object({ - query: z.string().nullish(), - score_threshold: z.number().gte(0).lte(1).nullish(), - score_threshold_enabled: z.boolean().nullish(), - top_k: z.int().gte(1).nullish(), -}) - -/** - * AgentKnowledgeQueryMode - */ -export const zAgentKnowledgeQueryMode = z.enum(['generated_query', 'user_query']) - -/** - * AgentSoulKnowledgeConfig - */ -export const zAgentSoulKnowledgeConfig = z.object({ - datasets: z.array(zAgentKnowledgeDatasetConfig).optional(), - query_config: zAgentKnowledgeQueryConfig.optional(), - query_mode: zAgentKnowledgeQueryMode.optional(), -}) - -/** - * AgentMemoryArtifactConfig - */ -export const zAgentMemoryArtifactConfig = z.object({ - id: z.string().max(255).nullish(), - name: z.string().max(255).nullish(), - type: z.string().max(64).nullish(), - url: z.string().nullish(), -}) - -/** - * AgentSoulMemoryConfig - */ -export const zAgentSoulMemoryConfig = z.object({ - artifacts: z.array(zAgentMemoryArtifactConfig).optional(), - budget: z.string().nullish(), - scope: z.string().nullish(), -}) - -/** - * AgentSoulModelCredentialRef - * - * Reference to model credentials resolved only at runtime. - */ -export const zAgentSoulModelCredentialRef = z.object({ - id: z.string().max(255).nullish(), - provider: z.string().max(255).nullish(), - type: z.string().min(1).max(64), -}) - -/** - * AgentSandboxProviderConfig - */ -export const zAgentSandboxProviderConfig = z.object({ - cpu: z.int().gte(1).nullish(), - env: z.array(zAgentEnvVariableConfig).optional(), - image: z.string().nullish(), - working_dir: z.string().nullish(), -}) - -/** - * AgentSoulSandboxConfig - */ -export const zAgentSoulSandboxConfig = z.object({ - config: zAgentSandboxProviderConfig.optional(), - provider: z.string().nullish(), -}) - -/** - * AgentFileRefConfig - */ -export const zAgentFileRefConfig = z.object({ - file_id: z.string().max(255).nullish(), - id: z.string().max(255).nullish(), - name: z.string().max(255).nullish(), - reference: z.string().max(255).nullish(), - remote_url: z.string().nullish(), - tenant_id: z.string().max(255).nullish(), - transfer_method: z.string().max(64).nullish(), - type: z.string().max(64).nullish(), - upload_file_id: z.string().max(255).nullish(), - url: z.string().nullish(), -}) - -/** - * AgentSkillRefConfig - */ -export const zAgentSkillRefConfig = z.object({ - description: z.string().nullish(), - file_id: z.string().max(255).nullish(), - id: z.string().max(255).nullish(), - name: z.string().max(255).nullish(), - path: z.string().nullish(), -}) - -/** - * AgentSoulSkillsFilesConfig - */ -export const zAgentSoulSkillsFilesConfig = z.object({ - files: z.array(zAgentFileRefConfig).optional(), - skills: z.array(zAgentSkillRefConfig).optional(), -}) - -/** - * AgentPermissionConfig - */ -export const zAgentPermissionConfig = z.object({ - allowed: z.boolean().nullish(), - state: z.string().max(64).nullish(), - status: z.string().max(64).nullish(), -}) - -/** - * AgentSecretRefConfig - */ -export const zAgentSecretRefConfig = z.object({ - credential_id: z.string().max(255).nullish(), - env_name: z.string().max(255).nullish(), - id: z.string().max(255).nullish(), - key: z.string().max(255).nullish(), - name: z.string().max(255).nullish(), - permission: zAgentPermissionConfig.optional(), - permission_status: z.string().max(64).nullish(), - provider: z.string().max(255).nullish(), - provider_credential_id: z.string().max(255).nullish(), - ref: z.string().max(255).nullish(), - type: z.string().max(64).nullish(), - variable: z.string().max(255).nullish(), -}) - -/** - * AgentSoulEnvConfig - */ -export const zAgentSoulEnvConfig = z.object({ - secret_refs: z.array(zAgentSecretRefConfig).optional(), - variables: z.array(zAgentEnvVariableConfig).optional(), -}) - -/** - * AgentModelResponseFormatConfig - */ -export const zAgentModelResponseFormatConfig = z.object({ - type: z.string().max(64).nullish(), -}) - -/** - * AgentSoulModelSettings - */ -export const zAgentSoulModelSettings = z.object({ - frequency_penalty: z.number().nullish(), - max_tokens: z.int().nullish(), - presence_penalty: z.number().nullish(), - response_format: zAgentModelResponseFormatConfig.optional(), - stop: z.array(z.string()).nullish(), - temperature: z.number().nullish(), - top_p: z.number().nullish(), -}) - -/** - * AgentSoulModelConfig - * - * Stable model selection for Agent runtime without storing secret values. - */ -export const zAgentSoulModelConfig = z.object({ - credential_ref: zAgentSoulModelCredentialRef.optional(), - model: z.string().min(1).max(255), - model_provider: z.string().min(1).max(255), - model_settings: zAgentSoulModelSettings.optional(), - plugin_id: z.string().min(1).max(255), -}) - -/** - * AgentSuggestedQuestionsAfterAnswerFeatureConfig - */ -export const zAgentSuggestedQuestionsAfterAnswerFeatureConfig = z.object({ - enabled: z.boolean().optional().default(false), - model: zAgentSoulModelConfig.optional(), - prompt: z.string().nullish(), -}) - -/** - * AgentCliToolAuthorizationStatus - * - * Authorization state for Agent-scoped CLI tools. - * - * Missing status keeps backward compatibility with draft rows and CLI tools that - * do not need pre-authorization. Explicit denied-like states are blocked by the - * composer/publish validators and skipped by runtime request builders. - */ -export const zAgentCliToolAuthorizationStatus = z.enum([ - 'allowed', - 'authorized', - 'denied', - 'forbidden', - 'not_required', - 'pending', - 'pre_authorized', - 'unauthorized', -]) - -/** - * AgentCliToolRiskLevel - * - * Risk marker for CLI tool bootstrap commands. - */ -export const zAgentCliToolRiskLevel = z.enum(['dangerous', 'safe', 'unknown']) - -/** - * AgentCliToolConfig - */ -export const zAgentCliToolConfig = z.object({ - approved: z.boolean().optional().default(false), - authorization_status: zAgentCliToolAuthorizationStatus.optional(), - command: z.string().nullish(), - dangerous: z.boolean().optional().default(false), - dangerous_accepted: z.boolean().optional().default(false), - dangerous_acknowledged: z.boolean().optional().default(false), - dangerous_command: z.boolean().optional().default(false), - description: z.string().nullish(), - enabled: z.boolean().optional().default(true), - install: z.string().nullish(), - install_command: z.string().nullish(), - install_commands: z.array(z.string()).optional(), - invoke_metadata: z.record(z.string(), z.unknown()).optional(), - label: z.string().max(255).nullish(), - name: z.string().max(255).nullish(), - permission: zAgentPermissionConfig.optional(), - pre_authorized: z.boolean().nullish(), - requires_confirmation: z.boolean().optional().default(false), - risk_accepted: z.boolean().optional().default(false), - risk_level: zAgentCliToolRiskLevel.optional(), - setup_command: z.string().nullish(), - tool_name: z.string().max(255).nullish(), -}) - -/** - * AgentSoulDifyToolCredentialRef - * - * Reference to a stored Dify Plugin Tool credential. - * - * Secret values are resolved only at runtime. The legacy ``credential_id`` - * field is accepted by :class:`AgentSoulDifyToolConfig` and normalized here so - * old Agent tool payloads can be read while new payloads stay explicit. - */ -export const zAgentSoulDifyToolCredentialRef = z.object({ - id: z.string().max(255).nullish(), - provider: z.string().max(255).nullish(), - type: z.enum(['provider', 'tool']).optional().default('tool'), -}) - -/** - * AgentSoulDifyToolConfig - * - * One Dify Plugin Tool configured on Agent Soul. - * - * The API backend prepares this persisted product shape into - * ``DifyPluginToolConfig`` before sending a run request to Agent backend. - * ``provider_id`` keeps compatibility with existing Agent tool config payloads; - * new callers should send ``plugin_id`` + ``provider`` when available. - */ -export const zAgentSoulDifyToolConfig = z.object({ - credential_ref: zAgentSoulDifyToolCredentialRef.optional(), - credential_type: z.enum(['api-key', 'oauth2', 'unauthorized']).optional().default('api-key'), - description: z.string().nullish(), - enabled: z.boolean().optional().default(true), - name: z.string().max(255).nullish(), - plugin_id: z.string().max(255).nullish(), - provider: z.string().max(255).nullish(), - provider_id: z.string().max(255).nullish(), - provider_type: z.string().optional().default('plugin'), - runtime_parameters: z.record(z.string(), z.unknown()).optional(), - tool_name: z.string().min(1).max(255), -}) - -/** - * AgentSoulToolsConfig - */ -export const zAgentSoulToolsConfig = z.object({ - cli_tools: z.array(zAgentCliToolConfig).optional(), - dify_tools: z.array(zAgentSoulDifyToolConfig).optional(), -}) - -/** - * AgentModerationIOConfig - */ -export const zAgentModerationIoConfig = z.object({ - enabled: z.boolean().optional().default(false), - preset_response: z.string().nullish(), -}) - -/** - * AgentModerationProviderConfig - */ -export const zAgentModerationProviderConfig = z.object({ - api_based_extension_id: z.string().nullish(), - inputs_config: zAgentModerationIoConfig.optional(), - keywords: z.string().nullish(), - outputs_config: zAgentModerationIoConfig.optional(), -}) - -/** - * AgentSensitiveWordAvoidanceFeatureConfig - */ -export const zAgentSensitiveWordAvoidanceFeatureConfig = z.object({ - config: zAgentModerationProviderConfig.optional(), - enabled: z.boolean().optional().default(false), - type: z.string().nullish(), -}) - -/** - * AgentSoulAppFeaturesConfig - */ -export const zAgentSoulAppFeaturesConfig = z.object({ - opening_statement: z.string().nullish(), - retriever_resource: zAgentFeatureToggleConfig.optional(), - sensitive_word_avoidance: zAgentSensitiveWordAvoidanceFeatureConfig.optional(), - speech_to_text: zAgentFeatureToggleConfig.optional(), - suggested_questions: z.array(z.string()).nullish(), - suggested_questions_after_answer: zAgentSuggestedQuestionsAfterAnswerFeatureConfig.optional(), - text_to_speech: zAgentTextToSpeechFeatureConfig.optional(), -}) - -/** - * AgentSoulConfig - */ -export const zAgentSoulConfig = z.object({ - app_features: zAgentSoulAppFeaturesConfig.optional(), - app_variables: z.array(zAppVariableConfig).optional(), - env: zAgentSoulEnvConfig.optional(), - human: zAgentSoulHumanConfig.optional(), - knowledge: zAgentSoulKnowledgeConfig.optional(), - memory: zAgentSoulMemoryConfig.optional(), - misc_legacy: zAgentSoulAppFeaturesConfig.optional(), - model: zAgentSoulModelConfig.optional(), - prompt: zAgentSoulPromptConfig.optional(), - sandbox: zAgentSoulSandboxConfig.optional(), - schema_version: z.int().optional().default(1), - skills_files: zAgentSoulSkillsFilesConfig.optional(), - tools: zAgentSoulToolsConfig.optional(), -}) - -/** - * RosterAgentCreatePayload - */ -export const zRosterAgentCreatePayload = z.object({ - agent_soul: zAgentSoulConfig.optional(), - description: z.string().optional().default(''), - icon: z.string().max(255).nullish(), - icon_background: z.string().max(255).nullish(), - icon_type: zAgentIconType.optional(), - name: z.string().min(1).max(255), - version_note: z.string().nullish(), -}) - -/** - * AgentConfigSnapshotDetailResponse - */ -export const zAgentConfigSnapshotDetailResponse = z.object({ - agent_id: z.string().nullish(), - config_snapshot: zAgentSoulConfig, - created_at: z.int().nullish(), - created_by: z.string().nullish(), - id: z.string(), - revisions: z.array(zAgentConfigRevisionResponse).optional(), - summary: z.string().nullish(), - version: z.int(), - version_note: z.string().nullish(), -}) - -export const zGetAgentsQuery = z.object({ - keyword: z.string().optional(), - limit: z.int().gte(1).lte(100).optional().default(20), - page: z.int().gte(1).optional().default(1), -}) - -/** - * Agent roster list - */ -export const zGetAgentsResponse = zAgentRosterListResponse - -export const zPostAgentsBody = zRosterAgentCreatePayload - -/** - * Agent created - */ -export const zPostAgentsResponse = zAgentRosterResponse - -export const zGetAgentsInviteOptionsQuery = z.object({ - app_id: z.string().optional(), - keyword: z.string().optional(), - limit: z.int().gte(1).lte(100).optional().default(20), - page: z.int().gte(1).optional().default(1), -}) - -/** - * Agent invite options - */ -export const zGetAgentsInviteOptionsResponse = zAgentInviteOptionsResponse - -export const zDeleteAgentsByAgentIdPath = z.object({ - agent_id: z.string(), -}) - -/** - * Agent archived - */ -export const zDeleteAgentsByAgentIdResponse = z.record(z.string(), z.never()) - -export const zGetAgentsByAgentIdPath = z.object({ - agent_id: z.string(), -}) - -/** - * Agent detail - */ -export const zGetAgentsByAgentIdResponse = zAgentRosterResponse - -export const zPatchAgentsByAgentIdBody = zRosterAgentUpdatePayload - -export const zPatchAgentsByAgentIdPath = z.object({ - agent_id: z.string(), -}) - -/** - * Agent updated - */ -export const zPatchAgentsByAgentIdResponse = zAgentRosterResponse - -export const zGetAgentsByAgentIdVersionsPath = z.object({ - agent_id: z.string(), -}) - -/** - * Agent versions - */ -export const zGetAgentsByAgentIdVersionsResponse = zAgentConfigSnapshotListResponse - -export const zGetAgentsByAgentIdVersionsByVersionIdPath = z.object({ - agent_id: z.string(), - version_id: z.string(), -}) - -/** - * Agent version detail - */ -export const zGetAgentsByAgentIdVersionsByVersionIdResponse = zAgentConfigSnapshotDetailResponse diff --git a/packages/contracts/generated/api/console/all-workspaces/orpc.gen.ts b/packages/contracts/generated/api/console/all-workspaces/orpc.gen.ts index 52ee85667eb..91ccdbc408f 100644 --- a/packages/contracts/generated/api/console/all-workspaces/orpc.gen.ts +++ b/packages/contracts/generated/api/console/all-workspaces/orpc.gen.ts @@ -5,16 +5,8 @@ import * as z from 'zod' import { zGetAllWorkspacesQuery, zGetAllWorkspacesResponse } from './zod.gen' -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const get = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAllWorkspaces', 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 2c30287835c..4683b2d9921 100644 --- a/packages/contracts/generated/api/console/all-workspaces/types.gen.ts +++ b/packages/contracts/generated/api/console/all-workspaces/types.gen.ts @@ -4,6 +4,21 @@ export type ClientOptions = { baseUrl: `${string}://${string}/console/api` | (string & {}) } +export type WorkspaceListResponse = { + data: Array + has_more: boolean + limit: number + page: number + total: number +} + +export type WorkspaceListItemResponse = { + created_at?: number | null + id: string + name?: string | null + status?: string | null +} + export type GetAllWorkspacesData = { body?: never path?: never @@ -15,9 +30,7 @@ export type GetAllWorkspacesData = { } export type GetAllWorkspacesResponses = { - 200: { - [key: string]: unknown - } + 200: WorkspaceListResponse } 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 bdb5f0d1322..f63bd0e396f 100644 --- a/packages/contracts/generated/api/console/all-workspaces/zod.gen.ts +++ b/packages/contracts/generated/api/console/all-workspaces/zod.gen.ts @@ -2,6 +2,27 @@ import * as z from 'zod' +/** + * WorkspaceListItemResponse + */ +export const zWorkspaceListItemResponse = z.object({ + created_at: z.int().nullish(), + id: z.string(), + name: z.string().nullish(), + status: z.string().nullish(), +}) + +/** + * WorkspaceListResponse + */ +export const zWorkspaceListResponse = z.object({ + data: z.array(zWorkspaceListItemResponse), + has_more: z.boolean(), + limit: z.int(), + page: z.int(), + total: z.int(), +}) + export const zGetAllWorkspacesQuery = z.object({ limit: z.int().gte(1).lte(100).optional().default(20), page: z.int().gte(1).lte(99999).optional().default(1), @@ -10,4 +31,4 @@ export const zGetAllWorkspacesQuery = z.object({ /** * Success */ -export const zGetAllWorkspacesResponse = z.record(z.string(), z.unknown()) +export const zGetAllWorkspacesResponse = zWorkspaceListResponse diff --git a/packages/contracts/generated/api/console/api-based-extension/types.gen.ts b/packages/contracts/generated/api/console/api-based-extension/types.gen.ts index 1b460d21664..e24fb6efc44 100644 --- a/packages/contracts/generated/api/console/api-based-extension/types.gen.ts +++ b/packages/contracts/generated/api/console/api-based-extension/types.gen.ts @@ -58,9 +58,7 @@ export type DeleteApiBasedExtensionByIdData = { } export type DeleteApiBasedExtensionByIdResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteApiBasedExtensionByIdResponse 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 dd7fa0c51d8..9bb0f67728f 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 @@ -43,7 +43,7 @@ export const zDeleteApiBasedExtensionByIdPath = z.object({ /** * Extension deleted successfully */ -export const zDeleteApiBasedExtensionByIdResponse = z.record(z.string(), z.never()) +export const zDeleteApiBasedExtensionByIdResponse = z.void() export const zGetApiBasedExtensionByIdPath = z.object({ id: z.string(), diff --git a/packages/contracts/generated/api/console/api-key-auth/orpc.gen.ts b/packages/contracts/generated/api/console/api-key-auth/orpc.gen.ts index 38714ef7450..75ed149fae8 100644 --- a/packages/contracts/generated/api/console/api-key-auth/orpc.gen.ts +++ b/packages/contracts/generated/api/console/api-key-auth/orpc.gen.ts @@ -11,16 +11,8 @@ import { zPostApiKeyAuthDataSourceBindingResponse, } from './zod.gen' -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postApiKeyAuthDataSourceBinding', diff --git a/packages/contracts/generated/api/console/api-key-auth/types.gen.ts b/packages/contracts/generated/api/console/api-key-auth/types.gen.ts index a519481a738..baa517f1866 100644 --- a/packages/contracts/generated/api/console/api-key-auth/types.gen.ts +++ b/packages/contracts/generated/api/console/api-key-auth/types.gen.ts @@ -16,6 +16,10 @@ export type ApiKeyAuthBindingPayload = { provider: string } +export type SimpleResultResponse = { + result: string +} + export type ApiKeyAuthDataSourceItem = { category: string created_at: number @@ -47,9 +51,7 @@ export type PostApiKeyAuthDataSourceBindingData = { } export type PostApiKeyAuthDataSourceBindingResponses = { - 200: { - [key: string]: unknown - } + 200: SimpleResultResponse } export type PostApiKeyAuthDataSourceBindingResponse @@ -65,9 +67,7 @@ export type DeleteApiKeyAuthDataSourceByBindingIdData = { } export type DeleteApiKeyAuthDataSourceByBindingIdResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteApiKeyAuthDataSourceByBindingIdResponse diff --git a/packages/contracts/generated/api/console/api-key-auth/zod.gen.ts b/packages/contracts/generated/api/console/api-key-auth/zod.gen.ts index 65c3c92f5cc..0ebff4365b3 100644 --- a/packages/contracts/generated/api/console/api-key-auth/zod.gen.ts +++ b/packages/contracts/generated/api/console/api-key-auth/zod.gen.ts @@ -11,6 +11,13 @@ export const zApiKeyAuthBindingPayload = z.object({ provider: z.string(), }) +/** + * SimpleResultResponse + */ +export const zSimpleResultResponse = z.object({ + result: z.string(), +}) + /** * ApiKeyAuthDataSourceItem */ @@ -40,7 +47,7 @@ export const zPostApiKeyAuthDataSourceBindingBody = zApiKeyAuthBindingPayload /** * Success */ -export const zPostApiKeyAuthDataSourceBindingResponse = z.record(z.string(), z.unknown()) +export const zPostApiKeyAuthDataSourceBindingResponse = zSimpleResultResponse export const zDeleteApiKeyAuthDataSourceByBindingIdPath = z.object({ binding_id: z.string(), @@ -49,4 +56,4 @@ export const zDeleteApiKeyAuthDataSourceByBindingIdPath = z.object({ /** * Binding deleted successfully */ -export const zDeleteApiKeyAuthDataSourceByBindingIdResponse = z.record(z.string(), z.never()) +export const zDeleteApiKeyAuthDataSourceByBindingIdResponse = z.void() diff --git a/packages/contracts/generated/api/console/app/orpc.gen.ts b/packages/contracts/generated/api/console/app/orpc.gen.ts index 1a2a10f23c5..7ccb9338664 100644 --- a/packages/contracts/generated/api/console/app/orpc.gen.ts +++ b/packages/contracts/generated/api/console/app/orpc.gen.ts @@ -7,16 +7,10 @@ import { zGetAppPromptTemplatesQuery, zGetAppPromptTemplatesResponse } from './z /** * Get advanced prompt templates based on app mode and model configuration - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get = oc .route({ - deprecated: true, - description: - 'Get advanced prompt templates based on app mode and model configuration\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get advanced prompt templates based on app mode and model configuration', inputStructure: 'detailed', method: 'GET', operationId: 'getAppPromptTemplates', diff --git a/packages/contracts/generated/api/console/app/types.gen.ts b/packages/contracts/generated/api/console/app/types.gen.ts index ad8334ad6d5..450e0acd866 100644 --- a/packages/contracts/generated/api/console/app/types.gen.ts +++ b/packages/contracts/generated/api/console/app/types.gen.ts @@ -4,6 +4,15 @@ export type ClientOptions = { baseUrl: `${string}://${string}/console/api` | (string & {}) } +export type AdvancedPromptTemplateResponse = { + chat_prompt_config?: { + [key: string]: unknown + } | null + completion_prompt_config?: { + [key: string]: unknown + } | null +} + export type GetAppPromptTemplatesData = { body?: never path?: never @@ -17,18 +26,11 @@ export type GetAppPromptTemplatesData = { } export type GetAppPromptTemplatesErrors = { - 400: { - [key: string]: unknown - } + 400: unknown } -export type GetAppPromptTemplatesError - = GetAppPromptTemplatesErrors[keyof GetAppPromptTemplatesErrors] - export type GetAppPromptTemplatesResponses = { - 200: Array<{ - [key: string]: unknown - }> + 200: AdvancedPromptTemplateResponse } export type GetAppPromptTemplatesResponse diff --git a/packages/contracts/generated/api/console/app/zod.gen.ts b/packages/contracts/generated/api/console/app/zod.gen.ts index df13f62825c..ad91eca9bbe 100644 --- a/packages/contracts/generated/api/console/app/zod.gen.ts +++ b/packages/contracts/generated/api/console/app/zod.gen.ts @@ -2,6 +2,14 @@ import * as z from 'zod' +/** + * AdvancedPromptTemplateResponse + */ +export const zAdvancedPromptTemplateResponse = z.object({ + chat_prompt_config: z.record(z.string(), z.unknown()).nullish(), + completion_prompt_config: z.record(z.string(), z.unknown()).nullish(), +}) + export const zGetAppPromptTemplatesQuery = z.object({ app_mode: z.string(), has_context: z.string().optional().default('true'), @@ -12,4 +20,4 @@ export const zGetAppPromptTemplatesQuery = z.object({ /** * Prompt templates retrieved successfully */ -export const zGetAppPromptTemplatesResponse = z.array(z.record(z.string(), z.unknown())) +export const zGetAppPromptTemplatesResponse = zAdvancedPromptTemplateResponse diff --git a/packages/contracts/generated/api/console/apps/orpc.gen.ts b/packages/contracts/generated/api/console/apps/orpc.gen.ts index 2ce5e6efac1..3952812a5a8 100644 --- a/packages/contracts/generated/api/console/apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/apps/orpc.gen.ts @@ -4,6 +4,12 @@ import { oc } from '@orpc/contract' import * as z from 'zod' import { + zDeleteAppsByAppIdAgentFilesPath, + zDeleteAppsByAppIdAgentFilesQuery, + zDeleteAppsByAppIdAgentFilesResponse, + zDeleteAppsByAppIdAgentSkillsBySlugPath, + zDeleteAppsByAppIdAgentSkillsBySlugQuery, + zDeleteAppsByAppIdAgentSkillsBySlugResponse, zDeleteAppsByAppIdAnnotationsByAnnotationIdPath, zDeleteAppsByAppIdAnnotationsByAnnotationIdResponse, zDeleteAppsByAppIdAnnotationsPath, @@ -14,8 +20,10 @@ import { zDeleteAppsByAppIdCompletionConversationsByConversationIdResponse, zDeleteAppsByAppIdPath, zDeleteAppsByAppIdResponse, - zDeleteAppsByAppIdTraceConfigBody, + zDeleteAppsByAppIdStarPath, + zDeleteAppsByAppIdStarResponse, zDeleteAppsByAppIdTraceConfigPath, + zDeleteAppsByAppIdTraceConfigQuery, zDeleteAppsByAppIdTraceConfigResponse, zDeleteAppsByAppIdWorkflowCommentsByCommentIdPath, zDeleteAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdPath, @@ -37,24 +45,18 @@ import { zGetAppsByAppIdAdvancedChatWorkflowRunsPath, zGetAppsByAppIdAdvancedChatWorkflowRunsQuery, zGetAppsByAppIdAdvancedChatWorkflowRunsResponse, - zGetAppsByAppIdAgentComposerCandidatesPath, - zGetAppsByAppIdAgentComposerCandidatesResponse, - zGetAppsByAppIdAgentComposerPath, - zGetAppsByAppIdAgentComposerResponse, + zGetAppsByAppIdAgentDriveFilesDownloadPath, + zGetAppsByAppIdAgentDriveFilesDownloadQuery, + zGetAppsByAppIdAgentDriveFilesDownloadResponse, + zGetAppsByAppIdAgentDriveFilesPath, + zGetAppsByAppIdAgentDriveFilesPreviewPath, + zGetAppsByAppIdAgentDriveFilesPreviewQuery, + zGetAppsByAppIdAgentDriveFilesPreviewResponse, + zGetAppsByAppIdAgentDriveFilesQuery, + zGetAppsByAppIdAgentDriveFilesResponse, zGetAppsByAppIdAgentLogsPath, zGetAppsByAppIdAgentLogsQuery, zGetAppsByAppIdAgentLogsResponse, - zGetAppsByAppIdAgentReferencingWorkflowsPath, - zGetAppsByAppIdAgentReferencingWorkflowsResponse, - zGetAppsByAppIdAgentWorkspaceFilesDownloadPath, - zGetAppsByAppIdAgentWorkspaceFilesDownloadQuery, - zGetAppsByAppIdAgentWorkspaceFilesDownloadResponse, - zGetAppsByAppIdAgentWorkspaceFilesPath, - zGetAppsByAppIdAgentWorkspaceFilesPreviewPath, - zGetAppsByAppIdAgentWorkspaceFilesPreviewQuery, - zGetAppsByAppIdAgentWorkspaceFilesPreviewResponse, - zGetAppsByAppIdAgentWorkspaceFilesQuery, - zGetAppsByAppIdAgentWorkspaceFilesResponse, zGetAppsByAppIdAnnotationReplyByActionStatusByJobIdPath, zGetAppsByAppIdAnnotationReplyByActionStatusByJobIdResponse, zGetAppsByAppIdAnnotationsBatchImportStatusByJobIdPath, @@ -153,15 +155,12 @@ import { zGetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponse, zGetAppsByAppIdWorkflowRunsByRunIdPath, zGetAppsByAppIdWorkflowRunsByRunIdResponse, - zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadPath, - zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadQuery, - zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadResponse, - zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPath, - zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewPath, - zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewQuery, - zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewResponse, - zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesQuery, - zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesResponse, + zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesPath, + zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesQuery, + zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadPath, + zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadQuery, + zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponse, + zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponse, zGetAppsByAppIdWorkflowRunsCountPath, zGetAppsByAppIdWorkflowRunsCountQuery, zGetAppsByAppIdWorkflowRunsCountResponse, @@ -238,6 +237,8 @@ import { zGetAppsImportsByAppIdCheckDependenciesResponse, zGetAppsQuery, zGetAppsResponse, + zGetAppsStarredQuery, + zGetAppsStarredResponse, zPatchAppsByAppIdTraceConfigBody, zPatchAppsByAppIdTraceConfigPath, zPatchAppsByAppIdTraceConfigResponse, @@ -263,13 +264,15 @@ import { zPostAppsByAppIdAdvancedChatWorkflowsDraftRunBody, zPostAppsByAppIdAdvancedChatWorkflowsDraftRunPath, zPostAppsByAppIdAdvancedChatWorkflowsDraftRunResponse, - zPostAppsByAppIdAgentComposerValidateBody, - zPostAppsByAppIdAgentComposerValidatePath, - zPostAppsByAppIdAgentComposerValidateResponse, - zPostAppsByAppIdAgentFeaturesBody, - zPostAppsByAppIdAgentFeaturesPath, - zPostAppsByAppIdAgentFeaturesResponse, + zPostAppsByAppIdAgentFilesBody, + zPostAppsByAppIdAgentFilesPath, + zPostAppsByAppIdAgentFilesQuery, + zPostAppsByAppIdAgentFilesResponse, + zPostAppsByAppIdAgentSkillsBySlugInferToolsPath, + zPostAppsByAppIdAgentSkillsBySlugInferToolsQuery, + zPostAppsByAppIdAgentSkillsBySlugInferToolsResponse, zPostAppsByAppIdAgentSkillsStandardizePath, + zPostAppsByAppIdAgentSkillsStandardizeQuery, zPostAppsByAppIdAgentSkillsStandardizeResponse, zPostAppsByAppIdAgentSkillsUploadPath, zPostAppsByAppIdAgentSkillsUploadResponse, @@ -330,6 +333,8 @@ import { zPostAppsByAppIdSiteEnableResponse, zPostAppsByAppIdSitePath, zPostAppsByAppIdSiteResponse, + zPostAppsByAppIdStarPath, + zPostAppsByAppIdStarResponse, zPostAppsByAppIdTextToAudioBody, zPostAppsByAppIdTextToAudioPath, zPostAppsByAppIdTextToAudioResponse, @@ -350,6 +355,9 @@ import { zPostAppsByAppIdWorkflowCommentsByCommentIdResolveResponse, zPostAppsByAppIdWorkflowCommentsPath, zPostAppsByAppIdWorkflowCommentsResponse, + zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadBody, + zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadPath, + zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponse, zPostAppsByAppIdWorkflowRunsTasksByTaskIdStopPath, zPostAppsByAppIdWorkflowRunsTasksByTaskIdStopResponse, zPostAppsByAppIdWorkflowsByWorkflowIdRestorePath, @@ -416,9 +424,6 @@ import { zPostAppsResponse, zPostAppsWorkflowsOnlineUsersBody, zPostAppsWorkflowsOnlineUsersResponse, - zPutAppsByAppIdAgentComposerBody, - zPutAppsByAppIdAgentComposerPath, - zPutAppsByAppIdAgentComposerResponse, zPutAppsByAppIdBody, zPutAppsByAppIdPath, zPutAppsByAppIdResponse, @@ -493,6 +498,25 @@ export const imports = { byImportId, } +/** + * Get applications starred by the current account + */ +export const get2 = oc + .route({ + description: 'Get applications starred by the current account', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsStarred', + path: '/apps/starred', + tags: ['console'], + }) + .input(z.object({ query: zGetAppsStarredQuery.optional() })) + .output(zGetAppsStarredResponse) + +export const starred = { + get: get2, +} + /** * Get workflow online users */ @@ -521,7 +545,7 @@ export const workflows = { * * Get advanced chat workflow runs count statistics */ -export const get2 = oc +export const get3 = oc .route({ description: 'Get advanced chat workflow runs count statistics', inputStructure: 'detailed', @@ -540,7 +564,7 @@ export const get2 = oc .output(zGetAppsByAppIdAdvancedChatWorkflowRunsCountResponse) export const count = { - get: get2, + get: get3, } /** @@ -548,7 +572,7 @@ export const count = { * * Get advanced chat workflow run list */ -export const get3 = oc +export const get4 = oc .route({ description: 'Get advanced chat workflow run list', inputStructure: 'detailed', @@ -567,7 +591,7 @@ export const get3 = oc .output(zGetAppsByAppIdAdvancedChatWorkflowRunsResponse) export const workflowRuns = { - get: get3, + get: get4, count, } @@ -575,16 +599,10 @@ export const workflowRuns = { * Preview human input form content and placeholders * * Get human input form preview for advanced chat workflow - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post4 = oc .route({ - deprecated: true, - description: - 'Get human input form preview for advanced chat workflow\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get human input form preview for advanced chat workflow', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormPreview', @@ -608,16 +626,10 @@ export const preview = { * Submit human input form preview * * Submit human input form preview for advanced chat workflow - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post5 = oc .route({ - deprecated: true, - description: - 'Submit human input form preview for advanced chat workflow\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Submit human input form preview for advanced chat workflow', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRun', @@ -658,16 +670,10 @@ export const humanInput = { * Run draft workflow iteration node * * Run draft workflow iteration node for advanced chat - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post6 = oc .route({ - deprecated: true, - description: - 'Run draft workflow iteration node for advanced chat\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Run draft workflow iteration node for advanced chat', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRun', @@ -703,16 +709,10 @@ export const iteration = { * Run draft workflow loop node * * Run draft workflow loop node for advanced chat - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post7 = oc .route({ - deprecated: true, - description: - 'Run draft workflow loop node for advanced chat\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Run draft workflow loop node for advanced chat', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRun', @@ -748,16 +748,10 @@ export const loop = { * Run draft workflow * * Run draft workflow for advanced chat application - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post8 = oc .route({ - deprecated: true, - description: - 'Run draft workflow for advanced chat application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Run draft workflow for advanced chat application', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdAdvancedChatWorkflowsDraftRun', @@ -793,207 +787,143 @@ export const advancedChat = { workflows: workflows2, } -export const get4 = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'getAppsByAppIdAgentComposerCandidates', - path: '/apps/{app_id}/agent-composer/candidates', - tags: ['console'], - }) - .input(z.object({ params: zGetAppsByAppIdAgentComposerCandidatesPath })) - .output(zGetAppsByAppIdAgentComposerCandidatesResponse) - -export const candidates = { - get: get4, -} - -export const post9 = oc - .route({ - inputStructure: 'detailed', - method: 'POST', - operationId: 'postAppsByAppIdAgentComposerValidate', - path: '/apps/{app_id}/agent-composer/validate', - tags: ['console'], - }) - .input( - z.object({ - body: zPostAppsByAppIdAgentComposerValidateBody, - params: zPostAppsByAppIdAgentComposerValidatePath, - }), - ) - .output(zPostAppsByAppIdAgentComposerValidateResponse) - -export const validate = { - post: post9, -} - +/** + * Time-limited external signed URL for one drive value (no streaming proxy) + */ export const get5 = oc .route({ + description: 'Time-limited external signed URL for one drive value (no streaming proxy)', inputStructure: 'detailed', method: 'GET', - operationId: 'getAppsByAppIdAgentComposer', - path: '/apps/{app_id}/agent-composer', - tags: ['console'], - }) - .input(z.object({ params: zGetAppsByAppIdAgentComposerPath })) - .output(zGetAppsByAppIdAgentComposerResponse) - -export const put = oc - .route({ - inputStructure: 'detailed', - method: 'PUT', - operationId: 'putAppsByAppIdAgentComposer', - path: '/apps/{app_id}/agent-composer', - tags: ['console'], - }) - .input( - z.object({ body: zPutAppsByAppIdAgentComposerBody, params: zPutAppsByAppIdAgentComposerPath }), - ) - .output(zPutAppsByAppIdAgentComposerResponse) - -export const agentComposer = { - get: get5, - put, - candidates, - validate, -} - -/** - * Update an Agent App's presentation features (opener, follow-up, citations, ...) - */ -export const post10 = oc - .route({ - description: 'Update an Agent App\'s presentation features (opener, follow-up, citations, ...)', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postAppsByAppIdAgentFeatures', - path: '/apps/{app_id}/agent-features', + operationId: 'getAppsByAppIdAgentDriveFilesDownload', + path: '/apps/{app_id}/agent/drive/files/download', tags: ['console'], }) .input( z.object({ - body: zPostAppsByAppIdAgentFeaturesBody, - params: zPostAppsByAppIdAgentFeaturesPath, + params: zGetAppsByAppIdAgentDriveFilesDownloadPath, + query: zGetAppsByAppIdAgentDriveFilesDownloadQuery, }), ) - .output(zPostAppsByAppIdAgentFeaturesResponse) + .output(zGetAppsByAppIdAgentDriveFilesDownloadResponse) -export const agentFeatures = { - post: post10, +export const download = { + get: get5, } /** - * List workflow apps that reference this Agent App's bound Agent (read-only) + * Truncated text preview of one drive value (binary-safe; SKILL.md is the main case) */ export const get6 = oc .route({ - description: 'List workflow apps that reference this Agent App\'s bound Agent (read-only)', + description: + 'Truncated text preview of one drive value (binary-safe; SKILL.md is the main case)', inputStructure: 'detailed', method: 'GET', - operationId: 'getAppsByAppIdAgentReferencingWorkflows', - path: '/apps/{app_id}/agent-referencing-workflows', + operationId: 'getAppsByAppIdAgentDriveFilesPreview', + path: '/apps/{app_id}/agent/drive/files/preview', tags: ['console'], }) - .input(z.object({ params: zGetAppsByAppIdAgentReferencingWorkflowsPath })) - .output(zGetAppsByAppIdAgentReferencingWorkflowsResponse) + .input( + z.object({ + params: zGetAppsByAppIdAgentDriveFilesPreviewPath, + query: zGetAppsByAppIdAgentDriveFilesPreviewQuery, + }), + ) + .output(zGetAppsByAppIdAgentDriveFilesPreviewResponse) -export const agentReferencingWorkflows = { +export const preview2 = { get: get6, } /** - * Download a file from an Agent App conversation's sandbox workspace (read-only) + * List agent drive entries (read-only inspector; one endpoint for both tabs) */ export const get7 = oc .route({ - description: 'Download a file from an Agent App conversation\'s sandbox workspace (read-only)', + description: 'List agent drive entries (read-only inspector; one endpoint for both tabs)', inputStructure: 'detailed', method: 'GET', - operationId: 'getAppsByAppIdAgentWorkspaceFilesDownload', - path: '/apps/{app_id}/agent-workspace/files/download', + operationId: 'getAppsByAppIdAgentDriveFiles', + path: '/apps/{app_id}/agent/drive/files', tags: ['console'], }) .input( z.object({ - params: zGetAppsByAppIdAgentWorkspaceFilesDownloadPath, - query: zGetAppsByAppIdAgentWorkspaceFilesDownloadQuery, + params: zGetAppsByAppIdAgentDriveFilesPath, + query: zGetAppsByAppIdAgentDriveFilesQuery.optional(), }), ) - .output(zGetAppsByAppIdAgentWorkspaceFilesDownloadResponse) - -export const download = { - get: get7, -} - -/** - * Preview a text/binary file in an Agent App conversation's sandbox workspace - */ -export const get8 = oc - .route({ - description: 'Preview a text/binary file in an Agent App conversation\'s sandbox workspace', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getAppsByAppIdAgentWorkspaceFilesPreview', - path: '/apps/{app_id}/agent-workspace/files/preview', - tags: ['console'], - }) - .input( - z.object({ - params: zGetAppsByAppIdAgentWorkspaceFilesPreviewPath, - query: zGetAppsByAppIdAgentWorkspaceFilesPreviewQuery, - }), - ) - .output(zGetAppsByAppIdAgentWorkspaceFilesPreviewResponse) - -export const preview2 = { - get: get8, -} - -/** - * List a directory in an Agent App conversation's sandbox workspace (read-only) - */ -export const get9 = oc - .route({ - description: 'List a directory in an Agent App conversation\'s sandbox workspace (read-only)', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getAppsByAppIdAgentWorkspaceFiles', - path: '/apps/{app_id}/agent-workspace/files', - tags: ['console'], - }) - .input( - z.object({ - params: zGetAppsByAppIdAgentWorkspaceFilesPath, - query: zGetAppsByAppIdAgentWorkspaceFilesQuery, - }), - ) - .output(zGetAppsByAppIdAgentWorkspaceFilesResponse) + .output(zGetAppsByAppIdAgentDriveFilesResponse) export const files = { - get: get9, + get: get7, download, preview: preview2, } -export const agentWorkspace = { +export const drive = { files, } +/** + * Delete one drive file by key; soul ref first, then the KV row (ENG-625 D5) + */ +export const delete_ = oc + .route({ + description: 'Delete one drive file by key; soul ref first, then the KV row (ENG-625 D5)', + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteAppsByAppIdAgentFiles', + path: '/apps/{app_id}/agent/files', + tags: ['console'], + }) + .input( + z.object({ + params: zDeleteAppsByAppIdAgentFilesPath, + query: zDeleteAppsByAppIdAgentFilesQuery, + }), + ) + .output(zDeleteAppsByAppIdAgentFilesResponse) + +/** + * ADD FILE: commit one uploaded file into the bound agent's drive + * + * Commit an uploaded file into the agent drive under files/ (ENG-625 D3) + */ +export const post9 = oc + .route({ + description: 'Commit an uploaded file into the agent drive under files/ (ENG-625 D3)', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAppsByAppIdAgentFiles', + path: '/apps/{app_id}/agent/files', + successStatus: 201, + summary: 'ADD FILE: commit one uploaded file into the bound agent\'s drive', + tags: ['console'], + }) + .input( + z.object({ + body: zPostAppsByAppIdAgentFilesBody, + params: zPostAppsByAppIdAgentFilesPath, + query: zPostAppsByAppIdAgentFilesQuery.optional(), + }), + ) + .output(zPostAppsByAppIdAgentFilesResponse) + +export const files2 = { + delete: delete_, + post: post9, +} + /** * Get agent logs * * Get agent execution logs for an application - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get10 = oc +export const get8 = oc .route({ - deprecated: true, - description: - 'Get agent execution logs for an application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get agent execution logs for an application', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdAgentLogs', @@ -1005,23 +935,17 @@ export const get10 = oc .output(zGetAppsByAppIdAgentLogsResponse) export const logs = { - get: get10, + get: get8, } /** * Upload a Skill, validate it, and standardize it into the app agent's drive * * Validate + standardize a Skill into the agent drive (ENG-594) - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const post11 = oc +export const post10 = oc .route({ - deprecated: true, - description: - 'Validate + standardize a Skill into the agent drive (ENG-594)\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Validate + standardize a Skill into the agent drive (ENG-594)', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdAgentSkillsStandardize', @@ -1030,11 +954,16 @@ export const post11 = oc summary: 'Upload a Skill, validate it, and standardize it into the app agent\'s drive', tags: ['console'], }) - .input(z.object({ params: zPostAppsByAppIdAgentSkillsStandardizePath })) + .input( + z.object({ + params: zPostAppsByAppIdAgentSkillsStandardizePath, + query: zPostAppsByAppIdAgentSkillsStandardizeQuery.optional(), + }), + ) .output(zPostAppsByAppIdAgentSkillsStandardizeResponse) export const standardize = { - post: post11, + post: post10, } /** @@ -1043,16 +972,11 @@ export const standardize = { * Upload + validate a Skill package (.zip/.skill) and extract its manifest * Returns a validated skill ref (to bind into the Agent soul config on save) * plus its manifest. Standardizing into the agent drive is ENG-594. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const post12 = oc +export const post11 = oc .route({ - deprecated: true, description: - 'Upload + validate a Skill package (.zip/.skill) and extract its manifest\nReturns a validated skill ref (to bind into the Agent soul config on save)\nplus its manifest. Standardizing into the agent drive is ENG-594.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + 'Upload + validate a Skill package (.zip/.skill) and extract its manifest\nReturns a validated skill ref (to bind into the Agent soul config on save)\nplus its manifest. Standardizing into the agent drive is ENG-594.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdAgentSkillsUpload', @@ -1065,31 +989,83 @@ export const post12 = oc .output(zPostAppsByAppIdAgentSkillsUploadResponse) export const upload = { + post: post11, +} + +/** + * Suggest CLI tools/env for a skill + * + * Infer CLI tool + ENV suggestions from a standardized skill's SKILL.md (draft only, ENG-371) + * Saving still goes through composer validation. + */ +export const post12 = 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.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAppsByAppIdAgentSkillsBySlugInferTools', + path: '/apps/{app_id}/agent/skills/{slug}/infer-tools', + summary: 'Suggest CLI tools/env for a skill', + tags: ['console'], + }) + .input( + z.object({ + params: zPostAppsByAppIdAgentSkillsBySlugInferToolsPath, + query: zPostAppsByAppIdAgentSkillsBySlugInferToolsQuery.optional(), + }), + ) + .output(zPostAppsByAppIdAgentSkillsBySlugInferToolsResponse) + +export const inferTools = { post: post12, } +/** + * Delete a standardized skill: soul ref first, then the / drive prefix (ENG-625 D5) + */ +export const delete2 = oc + .route({ + description: + 'Delete a standardized skill: soul ref first, then the / drive prefix (ENG-625 D5)', + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteAppsByAppIdAgentSkillsBySlug', + path: '/apps/{app_id}/agent/skills/{slug}', + tags: ['console'], + }) + .input( + z.object({ + params: zDeleteAppsByAppIdAgentSkillsBySlugPath, + query: zDeleteAppsByAppIdAgentSkillsBySlugQuery.optional(), + }), + ) + .output(zDeleteAppsByAppIdAgentSkillsBySlugResponse) + +export const bySlug = { + delete: delete2, + inferTools, +} + export const skills = { standardize, upload, + bySlug, } export const agent = { + drive, + files: files2, logs, skills, } /** * Get status of annotation reply action job - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get11 = oc +export const get9 = oc .route({ - deprecated: true, - description: - 'Get status of annotation reply action job\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get status of annotation reply action job', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdAnnotationReplyByActionStatusByJobId', @@ -1100,7 +1076,7 @@ export const get11 = oc .output(zGetAppsByAppIdAnnotationReplyByActionStatusByJobIdResponse) export const byJobId = { - get: get11, + get: get9, } export const status = { @@ -1109,16 +1085,10 @@ export const status = { /** * Enable or disable annotation reply for an app - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post13 = oc .route({ - deprecated: true, - description: - 'Enable or disable annotation reply for an app\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Enable or disable annotation reply for an app', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdAnnotationReplyByAction', @@ -1144,16 +1114,10 @@ export const annotationReply = { /** * Get annotation settings for an app - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get12 = oc +export const get10 = oc .route({ - deprecated: true, - description: - 'Get annotation settings for an app\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get annotation settings for an app', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdAnnotationSetting', @@ -1164,21 +1128,15 @@ export const get12 = oc .output(zGetAppsByAppIdAnnotationSettingResponse) export const annotationSetting = { - get: get12, + get: get10, } /** * Update annotation settings for an app - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post14 = oc .route({ - deprecated: true, - description: - 'Update annotation settings for an app\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Update annotation settings for an app', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdAnnotationSettingsByAnnotationSettingId', @@ -1203,16 +1161,10 @@ export const annotationSettings = { /** * Batch import annotations from CSV file with rate limiting and security checks - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post15 = oc .route({ - deprecated: true, - description: - 'Batch import annotations from CSV file with rate limiting and security checks\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Batch import annotations from CSV file with rate limiting and security checks', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdAnnotationsBatchImport', @@ -1228,16 +1180,10 @@ export const batchImport = { /** * Get status of batch import job - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get13 = oc +export const get11 = oc .route({ - deprecated: true, - description: - 'Get status of batch import job\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get status of batch import job', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdAnnotationsBatchImportStatusByJobId', @@ -1248,7 +1194,7 @@ export const get13 = oc .output(zGetAppsByAppIdAnnotationsBatchImportStatusByJobIdResponse) export const byJobId2 = { - get: get13, + get: get11, } export const batchImportStatus = { @@ -1258,7 +1204,7 @@ export const batchImportStatus = { /** * Get count of message annotations for the app */ -export const get14 = oc +export const get12 = oc .route({ description: 'Get count of message annotations for the app', inputStructure: 'detailed', @@ -1271,13 +1217,13 @@ export const get14 = oc .output(zGetAppsByAppIdAnnotationsCountResponse) export const count2 = { - get: get14, + get: get12, } /** * Export all annotations for an app with CSV injection protection */ -export const get15 = oc +export const get13 = oc .route({ description: 'Export all annotations for an app with CSV injection protection', inputStructure: 'detailed', @@ -1290,13 +1236,13 @@ export const get15 = oc .output(zGetAppsByAppIdAnnotationsExportResponse) export const export_ = { - get: get15, + get: get13, } /** * Get hit histories for an annotation */ -export const get16 = oc +export const get14 = oc .route({ description: 'Get hit histories for an annotation', inputStructure: 'detailed', @@ -1314,23 +1260,16 @@ export const get16 = oc .output(zGetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesResponse) export const hitHistories = { - get: get16, + get: get14, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const delete_ = oc +export const delete3 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteAppsByAppIdAnnotationsByAnnotationId', path: '/apps/{app_id}/annotations/{annotation_id}', + successStatus: 204, tags: ['console'], }) .input(z.object({ params: zDeleteAppsByAppIdAnnotationsByAnnotationIdPath })) @@ -1338,16 +1277,10 @@ export const delete_ = oc /** * Update or delete an annotation - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post16 = oc .route({ - deprecated: true, - description: - 'Update or delete an annotation\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Update or delete an annotation', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdAnnotationsByAnnotationId', @@ -1363,25 +1296,18 @@ export const post16 = oc .output(zPostAppsByAppIdAnnotationsByAnnotationIdResponse) export const byAnnotationId = { - delete: delete_, + delete: delete3, post: post16, hitHistories, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const delete2 = oc +export const delete4 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteAppsByAppIdAnnotations', path: '/apps/{app_id}/annotations', + successStatus: 204, tags: ['console'], }) .input(z.object({ params: zDeleteAppsByAppIdAnnotationsPath })) @@ -1389,16 +1315,10 @@ export const delete2 = oc /** * Get annotations for an app with pagination - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get17 = oc +export const get15 = oc .route({ - deprecated: true, - description: - 'Get annotations for an app with pagination\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get annotations for an app with pagination', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdAnnotations', @@ -1415,16 +1335,10 @@ export const get17 = oc /** * Create a new annotation for an app - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post17 = oc .route({ - deprecated: true, - description: - 'Create a new annotation for an app\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Create a new annotation for an app', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdAnnotations', @@ -1438,8 +1352,8 @@ export const post17 = oc .output(zPostAppsByAppIdAnnotationsResponse) export const annotations = { - delete: delete2, - get: get17, + delete: delete4, + get: get15, post: post17, batchImport, batchImportStatus, @@ -1450,16 +1364,10 @@ export const annotations = { /** * Enable or disable app API - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post18 = oc .route({ - deprecated: true, - description: - 'Enable or disable app API\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Enable or disable app API', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdApiEnable', @@ -1495,7 +1403,7 @@ export const audioToText = { /** * Delete a chat conversation */ -export const delete3 = oc +export const delete5 = oc .route({ description: 'Delete a chat conversation', inputStructure: 'detailed', @@ -1510,16 +1418,10 @@ export const delete3 = oc /** * Get chat conversation details - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get18 = oc +export const get16 = oc .route({ - deprecated: true, - description: - 'Get chat conversation details\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get chat conversation details', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdChatConversationsByConversationId', @@ -1530,14 +1432,14 @@ export const get18 = oc .output(zGetAppsByAppIdChatConversationsByConversationIdResponse) export const byConversationId = { - delete: delete3, - get: get18, + delete: delete5, + get: get16, } /** * Get chat conversations with pagination, filtering and summary */ -export const get19 = oc +export const get17 = oc .route({ description: 'Get chat conversations with pagination, filtering and summary', inputStructure: 'detailed', @@ -1555,14 +1457,14 @@ export const get19 = oc .output(zGetAppsByAppIdChatConversationsResponse) export const chatConversations = { - get: get19, + get: get17, byConversationId, } /** * Get suggested questions for a message */ -export const get20 = oc +export const get18 = oc .route({ description: 'Get suggested questions for a message', inputStructure: 'detailed', @@ -1575,7 +1477,7 @@ export const get20 = oc .output(zGetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsResponse) export const suggestedQuestions = { - get: get20, + get: get18, } export const byMessageId = { @@ -1607,16 +1509,10 @@ export const byTaskId = { /** * Get chat messages for a conversation with pagination - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get21 = oc +export const get19 = oc .route({ - deprecated: true, - description: - 'Get chat messages for a conversation with pagination\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get chat messages for a conversation with pagination', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdChatMessages', @@ -1629,7 +1525,7 @@ export const get21 = oc .output(zGetAppsByAppIdChatMessagesResponse) export const chatMessages = { - get: get21, + get: get19, byMessageId, byTaskId, } @@ -1637,7 +1533,7 @@ export const chatMessages = { /** * Delete a completion conversation */ -export const delete4 = oc +export const delete6 = oc .route({ description: 'Delete a completion conversation', inputStructure: 'detailed', @@ -1652,16 +1548,10 @@ export const delete4 = oc /** * Get completion conversation details with messages - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get22 = oc +export const get20 = oc .route({ - deprecated: true, - description: - 'Get completion conversation details with messages\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get completion conversation details with messages', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdCompletionConversationsByConversationId', @@ -1672,14 +1562,14 @@ export const get22 = oc .output(zGetAppsByAppIdCompletionConversationsByConversationIdResponse) export const byConversationId2 = { - delete: delete4, - get: get22, + delete: delete6, + get: get20, } /** * Get completion conversations with pagination and filtering */ -export const get23 = oc +export const get21 = oc .route({ description: 'Get completion conversations with pagination and filtering', inputStructure: 'detailed', @@ -1697,7 +1587,7 @@ export const get23 = oc .output(zGetAppsByAppIdCompletionConversationsResponse) export const completionConversations = { - get: get23, + get: get21, byConversationId: byConversationId2, } @@ -1726,16 +1616,10 @@ export const byTaskId2 = { /** * Generate completion message for debugging - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post22 = oc .route({ - deprecated: true, - description: - 'Generate completion message for debugging\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Generate completion message for debugging', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdCompletionMessages', @@ -1758,7 +1642,7 @@ export const completionMessages = { /** * Get conversation variables for an application */ -export const get24 = oc +export const get22 = oc .route({ description: 'Get conversation variables for an application', inputStructure: 'detailed', @@ -1776,7 +1660,7 @@ export const get24 = oc .output(zGetAppsByAppIdConversationVariablesResponse) export const conversationVariables = { - get: get24, + get: get22, } /** @@ -1813,16 +1697,10 @@ export const convertToWorkflow = { * Copy app * * Create a copy of an existing application - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post24 = oc .route({ - deprecated: true, - description: - 'Create a copy of an existing application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Create a copy of an existing application', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdCopy', @@ -1843,7 +1721,7 @@ export const copy = { * * Export application configuration as DSL */ -export const get25 = oc +export const get23 = oc .route({ description: 'Export application configuration as DSL', inputStructure: 'detailed', @@ -1859,21 +1737,15 @@ export const get25 = oc .output(zGetAppsByAppIdExportResponse) export const export2 = { - get: get25, + get: get23, } /** * Export user feedback data for Google Sheets - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get26 = oc +export const get24 = oc .route({ - deprecated: true, - description: - 'Export user feedback data for Google Sheets\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Export user feedback data for Google Sheets', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdFeedbacksExport', @@ -1889,7 +1761,7 @@ export const get26 = oc .output(zGetAppsByAppIdFeedbacksExportResponse) export const export3 = { - get: get26, + get: get24, } /** @@ -1914,16 +1786,10 @@ export const feedbacks = { /** * Update application icon - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post26 = oc .route({ - deprecated: true, - description: - 'Update application icon\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Update application icon', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdIcon', @@ -1939,16 +1805,10 @@ export const icon = { /** * Get message details by ID - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get27 = oc +export const get25 = oc .route({ - deprecated: true, - description: - 'Get message details by ID\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get message details by ID', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdMessagesByMessageId', @@ -1959,7 +1819,7 @@ export const get27 = oc .output(zGetAppsByAppIdMessagesByMessageIdResponse) export const byMessageId2 = { - get: get27, + get: get25, } export const messages = { @@ -1970,16 +1830,10 @@ export const messages = { * Modify app model config * * Update application model configuration - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post27 = oc .route({ - deprecated: true, - description: - 'Update application model configuration\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Update application model configuration', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdModelConfig', @@ -1998,16 +1852,10 @@ export const modelConfig = { /** * Check if app name is available - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post28 = oc .route({ - deprecated: true, - description: - 'Check if app name is available\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Check if app name is available', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdName', @@ -2042,16 +1890,10 @@ export const publishToCreatorsPlatform = { /** * Get MCP server configuration for an application - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get28 = oc +export const get26 = oc .route({ - deprecated: true, - description: - 'Get MCP server configuration for an application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get MCP server configuration for an application', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdServer', @@ -2063,16 +1905,10 @@ export const get28 = oc /** * Create MCP server configuration for an application - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post30 = oc .route({ - deprecated: true, - description: - 'Create MCP server configuration for an application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Create MCP server configuration for an application', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdServer', @@ -2085,16 +1921,10 @@ export const post30 = oc /** * Update MCP server configuration for an application - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const put2 = oc +export const put = oc .route({ - deprecated: true, - description: - 'Update MCP server configuration for an application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Update MCP server configuration for an application', inputStructure: 'detailed', method: 'PUT', operationId: 'putAppsByAppIdServer', @@ -2105,9 +1935,9 @@ export const put2 = oc .output(zPutAppsByAppIdServerResponse) export const server = { - get: get28, + get: get26, post: post30, - put: put2, + put, } /** @@ -2151,16 +1981,10 @@ export const site = { /** * Enable or disable app site - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post33 = oc .route({ - deprecated: true, - description: - 'Enable or disable app site\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Enable or disable app site', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdSiteEnable', @@ -2175,17 +1999,46 @@ export const siteEnable = { } /** - * Get average response time statistics for an application - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated + * Remove the current account's star from an application */ -export const get29 = oc +export const delete7 = oc .route({ - deprecated: true, - description: - 'Get average response time statistics for an application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Remove the current account\'s star from an application', + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteAppsByAppIdStar', + path: '/apps/{app_id}/star', + tags: ['console'], + }) + .input(z.object({ params: zDeleteAppsByAppIdStarPath })) + .output(zDeleteAppsByAppIdStarResponse) + +/** + * Star an application for the current account + */ +export const post34 = oc + .route({ + description: 'Star an application for the current account', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAppsByAppIdStar', + path: '/apps/{app_id}/star', + tags: ['console'], + }) + .input(z.object({ params: zPostAppsByAppIdStarPath })) + .output(zPostAppsByAppIdStarResponse) + +export const star = { + delete: delete7, + post: post34, +} + +/** + * Get average response time statistics for an application + */ +export const get27 = oc + .route({ + description: 'Get average response time statistics for an application', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdStatisticsAverageResponseTime', @@ -2201,21 +2054,15 @@ export const get29 = oc .output(zGetAppsByAppIdStatisticsAverageResponseTimeResponse) export const averageResponseTime = { - get: get29, + get: get27, } /** * Get average session interaction statistics for an application - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get30 = oc +export const get28 = oc .route({ - deprecated: true, - description: - 'Get average session interaction statistics for an application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get average session interaction statistics for an application', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdStatisticsAverageSessionInteractions', @@ -2231,21 +2078,15 @@ export const get30 = oc .output(zGetAppsByAppIdStatisticsAverageSessionInteractionsResponse) export const averageSessionInteractions = { - get: get30, + get: get28, } /** * Get daily conversation statistics for an application - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get31 = oc +export const get29 = oc .route({ - deprecated: true, - description: - 'Get daily conversation statistics for an application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get daily conversation statistics for an application', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdStatisticsDailyConversations', @@ -2261,21 +2102,15 @@ export const get31 = oc .output(zGetAppsByAppIdStatisticsDailyConversationsResponse) export const dailyConversations = { - get: get31, + get: get29, } /** * Get daily terminal/end-user statistics for an application - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get32 = oc +export const get30 = oc .route({ - deprecated: true, - description: - 'Get daily terminal/end-user statistics for an application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get daily terminal/end-user statistics for an application', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdStatisticsDailyEndUsers', @@ -2291,21 +2126,15 @@ export const get32 = oc .output(zGetAppsByAppIdStatisticsDailyEndUsersResponse) export const dailyEndUsers = { - get: get32, + get: get30, } /** * Get daily message statistics for an application - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get33 = oc +export const get31 = oc .route({ - deprecated: true, - description: - 'Get daily message statistics for an application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get daily message statistics for an application', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdStatisticsDailyMessages', @@ -2321,21 +2150,15 @@ export const get33 = oc .output(zGetAppsByAppIdStatisticsDailyMessagesResponse) export const dailyMessages = { - get: get33, + get: get31, } /** * Get daily token cost statistics for an application - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get34 = oc +export const get32 = oc .route({ - deprecated: true, - description: - 'Get daily token cost statistics for an application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get daily token cost statistics for an application', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdStatisticsTokenCosts', @@ -2351,21 +2174,15 @@ export const get34 = oc .output(zGetAppsByAppIdStatisticsTokenCostsResponse) export const tokenCosts = { - get: get34, + get: get32, } /** * Get tokens per second statistics for an application - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get35 = oc +export const get33 = oc .route({ - deprecated: true, - description: - 'Get tokens per second statistics for an application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get tokens per second statistics for an application', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdStatisticsTokensPerSecond', @@ -2381,21 +2198,15 @@ export const get35 = oc .output(zGetAppsByAppIdStatisticsTokensPerSecondResponse) export const tokensPerSecond = { - get: get35, + get: get33, } /** * Get user satisfaction rate statistics for an application - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get36 = oc +export const get34 = oc .route({ - deprecated: true, - description: - 'Get user satisfaction rate statistics for an application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get user satisfaction rate statistics for an application', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdStatisticsUserSatisfactionRate', @@ -2411,7 +2222,7 @@ export const get36 = oc .output(zGetAppsByAppIdStatisticsUserSatisfactionRateResponse) export const userSatisfactionRate = { - get: get36, + get: get34, } export const statistics = { @@ -2427,16 +2238,10 @@ export const statistics = { /** * Get available TTS voices for a specific language - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get37 = oc +export const get35 = oc .route({ - deprecated: true, - description: - 'Get available TTS voices for a specific language\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get available TTS voices for a specific language', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdTextToAudioVoices', @@ -2452,21 +2257,15 @@ export const get37 = oc .output(zGetAppsByAppIdTextToAudioVoicesResponse) export const voices = { - get: get37, + get: get35, } /** * Convert text to speech for chat messages - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const post34 = oc +export const post35 = oc .route({ - deprecated: true, - description: - 'Convert text to speech for chat messages\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Convert text to speech for chat messages', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdTextToAudio', @@ -2479,7 +2278,7 @@ export const post34 = oc .output(zPostAppsByAppIdTextToAudioResponse) export const textToAudio = { - post: post34, + post: post35, voices, } @@ -2487,16 +2286,10 @@ export const textToAudio = { * Get app trace * * Get app tracing configuration - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get38 = oc +export const get36 = oc .route({ - deprecated: true, - description: - 'Get app tracing configuration\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get app tracing configuration', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdTrace', @@ -2510,7 +2303,7 @@ export const get38 = oc /** * Update app tracing configuration */ -export const post35 = oc +export const post36 = oc .route({ description: 'Update app tracing configuration', inputStructure: 'detailed', @@ -2523,8 +2316,8 @@ export const post35 = oc .output(zPostAppsByAppIdTraceResponse) export const trace = { - get: get38, - post: post35, + get: get36, + post: post36, } /** @@ -2532,7 +2325,7 @@ export const trace = { * * Delete an existing tracing configuration for an application */ -export const delete5 = oc +export const delete8 = oc .route({ description: 'Delete an existing tracing configuration for an application', inputStructure: 'detailed', @@ -2545,24 +2338,18 @@ export const delete5 = oc }) .input( z.object({ - body: zDeleteAppsByAppIdTraceConfigBody, params: zDeleteAppsByAppIdTraceConfigPath, + query: zDeleteAppsByAppIdTraceConfigQuery, }), ) .output(zDeleteAppsByAppIdTraceConfigResponse) /** * Get tracing configuration for an application - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get39 = oc +export const get37 = oc .route({ - deprecated: true, - description: - 'Get tracing configuration for an application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get tracing configuration for an application', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdTraceConfig', @@ -2578,16 +2365,10 @@ export const get39 = oc * Update an existing trace app configuration * * Update an existing tracing configuration for an application - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const patch = oc .route({ - deprecated: true, - description: - 'Update an existing tracing configuration for an application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Update an existing tracing configuration for an application', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchAppsByAppIdTraceConfig', @@ -2604,16 +2385,10 @@ export const patch = oc * Create a new trace app configuration * * Create a new tracing configuration for an application - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const post36 = oc +export const post37 = oc .route({ - deprecated: true, - description: - 'Create a new tracing configuration for an application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Create a new tracing configuration for an application', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdTraceConfig', @@ -2628,16 +2403,16 @@ export const post36 = oc .output(zPostAppsByAppIdTraceConfigResponse) export const traceConfig = { - delete: delete5, - get: get39, + delete: delete8, + get: get37, patch, - post: post36, + post: post37, } /** * Update app trigger (enable/disable) */ -export const post37 = oc +export const post38 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2655,13 +2430,13 @@ export const post37 = oc .output(zPostAppsByAppIdTriggerEnableResponse) export const triggerEnable = { - post: post37, + post: post38, } /** * Get app triggers list */ -export const get40 = oc +export const get38 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2674,7 +2449,7 @@ export const get40 = oc .output(zGetAppsByAppIdTriggersResponse) export const triggers = { - get: get40, + get: get38, } /** @@ -2682,7 +2457,7 @@ export const triggers = { * * Get workflow application execution logs */ -export const get41 = oc +export const get39 = oc .route({ description: 'Get workflow application execution logs', inputStructure: 'detailed', @@ -2701,7 +2476,7 @@ export const get41 = oc .output(zGetAppsByAppIdWorkflowAppLogsResponse) export const workflowAppLogs = { - get: get41, + get: get39, } /** @@ -2709,7 +2484,7 @@ export const workflowAppLogs = { * * Get workflow archived execution logs */ -export const get42 = oc +export const get40 = oc .route({ description: 'Get workflow archived execution logs', inputStructure: 'detailed', @@ -2728,7 +2503,7 @@ export const get42 = oc .output(zGetAppsByAppIdWorkflowArchivedLogsResponse) export const workflowArchivedLogs = { - get: get42, + get: get40, } /** @@ -2736,7 +2511,7 @@ export const workflowArchivedLogs = { * * Get workflow runs count statistics */ -export const get43 = oc +export const get41 = oc .route({ description: 'Get workflow runs count statistics', inputStructure: 'detailed', @@ -2755,7 +2530,7 @@ export const get43 = oc .output(zGetAppsByAppIdWorkflowRunsCountResponse) export const count3 = { - get: get43, + get: get41, } /** @@ -2763,7 +2538,7 @@ export const count3 = { * * Stop running workflow task */ -export const post38 = oc +export const post39 = oc .route({ description: 'Stop running workflow task', inputStructure: 'detailed', @@ -2777,7 +2552,7 @@ export const post38 = oc .output(zPostAppsByAppIdWorkflowRunsTasksByTaskIdStopResponse) export const stop3 = { - post: post38, + post: post39, } export const byTaskId3 = { @@ -2791,7 +2566,7 @@ export const tasks = { /** * Generate a download URL for an archived workflow run. */ -export const get44 = oc +export const get42 = oc .route({ description: 'Generate a download URL for an archived workflow run.', inputStructure: 'detailed', @@ -2804,7 +2579,7 @@ export const get44 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdExportResponse) export const export4 = { - get: get44, + get: get42, } /** @@ -2812,7 +2587,7 @@ export const export4 = { * * Get workflow run node execution list */ -export const get45 = oc +export const get43 = oc .route({ description: 'Get workflow run node execution list', inputStructure: 'detailed', @@ -2826,7 +2601,7 @@ export const get45 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponse) export const nodeExecutions = { - get: get45, + get: get43, } /** @@ -2834,7 +2609,7 @@ export const nodeExecutions = { * * Get workflow run detail */ -export const get46 = oc +export const get44 = oc .route({ description: 'Get workflow run detail', inputStructure: 'detailed', @@ -2848,97 +2623,92 @@ export const get46 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdResponse) export const byRunId = { - get: get46, + get: get44, export: export4, nodeExecutions, } /** - * Download a file from a Workflow Agent node's sandbox workspace (read-only) + * Read a text/binary preview file in a workflow Agent node sandbox */ -export const get47 = oc +export const get45 = oc .route({ - description: 'Download a file from a Workflow Agent node\'s sandbox workspace (read-only)', + description: 'Read a text/binary preview file in a workflow Agent node sandbox', inputStructure: 'detailed', method: 'GET', - operationId: - 'getAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownload', - path: '/apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/workspace/files/download', + operationId: 'getAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesRead', + path: '/apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files/read', tags: ['console'], }) .input( z.object({ - params: - zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadPath, - query: - zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadQuery, + params: zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadPath, + query: zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadQuery, }), ) - .output( - zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadResponse, - ) + .output(zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponse) -export const download2 = { - get: get47, +export const read = { + get: get45, } /** - * Preview a text/binary file in a Workflow Agent node's sandbox workspace + * Upload one workflow Agent sandbox file as a Dify ToolFile mapping */ -export const get48 = oc +export const post40 = oc .route({ - description: 'Preview a text/binary file in a Workflow Agent node\'s sandbox workspace', + description: 'Upload one workflow Agent sandbox file as a Dify ToolFile mapping', inputStructure: 'detailed', - method: 'GET', - operationId: 'getAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreview', - path: '/apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/workspace/files/preview', + method: 'POST', + operationId: 'postAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUpload', + path: '/apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files/upload', tags: ['console'], }) .input( z.object({ - params: zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewPath, - query: zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewQuery, + body: zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadBody, + params: zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadPath, }), ) - .output(zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewResponse) + .output(zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponse) -export const preview3 = { - get: get48, +export const upload2 = { + post: post40, } /** - * List a directory in a Workflow Agent node's sandbox workspace (read-only) + * List a directory in a workflow Agent node sandbox */ -export const get49 = oc +export const get46 = oc .route({ - description: 'List a directory in a Workflow Agent node\'s sandbox workspace (read-only)', + description: 'List a directory in a workflow Agent node sandbox', inputStructure: 'detailed', method: 'GET', - operationId: 'getAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFiles', - path: '/apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/workspace/files', + operationId: 'getAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFiles', + path: '/apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files', tags: ['console'], }) .input( z.object({ - params: zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPath, + params: zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesPath, query: - zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesQuery.optional(), + zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesQuery.optional(), }), ) - .output(zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesResponse) + .output(zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponse) -export const files2 = { - get: get49, - download: download2, - preview: preview3, +export const files3 = { + get: get46, + read, + upload: upload2, } -export const workspace = { - files: files2, +export const sandbox = { + files: files3, } export const byNodeId4 = { - workspace, + sandbox, } export const agentNodes = { @@ -2954,7 +2724,7 @@ export const byWorkflowRunId = { * * Get workflow run list */ -export const get50 = oc +export const get47 = oc .route({ description: 'Get workflow run list', inputStructure: 'detailed', @@ -2973,7 +2743,7 @@ export const get50 = oc .output(zGetAppsByAppIdWorkflowRunsResponse) export const workflowRuns2 = { - get: get50, + get: get47, count: count3, tasks, byRunId, @@ -2985,7 +2755,7 @@ export const workflowRuns2 = { * * Get all users in current tenant for mentions */ -export const get51 = oc +export const get48 = oc .route({ description: 'Get all users in current tenant for mentions', inputStructure: 'detailed', @@ -2999,7 +2769,7 @@ export const get51 = oc .output(zGetAppsByAppIdWorkflowCommentsMentionUsersResponse) export const mentionUsers = { - get: get51, + get: get48, } /** @@ -3007,7 +2777,7 @@ export const mentionUsers = { * * Delete a comment reply */ -export const delete6 = oc +export const delete9 = oc .route({ description: 'Delete a comment reply', inputStructure: 'detailed', @@ -3026,7 +2796,7 @@ export const delete6 = oc * * Update a comment reply */ -export const put3 = oc +export const put2 = oc .route({ description: 'Update a comment reply', inputStructure: 'detailed', @@ -3045,8 +2815,8 @@ export const put3 = oc .output(zPutAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdResponse) export const byReplyId = { - delete: delete6, - put: put3, + delete: delete9, + put: put2, } /** @@ -3054,7 +2824,7 @@ export const byReplyId = { * * Add a reply to a workflow comment */ -export const post39 = oc +export const post41 = oc .route({ description: 'Add a reply to a workflow comment', inputStructure: 'detailed', @@ -3074,7 +2844,7 @@ export const post39 = oc .output(zPostAppsByAppIdWorkflowCommentsByCommentIdRepliesResponse) export const replies = { - post: post39, + post: post41, byReplyId, } @@ -3083,7 +2853,7 @@ export const replies = { * * Resolve a workflow comment */ -export const post40 = oc +export const post42 = oc .route({ description: 'Resolve a workflow comment', inputStructure: 'detailed', @@ -3097,7 +2867,7 @@ export const post40 = oc .output(zPostAppsByAppIdWorkflowCommentsByCommentIdResolveResponse) export const resolve = { - post: post40, + post: post42, } /** @@ -3105,7 +2875,7 @@ export const resolve = { * * Delete a workflow comment */ -export const delete7 = oc +export const delete10 = oc .route({ description: 'Delete a workflow comment', inputStructure: 'detailed', @@ -3124,7 +2894,7 @@ export const delete7 = oc * * Get a specific workflow comment */ -export const get52 = oc +export const get49 = oc .route({ description: 'Get a specific workflow comment', inputStructure: 'detailed', @@ -3142,7 +2912,7 @@ export const get52 = oc * * Update a workflow comment */ -export const put4 = oc +export const put3 = oc .route({ description: 'Update a workflow comment', inputStructure: 'detailed', @@ -3161,9 +2931,9 @@ export const put4 = oc .output(zPutAppsByAppIdWorkflowCommentsByCommentIdResponse) export const byCommentId = { - delete: delete7, - get: get52, - put: put4, + delete: delete10, + get: get49, + put: put3, replies, resolve, } @@ -3173,7 +2943,7 @@ export const byCommentId = { * * Get all comments for a workflow */ -export const get53 = oc +export const get50 = oc .route({ description: 'Get all comments for a workflow', inputStructure: 'detailed', @@ -3191,7 +2961,7 @@ export const get53 = oc * * Create a new workflow comment */ -export const post41 = oc +export const post43 = oc .route({ description: 'Create a new workflow comment', inputStructure: 'detailed', @@ -3211,24 +2981,18 @@ export const post41 = oc .output(zPostAppsByAppIdWorkflowCommentsResponse) export const comments = { - get: get53, - post: post41, + get: get50, + post: post43, mentionUsers, byCommentId, } /** * Get workflow average app interaction statistics - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get54 = oc +export const get51 = oc .route({ - deprecated: true, - description: - 'Get workflow average app interaction statistics\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get workflow average app interaction statistics', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflowStatisticsAverageAppInteractions', @@ -3244,21 +3008,15 @@ export const get54 = oc .output(zGetAppsByAppIdWorkflowStatisticsAverageAppInteractionsResponse) export const averageAppInteractions = { - get: get54, + get: get51, } /** * Get workflow daily runs statistics - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get55 = oc +export const get52 = oc .route({ - deprecated: true, - description: - 'Get workflow daily runs statistics\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get workflow daily runs statistics', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflowStatisticsDailyConversations', @@ -3274,21 +3032,15 @@ export const get55 = oc .output(zGetAppsByAppIdWorkflowStatisticsDailyConversationsResponse) export const dailyConversations2 = { - get: get55, + get: get52, } /** * Get workflow daily terminals statistics - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get56 = oc +export const get53 = oc .route({ - deprecated: true, - description: - 'Get workflow daily terminals statistics\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get workflow daily terminals statistics', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflowStatisticsDailyTerminals', @@ -3304,21 +3056,15 @@ export const get56 = oc .output(zGetAppsByAppIdWorkflowStatisticsDailyTerminalsResponse) export const dailyTerminals = { - get: get56, + get: get53, } /** * Get workflow daily token cost statistics - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get57 = oc +export const get54 = oc .route({ - deprecated: true, - description: - 'Get workflow daily token cost statistics\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get workflow daily token cost statistics', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflowStatisticsTokenCosts', @@ -3334,7 +3080,7 @@ export const get57 = oc .output(zGetAppsByAppIdWorkflowStatisticsTokenCostsResponse) export const tokenCosts2 = { - get: get57, + get: get54, } export const statistics2 = { @@ -3353,16 +3099,10 @@ export const workflow = { * Get default block config * * Get default block configuration by type - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get58 = oc +export const get55 = oc .route({ - deprecated: true, - description: - 'Get default block configuration by type\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get default block configuration by type', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockType', @@ -3379,23 +3119,17 @@ export const get58 = oc .output(zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponse) export const byBlockType = { - get: get58, + get: get55, } /** * Get default block config * * Get default block configurations for workflow - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get59 = oc +export const get56 = oc .route({ - deprecated: true, - description: - 'Get default block configurations for workflow\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get default block configurations for workflow', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflowsDefaultWorkflowBlockConfigs', @@ -3407,22 +3141,16 @@ export const get59 = oc .output(zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsResponse) export const defaultWorkflowBlockConfigs = { - get: get59, + get: get56, byBlockType, } /** * Get conversation variables for workflow - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get60 = oc +export const get57 = oc .route({ - deprecated: true, - description: - 'Get conversation variables for workflow\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get conversation variables for workflow', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflowsDraftConversationVariables', @@ -3434,16 +3162,10 @@ export const get60 = oc /** * Update conversation variables for workflow draft - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const post42 = oc +export const post44 = oc .route({ - deprecated: true, - description: - 'Update conversation variables for workflow draft\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Update conversation variables for workflow draft', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraftConversationVariables', @@ -3459,24 +3181,18 @@ export const post42 = oc .output(zPostAppsByAppIdWorkflowsDraftConversationVariablesResponse) export const conversationVariables2 = { - get: get60, - post: post42, + get: get57, + post: post44, } /** * Get draft workflow * * Get environment variables for workflow - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get61 = oc +export const get58 = oc .route({ - deprecated: true, - description: - 'Get environment variables for workflow\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get environment variables for workflow', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflowsDraftEnvironmentVariables', @@ -3489,16 +3205,10 @@ export const get61 = oc /** * Update environment variables for workflow draft - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const post43 = oc +export const post45 = oc .route({ - deprecated: true, - description: - 'Update environment variables for workflow draft\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Update environment variables for workflow draft', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraftEnvironmentVariables', @@ -3514,22 +3224,16 @@ export const post43 = oc .output(zPostAppsByAppIdWorkflowsDraftEnvironmentVariablesResponse) export const environmentVariables = { - get: get61, - post: post43, + get: get58, + post: post45, } /** * Update draft workflow features - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const post44 = oc +export const post46 = oc .route({ - deprecated: true, - description: - 'Update draft workflow features\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Update draft workflow features', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraftFeatures', @@ -3545,23 +3249,17 @@ export const post44 = oc .output(zPostAppsByAppIdWorkflowsDraftFeaturesResponse) export const features = { - post: post44, + post: post46, } /** * Test human input delivery * * Test human input delivery for workflow - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const post45 = oc +export const post47 = oc .route({ - deprecated: true, - description: - 'Test human input delivery for workflow\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Test human input delivery for workflow', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTest', @@ -3578,23 +3276,17 @@ export const post45 = oc .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTestResponse) export const deliveryTest = { - post: post45, + post: post47, } /** * Preview human input form content and placeholders * * Get human input form preview for workflow - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const post46 = oc +export const post48 = oc .route({ - deprecated: true, - description: - 'Get human input form preview for workflow\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get human input form preview for workflow', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreview', @@ -3610,24 +3302,18 @@ export const post46 = oc ) .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponse) -export const preview4 = { - post: post46, +export const preview3 = { + post: post48, } /** * Submit human input form preview * * Submit human input form preview for workflow - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const post47 = oc +export const post49 = oc .route({ - deprecated: true, - description: - 'Submit human input form preview for workflow\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Submit human input form preview for workflow', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRun', @@ -3644,11 +3330,11 @@ export const post47 = oc .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunResponse) export const run5 = { - post: post47, + post: post49, } export const form2 = { - preview: preview4, + preview: preview3, run: run5, } @@ -3669,16 +3355,10 @@ export const humanInput2 = { * Run draft workflow iteration node * * Run draft workflow iteration node - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const post48 = oc +export const post50 = oc .route({ - deprecated: true, - description: - 'Run draft workflow iteration node\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Run draft workflow iteration node', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRun', @@ -3695,7 +3375,7 @@ export const post48 = oc .output(zPostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunResponse) export const run6 = { - post: post48, + post: post50, } export const byNodeId6 = { @@ -3714,16 +3394,10 @@ export const iteration2 = { * Run draft workflow loop node * * Run draft workflow loop node - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const post49 = oc +export const post51 = oc .route({ - deprecated: true, - description: - 'Run draft workflow loop node\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Run draft workflow loop node', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRun', @@ -3740,7 +3414,7 @@ export const post49 = oc .output(zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponse) export const run7 = { - post: post49, + post: post51, } export const byNodeId7 = { @@ -3755,7 +3429,7 @@ export const loop2 = { nodes: nodes6, } -export const get62 = oc +export const get59 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3768,11 +3442,11 @@ export const get62 = oc ) .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse) -export const candidates2 = { - get: get62, +export const candidates = { + get: get59, } -export const post50 = oc +export const post52 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3789,10 +3463,10 @@ export const post50 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse) export const impact = { - post: post50, + post: post52, } -export const post51 = oc +export const post53 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3809,10 +3483,10 @@ export const post51 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponse) export const saveToRoster = { - post: post51, + post: post53, } -export const post52 = oc +export const post54 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3828,11 +3502,11 @@ export const post52 = oc ) .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse) -export const validate2 = { - post: post52, +export const validate = { + post: post54, } -export const get63 = oc +export const get60 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3843,7 +3517,7 @@ export const get63 = oc .input(z.object({ params: zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerPath })) .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse) -export const put5 = oc +export const put4 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -3859,19 +3533,19 @@ export const put5 = oc ) .output(zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse) -export const agentComposer2 = { - get: get63, - put: put5, - candidates: candidates2, +export const agentComposer = { + get: get60, + put: put4, + candidates, impact, saveToRoster, - validate: validate2, + validate, } /** * Get last run result for draft workflow node */ -export const get64 = oc +export const get61 = oc .route({ description: 'Get last run result for draft workflow node', inputStructure: 'detailed', @@ -3884,23 +3558,17 @@ export const get64 = oc .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunResponse) export const lastRun = { - get: get64, + get: get61, } /** * Run draft workflow node * * Run draft workflow node - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const post53 = oc +export const post55 = oc .route({ - deprecated: true, - description: - 'Run draft workflow node\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Run draft workflow node', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraftNodesByNodeIdRun', @@ -3917,23 +3585,17 @@ export const post53 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdRunResponse) export const run8 = { - post: post53, + post: post55, } /** * Poll for trigger events and execute single node when event arrives * * Poll for trigger events and execute single node when event arrives - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const post54 = oc +export const post56 = oc .route({ - deprecated: true, - description: - 'Poll for trigger events and execute single node when event arrives\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Poll for trigger events and execute single node when event arrives', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRun', @@ -3945,7 +3607,7 @@ export const post54 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunResponse) export const run9 = { - post: post54, + post: post56, } export const trigger = { @@ -3955,7 +3617,7 @@ export const trigger = { /** * Delete all variables for a specific node */ -export const delete8 = oc +export const delete11 = oc .route({ description: 'Delete all variables for a specific node', inputStructure: 'detailed', @@ -3970,16 +3632,10 @@ export const delete8 = oc /** * Get variables for a specific node - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get65 = oc +export const get62 = oc .route({ - deprecated: true, - description: - 'Get variables for a specific node\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get variables for a specific node', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflowsDraftNodesByNodeIdVariables', @@ -3990,12 +3646,12 @@ export const get65 = oc .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesResponse) export const variables = { - delete: delete8, - get: get65, + delete: delete11, + get: get62, } export const byNodeId8 = { - agentComposer: agentComposer2, + agentComposer, lastRun, run: run8, trigger, @@ -4010,16 +3666,10 @@ export const nodes7 = { * Run draft workflow * * Run draft workflow - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const post55 = oc +export const post57 = oc .route({ - deprecated: true, - description: - 'Run draft workflow\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Run draft workflow', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraftRun', @@ -4036,21 +3686,15 @@ export const post55 = oc .output(zPostAppsByAppIdWorkflowsDraftRunResponse) export const run10 = { - post: post55, + post: post57, } /** * Server-Sent Events stream of inspector deltas for a draft workflow run. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get66 = oc +export const get63 = oc .route({ - deprecated: true, - description: - 'Server-Sent Events stream of inspector deltas for a draft workflow run.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Server-Sent Events stream of inspector deltas for a draft workflow run.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsEvents', @@ -4061,13 +3705,13 @@ export const get66 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsEventsResponse) export const events = { - get: get66, + get: get63, } /** * Full value for one declared output, including signed download URL for files. */ -export const get67 = oc +export const get64 = oc .route({ description: 'Full value for one declared output, including signed download URL for files.', inputStructure: 'detailed', @@ -4083,18 +3727,18 @@ export const get67 = oc ) .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponse) -export const preview5 = { - get: get67, +export const preview4 = { + get: get64, } export const byOutputName = { - preview: preview5, + preview: preview4, } /** * One node's declared outputs for a draft workflow run. */ -export const get68 = oc +export const get65 = oc .route({ description: 'One node\'s declared outputs for a draft workflow run.', inputStructure: 'detailed', @@ -4107,14 +3751,14 @@ export const get68 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdResponse) export const byNodeId9 = { - get: get68, + get: get65, byOutputName, } /** * Snapshot of every node's declared outputs for a draft workflow run. */ -export const get69 = oc +export const get66 = oc .route({ description: 'Snapshot of every node\'s declared outputs for a draft workflow run.', inputStructure: 'detailed', @@ -4127,7 +3771,7 @@ export const get69 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsResponse) export const nodeOutputs = { - get: get69, + get: get66, events, byNodeId: byNodeId9, } @@ -4142,16 +3786,10 @@ export const runs = { /** * Get system variables for workflow - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get70 = oc +export const get67 = oc .route({ - deprecated: true, - description: - 'Get system variables for workflow\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get system variables for workflow', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflowsDraftSystemVariables', @@ -4162,23 +3800,17 @@ export const get70 = oc .output(zGetAppsByAppIdWorkflowsDraftSystemVariablesResponse) export const systemVariables = { - get: get70, + get: get67, } /** * Poll for trigger events and execute full workflow when event arrives * * Poll for trigger events and execute full workflow when event arrives - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const post56 = oc +export const post58 = oc .route({ - deprecated: true, - description: - 'Poll for trigger events and execute full workflow when event arrives\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Poll for trigger events and execute full workflow when event arrives', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraftTriggerRun', @@ -4195,23 +3827,17 @@ export const post56 = oc .output(zPostAppsByAppIdWorkflowsDraftTriggerRunResponse) export const run11 = { - post: post56, + post: post58, } /** * Full workflow debug when the start node is a trigger * * Full workflow debug when the start node is a trigger - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const post57 = oc +export const post59 = oc .route({ - deprecated: true, - description: - 'Full workflow debug when the start node is a trigger\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Full workflow debug when the start node is a trigger', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraftTriggerRunAll', @@ -4228,7 +3854,7 @@ export const post57 = oc .output(zPostAppsByAppIdWorkflowsDraftTriggerRunAllResponse) export const runAll = { - post: post57, + post: post59, } export const trigger2 = { @@ -4238,16 +3864,10 @@ export const trigger2 = { /** * Reset a workflow variable to its default value - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const put6 = oc +export const put5 = oc .route({ - deprecated: true, - description: - 'Reset a workflow variable to its default value\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Reset a workflow variable to its default value', inputStructure: 'detailed', method: 'PUT', operationId: 'putAppsByAppIdWorkflowsDraftVariablesByVariableIdReset', @@ -4258,13 +3878,13 @@ export const put6 = oc .output(zPutAppsByAppIdWorkflowsDraftVariablesByVariableIdResetResponse) export const reset = { - put: put6, + put: put5, } /** * Delete a workflow variable */ -export const delete9 = oc +export const delete12 = oc .route({ description: 'Delete a workflow variable', inputStructure: 'detailed', @@ -4279,16 +3899,10 @@ export const delete9 = oc /** * Get a specific workflow variable - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get71 = oc +export const get68 = oc .route({ - deprecated: true, - description: - 'Get a specific workflow variable\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get a specific workflow variable', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflowsDraftVariablesByVariableId', @@ -4300,16 +3914,10 @@ export const get71 = oc /** * Update a workflow variable - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const patch2 = oc .route({ - deprecated: true, - description: - 'Update a workflow variable\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Update a workflow variable', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchAppsByAppIdWorkflowsDraftVariablesByVariableId', @@ -4325,8 +3933,8 @@ export const patch2 = oc .output(zPatchAppsByAppIdWorkflowsDraftVariablesByVariableIdResponse) export const byVariableId = { - delete: delete9, - get: get71, + delete: delete12, + get: get68, patch: patch2, reset, } @@ -4334,7 +3942,7 @@ export const byVariableId = { /** * Delete all draft workflow variables */ -export const delete10 = oc +export const delete13 = oc .route({ description: 'Delete all draft workflow variables', inputStructure: 'detailed', @@ -4351,16 +3959,10 @@ export const delete10 = oc * Get draft workflow * * Get draft workflow variables - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get72 = oc +export const get69 = oc .route({ - deprecated: true, - description: - 'Get draft workflow variables\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get draft workflow variables', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflowsDraftVariables', @@ -4377,8 +3979,8 @@ export const get72 = oc .output(zGetAppsByAppIdWorkflowsDraftVariablesResponse) export const variables2 = { - delete: delete10, - get: get72, + delete: delete13, + get: get69, byVariableId, } @@ -4386,16 +3988,10 @@ export const variables2 = { * Get draft workflow * * Get draft workflow for an application - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get73 = oc +export const get70 = oc .route({ - deprecated: true, - description: - 'Get draft workflow for an application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get draft workflow for an application', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflowsDraft', @@ -4410,16 +4006,10 @@ export const get73 = oc * Sync draft workflow * * Sync draft workflow configuration - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const post58 = oc +export const post60 = oc .route({ - deprecated: true, - description: - 'Sync draft workflow configuration\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Sync draft workflow configuration', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraft', @@ -4436,8 +4026,8 @@ export const post58 = oc .output(zPostAppsByAppIdWorkflowsDraftResponse) export const draft2 = { - get: get73, - post: post58, + get: get70, + post: post60, conversationVariables: conversationVariables2, environmentVariables, features, @@ -4456,16 +4046,10 @@ export const draft2 = { * Get published workflow * * Get published workflow for an application - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get74 = oc +export const get71 = oc .route({ - deprecated: true, - description: - 'Get published workflow for an application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get published workflow for an application', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflowsPublish', @@ -4478,16 +4062,9 @@ export const get74 = oc /** * Publish workflow - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const post59 = oc +export const post61 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdWorkflowsPublish', @@ -4504,22 +4081,16 @@ export const post59 = oc .output(zPostAppsByAppIdWorkflowsPublishResponse) export const publish = { - get: get74, - post: post59, + get: get71, + post: post61, } /** * Server-Sent Events stream of inspector deltas for a published workflow run. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get75 = oc +export const get72 = oc .route({ - deprecated: true, - description: - 'Server-Sent Events stream of inspector deltas for a published workflow run.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Server-Sent Events stream of inspector deltas for a published workflow run.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEvents', @@ -4530,13 +4101,13 @@ export const get75 = oc .output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEventsResponse) export const events2 = { - get: get75, + get: get72, } /** * Full value for one declared output of a published run. */ -export const get76 = oc +export const get73 = oc .route({ description: 'Full value for one declared output of a published run.', inputStructure: 'detailed', @@ -4556,18 +4127,18 @@ export const get76 = oc zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponse, ) -export const preview6 = { - get: get76, +export const preview5 = { + get: get73, } export const byOutputName2 = { - preview: preview6, + preview: preview5, } /** * One node's declared outputs for a published workflow run. */ -export const get77 = oc +export const get74 = oc .route({ description: 'One node\'s declared outputs for a published workflow run.', inputStructure: 'detailed', @@ -4580,14 +4151,14 @@ export const get77 = oc .output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdResponse) export const byNodeId10 = { - get: get77, + get: get74, byOutputName: byOutputName2, } /** * Snapshot of every node's declared outputs for a published workflow run. */ -export const get78 = oc +export const get75 = oc .route({ description: 'Snapshot of every node\'s declared outputs for a published workflow run.', inputStructure: 'detailed', @@ -4600,7 +4171,7 @@ export const get78 = oc .output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsResponse) export const nodeOutputs2 = { - get: get78, + get: get75, events: events2, byNodeId: byNodeId10, } @@ -4619,16 +4190,9 @@ export const published = { /** * Get webhook trigger for a node - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get79 = oc +export const get76 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflowsTriggersWebhook', @@ -4645,7 +4209,7 @@ export const get79 = oc .output(zGetAppsByAppIdWorkflowsTriggersWebhookResponse) export const webhook = { - get: get79, + get: get76, } export const triggers2 = { @@ -4654,16 +4218,10 @@ export const triggers2 = { /** * Restore a published workflow version into the draft workflow - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const post60 = oc +export const post62 = oc .route({ - deprecated: true, - description: - 'Restore a published workflow version into the draft workflow\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Restore a published workflow version into the draft workflow', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdWorkflowsByWorkflowIdRestore', @@ -4674,25 +4232,19 @@ export const post60 = oc .output(zPostAppsByAppIdWorkflowsByWorkflowIdRestoreResponse) export const restore = { - post: post60, + post: post62, } /** * Delete workflow - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const delete11 = oc +export const delete14 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteAppsByAppIdWorkflowsByWorkflowId', path: '/apps/{app_id}/workflows/{workflow_id}', + successStatus: 204, summary: 'Delete workflow', tags: ['console'], }) @@ -4703,16 +4255,10 @@ export const delete11 = oc * Update workflow attributes * * Update workflow by ID - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const patch3 = oc .route({ - deprecated: true, - description: - 'Update workflow by ID\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Update workflow by ID', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchAppsByAppIdWorkflowsByWorkflowId', @@ -4729,7 +4275,7 @@ export const patch3 = oc .output(zPatchAppsByAppIdWorkflowsByWorkflowIdResponse) export const byWorkflowId = { - delete: delete11, + delete: delete14, patch: patch3, restore, } @@ -4738,16 +4284,10 @@ export const byWorkflowId = { * Get published workflows * * Get all published workflows for an application - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get80 = oc +export const get77 = oc .route({ - deprecated: true, - description: - 'Get all published workflows for an application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get all published workflows for an application', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflows', @@ -4764,7 +4304,7 @@ export const get80 = oc .output(zGetAppsByAppIdWorkflowsResponse) export const workflows3 = { - get: get80, + get: get77, defaultWorkflowBlockConfigs, draft: draft2, publish, @@ -4778,7 +4318,7 @@ export const workflows3 = { * * Delete application */ -export const delete12 = oc +export const delete15 = oc .route({ description: 'Delete application', inputStructure: 'detailed', @@ -4796,16 +4336,10 @@ export const delete12 = oc * Get app detail * * Get application details - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get81 = oc +export const get78 = oc .route({ - deprecated: true, - description: - 'Get application details\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get application details', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppId', @@ -4820,16 +4354,10 @@ export const get81 = oc * Update app * * Update application details - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const put7 = oc +export const put6 = oc .route({ - deprecated: true, - description: - 'Update application details\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Update application details', inputStructure: 'detailed', method: 'PUT', operationId: 'putAppsByAppId', @@ -4841,14 +4369,10 @@ export const put7 = oc .output(zPutAppsByAppIdResponse) export const byAppId2 = { - delete: delete12, - get: get81, - put: put7, + delete: delete15, + get: get78, + put: put6, advancedChat, - agentComposer, - agentFeatures, - agentReferencingWorkflows, - agentWorkspace, agent, annotationReply, annotationSetting, @@ -4873,6 +4397,7 @@ export const byAppId2 = { server, site, siteEnable, + star, statistics, textToAudio, trace, @@ -4891,7 +4416,7 @@ export const byAppId2 = { * * Delete an API key for an app */ -export const delete13 = oc +export const delete16 = oc .route({ description: 'Delete an API key for an app', inputStructure: 'detailed', @@ -4906,7 +4431,7 @@ export const delete13 = oc .output(zDeleteAppsByResourceIdApiKeysByApiKeyIdResponse) export const byApiKeyId = { - delete: delete13, + delete: delete16, } /** @@ -4914,7 +4439,7 @@ export const byApiKeyId = { * * Get all API keys for an app */ -export const get82 = oc +export const get79 = oc .route({ description: 'Get all API keys for an app', inputStructure: 'detailed', @@ -4932,7 +4457,7 @@ export const get82 = oc * * Create a new API key for an app */ -export const post61 = oc +export const post63 = oc .route({ description: 'Create a new API key for an app', inputStructure: 'detailed', @@ -4947,8 +4472,8 @@ export const post61 = oc .output(zPostAppsByResourceIdApiKeysResponse) export const apiKeys = { - get: get82, - post: post61, + get: get79, + post: post63, byApiKeyId, } @@ -4958,16 +4483,10 @@ export const byResourceId = { /** * Refresh MCP server configuration and regenerate server code - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get83 = oc +export const get80 = oc .route({ - deprecated: true, - description: - 'Refresh MCP server configuration and regenerate server code\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Refresh MCP server configuration and regenerate server code', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByServerIdServerRefresh', @@ -4978,7 +4497,7 @@ export const get83 = oc .output(zGetAppsByServerIdServerRefreshResponse) export const refresh = { - get: get83, + get: get80, } export const server2 = { @@ -4994,7 +4513,7 @@ export const byServerId = { * * Get list of applications with pagination and filtering */ -export const get84 = oc +export const get81 = oc .route({ description: 'Get list of applications with pagination and filtering', inputStructure: 'detailed', @@ -5011,16 +4530,10 @@ export const get84 = oc * Create app * * Create a new application - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const post62 = oc +export const post64 = oc .route({ - deprecated: true, - description: - 'Create a new application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Create a new application', inputStructure: 'detailed', method: 'POST', operationId: 'postApps', @@ -5033,9 +4546,10 @@ export const post62 = oc .output(zPostAppsResponse) export const apps = { - get: get84, - post: post62, + get: get81, + post: post64, imports, + starred, workflows, byAppId: byAppId2, byResourceId, diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index 6f61e1cb0a9..8ccf899b451 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -16,30 +16,40 @@ export type CreateAppPayload = { description?: string | null icon?: string | null icon_background?: string | null - icon_type?: IconType - mode: 'advanced-chat' | 'agent' | 'agent-chat' | 'chat' | 'completion' | 'workflow' + icon_type?: IconType | null + mode: 'advanced-chat' | 'agent-chat' | 'chat' | 'completion' | 'workflow' name: string } -export type AppDetail = { +export type AppDetailWithSite = { access_mode?: string | null - app_model_config?: ModelConfig + active_config_is_published?: boolean + api_base_url?: string | null + app_id?: string | null + bound_agent_id?: string | null created_at?: number | null created_by?: string | null + deleted_tools?: Array description?: string | null enable_api: boolean enable_site: boolean icon?: string | null icon_background?: string | null + icon_type?: string | null + readonly icon_url: string | null id: string - mode_compatible_with_agent: string + max_active_requests?: number | null + mode: string + model_config?: ModelConfig | null name: string + role?: string | null + site?: Site | null tags?: Array - tracing?: JsonValue + tracing?: JsonValue | null updated_at?: number | null updated_by?: string | null use_icon_as_answer_icon?: boolean | null - workflow?: WorkflowPartial + workflow?: WorkflowPartial | null } export type AppImportPayload = { @@ -76,38 +86,11 @@ export type WorkflowOnlineUsersResponse = { data: Array } -export type AppDetailWithSite = { - access_mode?: string | null - api_base_url?: string | null - app_model_config?: ModelConfig - bound_agent_id?: string | null - created_at?: number | null - created_by?: string | null - deleted_tools?: Array - description?: string | null - enable_api: boolean - enable_site: boolean - icon?: string | null - icon_background?: string | null - icon_type?: string | null - id: string - max_active_requests?: number | null - mode_compatible_with_agent: string - name: string - site?: Site - tags?: Array - tracing?: JsonValue - updated_at?: number | null - updated_by?: string | null - use_icon_as_answer_icon?: boolean | null - workflow?: WorkflowPartial -} - export type UpdateAppPayload = { description?: string | null icon?: string | null icon_background?: string | null - icon_type?: IconType + icon_type?: IconType | null max_active_requests?: number | null name: string use_icon_as_answer_icon?: boolean | null @@ -134,6 +117,25 @@ export type HumanInputFormPreviewPayload = { } } +export type HumanInputFormPreviewResponse = { + actions?: Array<{ + [key: string]: unknown + }> + display_in_ui?: boolean | null + expiration_time?: number | null + form_content: string + form_id: string + form_token?: string | null + inputs?: Array<{ + [key: string]: unknown + }> + node_id: string + node_title: string + resolved_default_values?: { + [key: string]: unknown + } +} + export type HumanInputFormSubmitPayload = { action: string form_inputs: { @@ -144,12 +146,18 @@ export type HumanInputFormSubmitPayload = { } } +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 @@ -168,85 +176,91 @@ export type AdvancedChatWorkflowRunPayload = { query?: string } -export type AgentAppComposerResponse = { - active_config_snapshot: AgentConfigSnapshotSummaryResponse - agent: AgentComposerAgentResponse - agent_soul: AgentSoulConfig - save_options: Array - validation?: ComposerValidationFindingsResponse - variant: string +export type AgentDriveListResponse = { + items?: Array } -export type ComposerSavePayload = { - agent_soul?: AgentSoulConfig - binding?: ComposerBindingPayload - client_revision_id?: string | null - idempotency_key?: string | null - new_agent_name?: string | null - node_job?: WorkflowNodeJobConfig - save_strategy: ComposerSaveStrategy - soul_lock?: ComposerSoulLockPayload - variant: ComposerVariant - version_note?: string | null +export type AgentDriveDownloadResponse = { + url: string } -export type AgentComposerCandidatesResponse = { - allowed_node_job_candidates?: AgentComposerNodeJobCandidatesResponse - allowed_soul_candidates?: AgentComposerSoulCandidatesResponse - capabilities?: ComposerCandidateCapabilities - truncated?: boolean - variant: ComposerVariant -} - -export type AgentComposerValidateResponse = { - errors?: Array - knowledge_retrieval_placeholder?: Array - result: string - warnings?: Array -} - -export type AgentAppFeaturesPayload = { - opening_statement?: string | null - retriever_resource?: AgentFeatureToggleConfig - sensitive_word_avoidance?: AgentSensitiveWordAvoidanceFeatureConfig - speech_to_text?: AgentFeatureToggleConfig - suggested_questions?: Array | null - suggested_questions_after_answer?: AgentSuggestedQuestionsAfterAnswerFeatureConfig - text_to_speech?: AgentTextToSpeechFeatureConfig -} - -export type SimpleResultResponse = { - result: string -} - -export type AgentReferencingWorkflowsResponse = { - data?: Array -} - -export type WorkspaceListResponse = { - entries?: Array - path: string - truncated?: boolean -} - -export type WorkspacePreviewResponse = { +export type AgentDrivePreviewResponse = { binary: boolean - path: string - size: number + key: string + size?: number | null text?: string | null truncated: boolean } +export type AgentDriveDeleteResponse = { + config_version_id?: string | null + removed_keys?: Array + result: string +} + +export type AgentDriveFilePayload = { + upload_file_id: string +} + +export type AgentDriveFileCommitResponse = { + config_version_id?: string | null + file: AgentDriveFileResponse +} + +export type AgentLogResponse = { + files?: Array + iterations: Array + meta: AgentLogMetaResponse +} + +export type AgentSkillStandardizeResponse = { + manifest: SkillManifest + skill: AgentSkillRefConfig +} + +export type AgentSkillUploadResponse = { + manifest: SkillManifest + skill: AgentSkillRefConfig +} + +export type SkillToolInferenceResult = { + cli_tools?: Array + inferable: boolean + reason?: string | null +} + export type AnnotationReplyPayload = { embedding_model_name: string embedding_provider_name: string score_threshold: number } +export type AnnotationJobStatusResponse = { + error_msg?: string | null + job_id?: string | null + job_status?: string | null + record_count?: number | null +} + +export type AnnotationSettingResponse = { + embedding_model?: AnnotationEmbeddingModelResponse | null + enabled: boolean + id?: string | null + score_threshold?: number | null +} + export type AnnotationSettingUpdatePayload = { score_threshold: number } +export type AnnotationList = { + data: Array + has_more: boolean + limit: number + page: number + total: number +} + export type CreateAnnotationPayload = { annotation_reply?: { [key: string]: unknown @@ -258,7 +272,7 @@ export type CreateAnnotationPayload = { } export type Annotation = { - content?: string | null + answer?: string | null created_at?: number | null hit_count?: number | null id: string @@ -294,6 +308,27 @@ export type AppApiStatusPayload = { enable_api: boolean } +export type AppDetail = { + access_mode?: string | null + app_model_config?: ModelConfig | null + created_at?: number | null + created_by?: string | null + description?: string | null + enable_api: boolean + enable_site: boolean + icon?: string | null + icon_background?: string | null + id: string + mode_compatible_with_agent: string + name: string + tags?: Array + tracing?: JsonValue | null + updated_at?: number | null + updated_by?: string | null + use_icon_as_answer_icon?: boolean | null + workflow?: WorkflowPartial | null +} + export type AudioTranscriptResponse = { text: string } @@ -307,7 +342,7 @@ export type ConversationWithSummaryPagination = { } export type ConversationDetail = { - admin_feedback_stats?: FeedbackStat + admin_feedback_stats?: FeedbackStat | null annotated: boolean created_at?: number | null from_account_id?: string | null @@ -316,10 +351,10 @@ export type ConversationDetail = { id: string introduction?: string | null message_count: number - model_config?: ModelConfig + model_config?: ModelConfig | null status: string updated_at?: number | null - user_feedback_stats?: FeedbackStat + user_feedback_stats?: FeedbackStat | null } export type MessageInfiniteScrollPaginationResponse = { @@ -332,6 +367,10 @@ export type SuggestedQuestionsResponse = { data: Array } +export type SimpleResultResponse = { + result: string +} + export type ConversationPagination = { has_next: boolean items: Array @@ -342,12 +381,12 @@ export type ConversationPagination = { export type ConversationMessageDetail = { created_at?: number | null - first_message?: MessageDetail + first_message?: MessageDetail | null from_account_id?: string | null from_end_user_id?: string | null from_source: string id: string - model_config?: ModelConfig + model_config?: ModelConfig | null status: string } @@ -387,7 +426,7 @@ export type CopyAppPayload = { description?: string | null icon?: string | null icon_background?: string | null - icon_type?: IconType + icon_type?: IconType | null name?: string | null } @@ -401,16 +440,18 @@ export type MessageFeedbackPayload = { rating?: 'dislike' | 'like' | null } +export type TextFileResponse = string + export type AppIconPayload = { icon?: string | null icon_background?: string | null - icon_type?: IconType + icon_type?: IconType | null } export type MessageDetailResponse = { agent_thoughts?: Array - annotation?: ConversationAnnotation - annotation_hit_history?: ConversationAnnotationHitHistory + annotation?: ConversationAnnotation | null + annotation_hit_history?: ConversationAnnotationHitHistory | null answer_tokens?: number | null conversation_id: string created_at?: number | null @@ -424,9 +465,9 @@ export type MessageDetailResponse = { inputs: { [key: string]: JsonValue } - message?: JsonValue + message?: JsonValue | null message_files?: Array - message_metadata_dict?: JsonValue + message_metadata_dict?: JsonValue | null message_tokens?: number | null parent_message_id?: string | null provider_response_latency?: number | null @@ -480,7 +521,12 @@ export type AppMcpServerResponse = { description: string id: string name: string - parameters: unknown + parameters: + | { + [key: string]: unknown + } + | Array + | string server_code: string status: AppMcpServerStatus updated_at?: number | null @@ -543,6 +589,38 @@ export type AppSiteStatusPayload = { enable_site: boolean } +export type AverageResponseTimeStatisticResponse = { + data: Array +} + +export type AverageSessionInteractionStatisticResponse = { + data: Array +} + +export type DailyConversationStatisticResponse = { + data: Array +} + +export type DailyTerminalStatisticResponse = { + data: Array +} + +export type DailyMessageStatisticResponse = { + data: Array +} + +export type DailyTokenCostStatisticResponse = { + data: Array +} + +export type TokensPerSecondStatisticResponse = { + data: Array +} + +export type UserSatisfactionRateStatisticResponse = { + data: Array +} + export type TextToSpeechPayload = { message_id?: string | null streaming?: boolean | null @@ -550,13 +628,35 @@ export type TextToSpeechPayload = { voice?: string | null } +export type AudioBinaryResponse = Blob | File + +export type TextToSpeechVoiceListResponse = Array<{ + [key: string]: unknown +}> + +export type AppTraceResponse = { + enabled: boolean + tracing_provider?: string | null +} + export type AppTracePayload = { enabled: boolean tracing_provider?: string | null } -export type TraceProviderQuery = { - tracing_provider: string +export type TraceAppConfigResponse = { + app_id?: string | null + created_at?: string | null + error?: string | null + has_not_configured?: boolean | null + id?: string | null + is_active?: boolean | null + result?: string | null + tracing_config?: { + [key: string]: unknown + } | null + tracing_provider?: string | null + updated_at?: string | null } export type TraceConfigPayload = { @@ -611,8 +711,8 @@ export type WorkflowRunPaginationResponse = { export type WorkflowRunDetailResponse = { created_at?: number | null - created_by_account?: SimpleAccount - created_by_end_user?: SimpleEndUser + created_by_account?: SimpleAccount | null + created_by_end_user?: SimpleEndUser | null created_by_role?: string | null elapsed_time?: number | null error?: string | null @@ -638,6 +738,30 @@ export type WorkflowRunNodeExecutionListResponse = { data: Array } +export type SandboxListResponse = { + entries?: Array + path: string + truncated?: boolean +} + +export type SandboxReadResponse = { + binary: boolean + path: string + size?: number | null + text?: string | null + truncated: boolean +} + +export type WorkflowAgentSandboxUploadPayload = { + node_execution_id?: string | null + path: string +} + +export type SandboxUploadResponse = { + file: SandboxToolFileResponse + path: string +} + export type WorkflowCommentBasicList = { data: Array } @@ -662,7 +786,7 @@ export type WorkflowCommentDetail = { content: string created_at?: number | null created_by: string - created_by_account?: WorkflowCommentAccount + created_by_account?: WorkflowCommentAccount | null id: string mentions: Array position_x: number @@ -671,7 +795,7 @@ export type WorkflowCommentDetail = { resolved: boolean resolved_at?: number | null resolved_by?: string | null - resolved_by_account?: WorkflowCommentAccount + resolved_by_account?: WorkflowCommentAccount | null updated_at?: number | null } @@ -709,6 +833,22 @@ export type WorkflowCommentResolve = { resolved_by?: string | null } +export type WorkflowAverageAppInteractionStatisticResponse = { + data: Array +} + +export type WorkflowDailyRunsStatisticResponse = { + data: Array +} + +export type WorkflowDailyTerminalsStatisticResponse = { + data: Array +} + +export type WorkflowDailyTokenCostStatisticResponse = { + data: Array +} + export type WorkflowPaginationResponse = { has_more: boolean items: Array @@ -716,10 +856,18 @@ 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 + created_by?: SimpleAccount | null environment_variables: Array features: { [key: string]: unknown @@ -734,7 +882,7 @@ export type WorkflowResponse = { rag_pipeline_variables: Array tool_published: boolean updated_at: number - updated_by?: SimpleAccount + updated_by?: SimpleAccount | null version: string } @@ -770,6 +918,10 @@ export type ConversationVariableUpdatePayload = { }> } +export type EnvironmentVariableListResponse = { + items: Array +} + export type EnvironmentVariableUpdatePayload = { environment_variables: Array<{ [key: string]: unknown @@ -789,33 +941,65 @@ export type HumanInputDeliveryTestPayload = { } } +export type EmptyObjectResponse = { + [key: string]: unknown +} + export type WorkflowAgentComposerResponse = { - active_config_snapshot?: AgentConfigSnapshotSummaryResponse - agent?: AgentComposerAgentResponse + active_config_snapshot?: AgentConfigSnapshotSummaryResponse | null + agent?: AgentComposerAgentResponse | null agent_soul: AgentSoulConfig app_id?: string | null - binding?: AgentComposerBindingResponse + binding?: AgentComposerBindingResponse | null effective_declared_outputs?: Array - impact_summary?: AgentComposerImpactResponse + impact_summary?: AgentComposerImpactResponse | null node_id?: string | null node_job: WorkflowNodeJobConfig save_options: Array soul_lock: AgentComposerSoulLockResponse - validation?: ComposerValidationFindingsResponse - variant: string + validation?: ComposerValidationFindingsResponse | null + variant: 'workflow' workflow_id?: string | null } +export type ComposerSavePayload = { + agent_soul?: AgentSoulConfig | null + binding?: ComposerBindingPayload | null + client_revision_id?: string | null + idempotency_key?: string | null + new_agent_name?: string | null + node_job?: WorkflowNodeJobConfig | null + save_strategy: ComposerSaveStrategy + soul_lock?: ComposerSoulLockPayload + variant: ComposerVariant + version_note?: string | null +} + +export type AgentComposerCandidatesResponse = { + allowed_node_job_candidates?: AgentComposerNodeJobCandidatesResponse + allowed_soul_candidates?: AgentComposerSoulCandidatesResponse + capabilities?: ComposerCandidateCapabilities + truncated?: boolean + variant: ComposerVariant +} + export type AgentComposerImpactResponse = { bindings?: Array current_snapshot_id?: string | null workflow_node_count: number } +export type AgentComposerValidateResponse = { + errors?: Array + knowledge_retrieval_placeholder?: Array + result: 'success' + warnings?: Array +} + export type WorkflowRunNodeExecutionResponse = { created_at?: number | null - created_by_account?: SimpleAccount - created_by_end_user?: SimpleEndUser + created_by_account?: SimpleAccount | null + created_by_end_user?: SimpleEndUser | null created_by_role?: string | null elapsed_time?: number | null error?: string | null @@ -864,6 +1048,8 @@ export type WorkflowRunSnapshotView = { workflow_run_status: WorkflowExecutionStatus } +export type EventStreamResponse = string + export type NodeOutputsView = { node_completed_at?: string | null node_display_name: string @@ -878,7 +1064,7 @@ export type OutputPreviewView = { node_id: string output_name: string status: NodeOutputStatus - type?: DeclaredOutputType + type?: DeclaredOutputType | null value?: unknown } @@ -892,9 +1078,7 @@ export type DraftWorkflowTriggerRunAllPayload = { export type WorkflowDraftVariableListWithoutValue = { items?: Array - total?: { - [key: string]: unknown - } + total?: number } export type WorkflowDraftVariable = { @@ -908,16 +1092,23 @@ export type WorkflowDraftVariable = { name?: string selector?: Array type?: string - value?: { - [key: string]: unknown - } + value?: + | string + | number + | number + | boolean + | { + [key: string]: unknown + } + | Array + | null value_type?: string visible?: boolean } export type WorkflowDraftVariableUpdatePayload = { name?: string | null - value?: unknown + value?: unknown | null } export type PublishWorkflowPayload = { @@ -926,6 +1117,11 @@ export type PublishWorkflowPayload = { } | null } +export type WorkflowPublishResponse = { + created_at: number + result: string +} + export type WebhookTriggerResponse = { created_at?: string | null id: string @@ -940,6 +1136,12 @@ export type WorkflowUpdatePayload = { marked_name?: string | null } +export type WorkflowRestoreResponse = { + hash: string + result: string + updated_at: number +} + export type ApiKeyList = { data: Array } @@ -954,8 +1156,11 @@ export type ApiKeyItem = { export type AppPartial = { access_mode?: string | null - app_model_config?: ModelConfigPartial + active_config_is_published?: boolean + app_id?: string | null + app_model_config?: ModelConfigPartial | null author_name?: string | null + bound_agent_id?: string | null create_user_name?: string | null created_at?: number | null created_by?: string | null @@ -965,18 +1170,26 @@ export type AppPartial = { icon_background?: string | null icon_type?: string | null id: string + is_starred?: boolean max_active_requests?: number | null mode_compatible_with_agent: string name: string + role?: string | null tags?: Array updated_at?: number | null updated_by?: string | null use_icon_as_answer_icon?: boolean | null - workflow?: WorkflowPartial + workflow?: WorkflowPartial | null } export type IconType = 'emoji' | 'image' | 'link' +export type DeletedTool = { + provider_id: string + tool_name: string + type: string +} + export type ModelConfig = { completion_params?: { [key: string]: unknown @@ -986,13 +1199,39 @@ export type ModelConfig = { provider: string } +export type Site = { + chat_color_theme?: string | null + chat_color_theme_inverted: boolean + copyright?: string | null + custom_disclaimer?: string | null + default_language: string + description?: string | null + icon?: string | null + icon_background?: string | null + icon_type?: string | null + readonly icon_url: string | null + privacy_policy?: string | null + show_workflow_steps: boolean + title: string + use_icon_as_answer_icon: boolean +} + export type Tag = { id: string name: string type: string } -export type JsonValue = unknown +export type JsonValue + = | string + | number + | number + | boolean + | { + [key: string]: unknown + } + | Array + | null export type WorkflowPartial = { created_at?: number | null @@ -1007,7 +1246,7 @@ export type ImportStatus = 'completed' | 'completed-with-warnings' | 'failed' | export type PluginDependency = { current_identifier?: string | null type: Type - value: unknown + value: Github | Marketplace | Package } export type WorkflowOnlineUsersByApp = { @@ -1015,41 +1254,10 @@ export type WorkflowOnlineUsersByApp = { users: Array } -export type DeletedTool = { - provider_id: string - tool_name: string - type: string -} - -export type Site = { - app_base_url?: string | null - chat_color_theme?: string | null - chat_color_theme_inverted?: boolean | null - code?: string | null - copyright?: string | null - created_at?: number | null - created_by?: string | null - custom_disclaimer?: string | null - customize_domain?: string | null - customize_token_strategy?: string | null - default_language?: string | null - description?: string | null - icon?: string | null - icon_background?: string | null - icon_type?: unknown - privacy_policy?: string | null - prompt_public?: boolean | null - show_workflow_steps?: boolean | null - title?: string | null - updated_at?: number | null - updated_by?: string | null - use_icon_as_answer_icon?: boolean | null -} - export type AdvancedChatWorkflowRunForListResponse = { conversation_id?: string | null created_at?: number | null - created_by_account?: SimpleAccount + created_by_account?: SimpleAccount | null elapsed_time?: number | null exceptions_count?: number | null finished_at?: number | null @@ -1062,161 +1270,93 @@ export type AdvancedChatWorkflowRunForListResponse = { version?: string | null } -export type AgentConfigSnapshotSummaryResponse = { - agent_id?: string | null +export type AgentDriveItemResponse = { created_at?: number | null - created_by?: string | null - id: string - summary?: string | null - version: number - version_note?: string | null + file_kind: string + hash?: string | null + key: string + mime_type?: string | null + size?: number | null } -export type AgentComposerAgentResponse = { - active_config_snapshot_id?: string | null - description: string - id: string +export type AgentDriveFileResponse = { + drive_key: string + file_id: string + mime_type?: string | null name: string - scope: AgentScope - status: AgentStatus + size?: number | null } -export type AgentSoulConfig = { - app_features?: AgentSoulAppFeaturesConfig - app_variables?: Array - env?: AgentSoulEnvConfig - human?: AgentSoulHumanConfig - knowledge?: AgentSoulKnowledgeConfig - memory?: AgentSoulMemoryConfig - misc_legacy?: AgentSoulAppFeaturesConfig - model?: AgentSoulModelConfig - prompt?: AgentSoulPromptConfig - sandbox?: AgentSoulSandboxConfig - schema_version?: number - skills_files?: AgentSoulSkillsFilesConfig - tools?: AgentSoulToolsConfig +export type AgentIterationLogResponse = { + created_at: string + files?: Array + thought?: string | null + tokens: number + tool_calls: Array + tool_raw: { + [key: string]: unknown + } } -export type ComposerSaveStrategy - = | 'node_job_only' - | 'save_as_new_agent' - | 'save_as_new_version' - | 'save_to_current_version' - | 'save_to_roster' - -export type ComposerValidationFindingsResponse = { - knowledge_retrieval_placeholder?: Array - warnings?: Array +export type AgentLogMetaResponse = { + agent_mode: string + elapsed_time?: number | null + executor: string + iterations: number + start_time: string + status: string + total_tokens: number } -export type ComposerBindingPayload = { - agent_id?: string | null - binding_type: 'inline_agent' | 'roster_agent' - current_snapshot_id?: string | null -} - -export type WorkflowNodeJobConfig = { - declared_outputs?: Array - human_contacts?: Array - metadata?: WorkflowNodeJobMetadata - mode?: WorkflowNodeJobMode - previous_node_output_refs?: Array - schema_version?: number - workflow_prompt?: string -} - -export type ComposerSoulLockPayload = { - locked?: boolean - unlocked_from_version_id?: string | null -} - -export type ComposerVariant = 'agent_app' | 'workflow' - -export type AgentComposerNodeJobCandidatesResponse = { - declare_output_types?: Array - human_contacts?: Array - previous_node_outputs?: Array -} - -export type AgentComposerSoulCandidatesResponse = { - cli_tools?: Array - dify_tools?: Array - human_contacts?: Array - knowledge_datasets?: Array - skills_files?: Array -} - -export type ComposerCandidateCapabilities = { - human_roster_available?: boolean -} - -export type ComposerKnowledgePlaceholderResponse = { - id: string - placeholder_name: string -} - -export type ComposerValidationWarningResponse = { - code: string - id?: string | null - kind?: string | null - message?: string | null - surface?: string | null -} - -export type AgentFeatureToggleConfig = { - enabled?: boolean - [key: string]: unknown -} - -export type AgentSensitiveWordAvoidanceFeatureConfig = { - config?: AgentModerationProviderConfig - enabled?: boolean - type?: string | null - [key: string]: unknown -} - -export type AgentSuggestedQuestionsAfterAnswerFeatureConfig = { - enabled?: boolean - model?: AgentSoulModelConfig - prompt?: string | null - [key: string]: unknown -} - -export type AgentTextToSpeechFeatureConfig = { - autoPlay?: string | null - enabled?: boolean - language?: string | null - voice?: string | null - [key: string]: unknown -} - -export type AgentReferencingWorkflowResponse = { - app_id: string - app_mode: string - app_name: string - node_ids?: Array - workflow_id: string -} - -export type WorkspaceFileEntryResponse = { - mtime: number +export type SkillManifest = { + description: string + entry_path: string + files: Array + hash: string name: string size: number - type: 'dir' | 'file' | 'symlink' +} + +export type AgentSkillRefConfig = { + description?: string | null + file_id?: string | null + full_archive_file_id?: string | null + full_archive_key?: string | null + id?: string | null + manifest_files?: Array | null + name?: string | null + path?: string | null + skill_md_file_id?: string | null + skill_md_key?: string | null + [key: string]: unknown +} + +export type CliToolSuggestion = { + command?: string + description?: string + env_suggestions?: Array + inferred_from?: string + install_commands?: Array + name: string +} + +export type AnnotationEmbeddingModelResponse = { + embedding_model_name?: string | null + embedding_provider_name?: string | null } export type AnnotationHitHistory = { - annotation_content?: string | null - annotation_question?: string | null created_at?: number | null id: string + match?: string | null question?: string | null + response?: string | null score?: number | null source?: string | null } export type ConversationWithSummary = { - admin_feedback_stats?: FeedbackStat + admin_feedback_stats?: FeedbackStat | null annotated: boolean created_at?: number | null from_account_id?: string | null @@ -1226,14 +1366,14 @@ export type ConversationWithSummary = { from_source: string id: string message_count: number - model_config?: SimpleModelConfig + model_config?: SimpleModelConfig | null name: string read_at?: number | null status: string - status_count?: StatusCount + status_count?: StatusCount | null summary_or_query: string updated_at?: number | null - user_feedback_stats?: FeedbackStat + user_feedback_stats?: FeedbackStat | null } export type FeedbackStat = { @@ -1242,27 +1382,27 @@ export type FeedbackStat = { } export type Conversation = { - admin_feedback_stats?: FeedbackStat - annotation?: ConversationAnnotation + admin_feedback_stats?: FeedbackStat | null + annotation?: ConversationAnnotation | null created_at?: number | null - first_message?: SimpleMessageDetail + 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 - model_config?: SimpleModelConfig + model_config?: SimpleModelConfig | null read_at?: number | null status: string updated_at?: number | null - user_feedback_stats?: FeedbackStat + user_feedback_stats?: FeedbackStat | null } export type MessageDetail = { agent_thoughts: Array - annotation?: ConversationAnnotation - annotation_hit_history?: ConversationAnnotationHitHistory + annotation?: ConversationAnnotation | null + annotation_hit_history?: ConversationAnnotationHitHistory | null answer_tokens: number conversation_id: string created_at?: number | null @@ -1313,7 +1453,7 @@ export type AgentThought = { } export type ConversationAnnotation = { - account?: SimpleAccount + account?: SimpleAccount | null content: string created_at?: number | null id: string @@ -1321,14 +1461,14 @@ export type ConversationAnnotation = { } export type ConversationAnnotationHitHistory = { - annotation_create_account?: SimpleAccount + annotation_create_account?: SimpleAccount | null created_at?: number | null id: string } export type HumanInputContent = { - form_definition?: HumanInputFormDefinition - form_submission_data?: HumanInputFormSubmissionData + form_definition?: HumanInputFormDefinition | null + form_submission_data?: HumanInputFormSubmissionData | null submitted: boolean type?: ExecutionContentType workflow_run_id: string @@ -1336,7 +1476,7 @@ export type HumanInputContent = { export type Feedback = { content?: string | null - from_account?: SimpleAccount + from_account?: SimpleAccount | null from_end_user_id?: string | null from_source: string rating: string @@ -1356,29 +1496,71 @@ export type MessageFile = { export type AppMcpServerStatus = 'active' | 'inactive' | 'normal' +export type AverageResponseTimeStatisticItem = { + date: string + latency: number +} + +export type AverageSessionInteractionStatisticItem = { + date: string + interactions: number +} + +export type DailyConversationStatisticItem = { + conversation_count: number + date: string +} + +export type DailyTerminalStatisticItem = { + date: string + terminal_count: number +} + +export type DailyMessageStatisticItem = { + date: string + message_count: number +} + +export type DailyTokenCostStatisticItem = { + currency: string + date: string + token_count: number + total_price: string | number +} + +export type TokensPerSecondStatisticItem = { + date: string + tps: number +} + +export type UserSatisfactionRateStatisticItem = { + date: string + rate: number +} + export type WorkflowAppLogPartialResponse = { created_at?: number | null - created_by_account?: SimpleAccount - created_by_end_user?: SimpleEndUser + created_by_account?: SimpleAccount | null + created_by_end_user?: SimpleEndUser | null created_by_role?: string | null created_from?: string | null details?: unknown id: string - workflow_run?: WorkflowRunForLogResponse + workflow_run?: WorkflowRunForLogResponse | null } export type WorkflowArchivedLogPartialResponse = { created_at?: number | null - created_by_account?: SimpleAccount - created_by_end_user?: SimpleEndUser + created_by_account?: SimpleAccount | null + created_by_end_user?: SimpleEndUser | null id: string trigger_metadata?: unknown - workflow_run?: WorkflowRunForArchivedLogResponse + workflow_run?: WorkflowRunForArchivedLogResponse | null } export type WorkflowRunForListResponse = { created_at?: number | null - created_by_account?: SimpleAccount + created_by_account?: SimpleAccount | null elapsed_time?: number | null exceptions_count?: number | null finished_at?: number | null @@ -1403,11 +1585,23 @@ export type SimpleEndUser = { type: string } +export type SandboxFileEntryResponse = { + mtime?: number | null + name: string + size?: number | null + type: 'dir' | 'file' | 'other' | 'symlink' +} + +export type SandboxToolFileResponse = { + reference: string + transfer_method?: 'tool_file' +} + export type WorkflowCommentBasic = { content: string created_at?: number | null created_by: string - created_by_account?: WorkflowCommentAccount + created_by_account?: WorkflowCommentAccount | null id: string mention_count: number participants: Array @@ -1417,7 +1611,7 @@ export type WorkflowCommentBasic = { resolved: boolean resolved_at?: number | null resolved_by?: string | null - resolved_by_account?: WorkflowCommentAccount + resolved_by_account?: WorkflowCommentAccount | null updated_at?: number | null } @@ -1441,7 +1635,7 @@ export type WorkflowCommentAccount = { } export type WorkflowCommentMention = { - mentioned_user_account?: WorkflowCommentAccount + mentioned_user_account?: WorkflowCommentAccount | null mentioned_user_id: string reply_id?: string | null } @@ -1450,17 +1644,35 @@ export type WorkflowCommentReply = { content: string created_at?: number | null created_by: string - created_by_account?: WorkflowCommentAccount + created_by_account?: WorkflowCommentAccount | null id: string } +export type WorkflowAverageAppInteractionStatisticItem = { + date: string + interactions: number +} + +export type WorkflowDailyRunsStatisticItem = { + date: string + runs: number +} + +export type WorkflowDailyTerminalsStatisticItem = { + date: string + terminal_count: number +} + +export type WorkflowDailyTokenCostStatisticItem = { + date: string + token_count: number +} + export type WorkflowConversationVariableResponse = { description: string id: string name: string - value: { - [key: string]: unknown - } + value: unknown value_type: string } @@ -1468,9 +1680,7 @@ export type WorkflowEnvironmentVariableResponse = { description: string id: string name: string - value: { - [key: string]: unknown - } + value: unknown value_type: string } @@ -1479,9 +1689,7 @@ export type PipelineVariableResponse = { allowed_file_types?: Array | null allowed_file_upload_methods?: Array | null belong_to_node_id: string - default_value?: { - [key: string]: unknown - } + default_value?: unknown label: string max_length?: number | null options?: Array | null @@ -1493,6 +1701,54 @@ export type PipelineVariableResponse = { variable: string } +export type EnvironmentVariableItemResponse = { + description?: string | null + editable: boolean + edited: boolean + id: string + name: string + selector: Array + type: string + value: unknown + value_type: string + visible: boolean +} + +export type AgentConfigSnapshotSummaryResponse = { + agent_id?: string | null + created_at?: number | null + created_by?: string | null + id: string + summary?: string | null + version: number + version_note?: string | null +} + +export type AgentComposerAgentResponse = { + active_config_snapshot_id?: string | null + description: string + id: string + name: string + scope: AgentScope + status: AgentStatus +} + +export type AgentSoulConfig = { + app_features?: AgentSoulAppFeaturesConfig + app_variables?: Array + env?: AgentSoulEnvConfig + human?: AgentSoulHumanConfig + knowledge?: AgentSoulKnowledgeConfig + memory?: AgentSoulMemoryConfig + misc_legacy?: AgentSoulAppFeaturesConfig + model?: AgentSoulModelConfig | null + prompt?: AgentSoulPromptConfig + sandbox?: AgentSoulSandboxConfig + schema_version?: number + skills_files?: AgentSoulSkillsFilesConfig + tools?: AgentSoulToolsConfig +} + export type AgentComposerBindingResponse = { agent_id?: string | null binding_type: WorkflowAgentBindingType @@ -1503,29 +1759,122 @@ export type AgentComposerBindingResponse = { } export type DeclaredOutputConfig = { - array_item?: DeclaredArrayItem - check?: DeclaredOutputCheckConfig + array_item?: DeclaredArrayItem | null + check?: DeclaredOutputCheckConfig | null + children?: Array<{ + array_item?: { + children?: Array<{ + [key: string]: unknown + }> + description?: string | null + type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' + [key: string]: unknown + } + children?: Array<{ + [key: string]: unknown + }> + description?: string | null + file?: { + [key: string]: unknown + } + name: string + required?: boolean + type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' + }> description?: string | null failure_strategy?: DeclaredOutputFailureStrategy - file?: DeclaredOutputFileConfig + file?: DeclaredOutputFileConfig | null id?: string | null name: string required?: boolean type: DeclaredOutputType } +export type WorkflowNodeJobConfig = { + declared_outputs?: Array + human_contacts?: Array + metadata?: WorkflowNodeJobMetadata + mode?: WorkflowNodeJobMode + previous_node_output_refs?: Array + schema_version?: number + workflow_prompt?: string +} + +export type ComposerSaveStrategy + = | 'node_job_only' + | 'save_as_new_agent' + | 'save_as_new_version' + | 'save_to_current_version' + | 'save_to_roster' + export type AgentComposerSoulLockResponse = { can_unlock?: boolean locked: boolean reason?: string | null } +export type ComposerValidationFindingsResponse = { + knowledge_retrieval_placeholder?: Array + warnings?: Array +} + +export type ComposerBindingPayload = { + agent_id?: string | null + binding_type: 'inline_agent' | 'roster_agent' + current_snapshot_id?: string | null +} + +export type ComposerSoulLockPayload = { + locked?: boolean + unlocked_from_version_id?: string | null +} + +export type ComposerVariant = 'agent_app' | 'workflow' + +export type AgentComposerNodeJobCandidatesResponse = { + declare_output_types?: Array + human_contacts?: Array + previous_node_outputs?: Array +} + +export type AgentComposerSoulCandidatesResponse = { + cli_tools?: Array + dify_tools?: Array + human_contacts?: Array + knowledge_datasets?: Array + skills_files?: Array< + | ({ + kind: 'skill' + } & AgentComposerSkillCandidateResponse) + | ({ + kind: 'file' + } & AgentComposerFileCandidateResponse) + > +} + +export type ComposerCandidateCapabilities = { + human_roster_available?: boolean +} + export type AgentComposerImpactBindingResponse = { app_id: string node_id: string workflow_id: string } +export type ComposerKnowledgePlaceholderResponse = { + id: string + placeholder_name: string +} + +export type ComposerValidationWarningResponse = { + code: string + id?: string | null + kind?: string | null + message?: string | null + surface?: string | null +} + export type WorkflowExecutionStatus = | 'failed' | 'partial-succeeded' @@ -1539,11 +1888,11 @@ export type NodeStatus = 'failed' | 'idle' | 'ready' | 'running' export type NodeOutputView = { name: string - output_check?: CheckResultView + output_check?: CheckResultView | null retried?: number status: NodeOutputStatus - type?: DeclaredOutputType - type_check?: CheckResultView + type?: DeclaredOutputType | null + type_check?: CheckResultView | null value_preview?: unknown } @@ -1573,7 +1922,7 @@ export type WorkflowDraftVariableWithoutValue = { export type ModelConfigPartial = { created_at?: number | null created_by?: string | null - model_dict?: JsonValue + model?: JsonValue | null pre_prompt?: string | null updated_at?: number | null updated_by?: string | null @@ -1606,190 +1955,32 @@ export type WorkflowOnlineUser = { username: string } -export type AgentScope = 'roster' | 'workflow_only' - -export type AgentStatus = 'active' | 'archived' - -export type AgentSoulAppFeaturesConfig = { - opening_statement?: string | null - retriever_resource?: AgentFeatureToggleConfig - sensitive_word_avoidance?: AgentSensitiveWordAvoidanceFeatureConfig - speech_to_text?: AgentFeatureToggleConfig - suggested_questions?: Array | null - suggested_questions_after_answer?: AgentSuggestedQuestionsAfterAnswerFeatureConfig - text_to_speech?: AgentTextToSpeechFeatureConfig - [key: string]: unknown -} - -export type AppVariableConfig = { - default?: unknown - name: string - required?: boolean - type: string -} - -export type AgentSoulEnvConfig = { - secret_refs?: Array - variables?: Array -} - -export type AgentSoulHumanConfig = { - contacts?: Array - tools?: Array -} - -export type AgentSoulKnowledgeConfig = { - datasets?: Array - query_config?: AgentKnowledgeQueryConfig - query_mode?: AgentKnowledgeQueryMode -} - -export type AgentSoulMemoryConfig = { - artifacts?: Array - budget?: string | null - scope?: string | null -} - -export type AgentSoulModelConfig = { - credential_ref?: AgentSoulModelCredentialRef - model: string - model_provider: string - model_settings?: AgentSoulModelSettings - plugin_id: string -} - -export type AgentSoulPromptConfig = { - system_prompt?: string -} - -export type AgentSoulSandboxConfig = { - config?: AgentSandboxProviderConfig - provider?: string | null -} - -export type AgentSoulSkillsFilesConfig = { - files?: Array - skills?: Array -} - -export type AgentSoulToolsConfig = { - cli_tools?: Array - dify_tools?: Array -} - -export type AgentHumanContactConfig = { - channel?: string | null - contact_id?: string | null - contact_method?: string | null - email?: string | null - human_id?: string | null - id?: string | null - method?: string | null - name?: string | null - tenant_id?: string | null - [key: string]: unknown -} - -export type WorkflowNodeJobMetadata = { - agent_soul?: { - [key: string]: unknown - } | null - file_refs?: Array | null -} - -export type WorkflowNodeJobMode = 'let_agent_figure_it_out' | 'tell_agent_what_to_do' - -export type WorkflowPreviousNodeOutputRef = { - key?: string | null - name?: string | null - node_id?: string | null - output?: string | null - selector?: Array | null - value_selector?: Array | null - variable?: string | null - variable_selector?: Array | null - [key: string]: unknown -} - -export type AgentCliToolConfig = { - approved?: boolean - authorization_status?: AgentCliToolAuthorizationStatus - command?: string | null - dangerous?: boolean - dangerous_accepted?: boolean - dangerous_acknowledged?: boolean - dangerous_command?: boolean - description?: string | null - enabled?: boolean - install?: string | null - install_command?: string | null - install_commands?: Array - invoke_metadata?: { +export type AgentToolCallResponse = { + error?: string | null + status: string + time_cost: number | number + tool_icon?: unknown + tool_input: { + [key: string]: unknown + } + tool_label: string + tool_name: string + tool_output: { + [key: string]: unknown + } + tool_parameters: { [key: string]: unknown } - label?: string | null - name?: string | null - permission?: AgentPermissionConfig - pre_authorized?: boolean | null - requires_confirmation?: boolean - risk_accepted?: boolean - risk_level?: AgentCliToolRiskLevel - setup_command?: string | null - tool_name?: string | null - [key: string]: unknown } -export type AgentComposerDifyToolCandidateResponse = { - description?: string | null - id?: string | null - name?: string | null - plugin_id?: string | null - provider?: string | null - provider_id?: string | null -} - -export type AgentKnowledgeDatasetConfig = { - description?: string | null - id?: string | null - name?: string | null - [key: string]: unknown -} - -export type AgentComposerSkillCandidateResponse = { - description?: string | null - file_id?: string | null - id?: string | null - kind?: string - name?: string | null - path?: string | null - [key: string]: unknown -} - -export type AgentComposerFileCandidateResponse = { - file_id?: string | null - id?: string | null - kind?: string - name?: string | null - reference?: string | null - remote_url?: string | null - tenant_id?: string | null - transfer_method?: string | null - type?: string | null - upload_file_id?: string | null - url?: string | null - [key: string]: unknown -} - -export type AgentModerationProviderConfig = { - api_based_extension_id?: string | null - inputs_config?: AgentModerationIoConfig - keywords?: string | null - outputs_config?: AgentModerationIoConfig - [key: string]: unknown +export type EnvSuggestion = { + key: string + reason?: string + secret_likely?: boolean } export type SimpleModelConfig = { - model_dict?: JsonValue + model_dict?: JsonValue | null pre_prompt?: string | null } @@ -1859,17 +2050,108 @@ export type WorkflowRunForArchivedLogResponse = { triggered_from?: string | null } +export type AgentScope = 'roster' | 'workflow_only' + +export type AgentStatus = 'active' | 'archived' + +export type AgentSoulAppFeaturesConfig = { + opening_statement?: string | null + retriever_resource?: AgentFeatureToggleConfig | null + sensitive_word_avoidance?: AgentSensitiveWordAvoidanceFeatureConfig | null + speech_to_text?: AgentFeatureToggleConfig | null + suggested_questions?: Array | null + suggested_questions_after_answer?: AgentSuggestedQuestionsAfterAnswerFeatureConfig | null + text_to_speech?: AgentTextToSpeechFeatureConfig | null + [key: string]: unknown +} + +export type AppVariableConfig = { + default?: unknown + name: string + required?: boolean + type: string +} + +export type AgentSoulEnvConfig = { + secret_refs?: Array + variables?: Array +} + +export type AgentSoulHumanConfig = { + contacts?: Array + tools?: Array +} + +export type AgentSoulKnowledgeConfig = { + datasets?: Array + query_config?: AgentKnowledgeQueryConfig + query_mode?: AgentKnowledgeQueryMode | null +} + +export type AgentSoulMemoryConfig = { + artifacts?: Array + budget?: string | null + scope?: string | null +} + +export type AgentSoulModelConfig = { + credential_ref?: AgentSoulModelCredentialRef | null + model: string + model_provider: string + model_settings?: AgentSoulModelSettings + plugin_id: string +} + +export type AgentSoulPromptConfig = { + system_prompt?: string +} + +export type AgentSoulSandboxConfig = { + config?: AgentSandboxProviderConfig + provider?: string | null +} + +export type AgentSoulSkillsFilesConfig = { + files?: Array + skills?: Array +} + +export type AgentSoulToolsConfig = { + cli_tools?: Array + dify_tools?: Array +} + export type WorkflowAgentBindingType = 'inline_agent' | 'roster_agent' export type DeclaredArrayItem = { + children?: Array<{ + array_item?: { + children?: Array<{ + [key: string]: unknown + }> + description?: string | null + type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' + [key: string]: unknown + } + children?: Array<{ + [key: string]: unknown + }> + description?: string | null + file?: { + [key: string]: unknown + } + name: string + required?: boolean + type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' + }> description?: string | null type: DeclaredOutputType } export type DeclaredOutputCheckConfig = { - benchmark_file_ref?: AgentFileRefConfig + benchmark_file_ref?: AgentFileRefConfig | null enabled?: boolean - model_ref?: AgentSoulModelConfig + model_ref?: AgentSoulModelConfig | null prompt?: string | null } @@ -1884,35 +2166,217 @@ export type DeclaredOutputFileConfig = { mime_types?: Array } +export type AgentHumanContactConfig = { + channel?: string | null + contact_id?: string | null + contact_method?: string | null + email?: string | null + human_id?: string | null + id?: string | null + method?: string | null + name?: string | null + tenant_id?: string | null + [key: string]: unknown +} + +export type WorkflowNodeJobMetadata = { + agent_soul?: { + [key: string]: unknown + } | null + file_refs?: Array | null +} + +export type WorkflowNodeJobMode = 'let_agent_figure_it_out' | 'tell_agent_what_to_do' + +export type WorkflowPreviousNodeOutputRef = { + key?: string | null + name?: string | null + node_id?: string | null + output?: string | null + selector?: Array | null + value_selector?: Array | null + variable?: string | null + variable_selector?: Array | null + [key: string]: unknown +} + +export type AgentCliToolConfig = { + approved?: boolean + authorization_status?: AgentCliToolAuthorizationStatus | null + command?: string | null + dangerous?: boolean + dangerous_accepted?: boolean + dangerous_acknowledged?: boolean + dangerous_command?: boolean + description?: string | null + enabled?: boolean + env?: AgentCliToolEnvConfig + id?: string | null + inferred_from?: string | null + install?: string | null + install_command?: string | null + install_commands?: Array + invoke_metadata?: { + [key: string]: unknown + } + label?: string | null + name?: string | null + permission?: AgentPermissionConfig | null + pre_authorized?: boolean | null + requires_confirmation?: boolean + risk_accepted?: boolean + risk_level?: AgentCliToolRiskLevel | null + setup_command?: string | null + tool_name?: string | null + [key: string]: unknown +} + +export type AgentComposerDifyToolCandidateResponse = { + description?: string | null + granularity?: string | null + id?: string | null + name?: string | null + plugin_id?: string | null + provider?: string | null + provider_id?: string | null + tools_count?: number | null +} + +export type AgentKnowledgeDatasetConfig = { + description?: string | null + id?: string | null + name?: string | null + [key: string]: unknown +} + +export type AgentComposerSkillCandidateResponse = { + description?: string | null + file_id?: string | null + full_archive_file_id?: string | null + full_archive_key?: string | null + id?: string | null + kind?: 'skill' + manifest_files?: Array | null + name?: string | null + path?: string | null + skill_md_file_id?: string | null + skill_md_key?: string | null + [key: string]: unknown +} + +export type AgentComposerFileCandidateResponse = { + drive_key?: string | null + file_id?: string | null + id?: string | null + kind?: 'file' + name?: string | null + reference?: string | null + remote_url?: string | null + tenant_id?: string | null + transfer_method?: string | null + type?: string | null + upload_file_id?: string | null + url?: string | null + [key: string]: unknown +} + export type CheckResultView = { passed: boolean reason?: string | null } +export type UserActionConfig = { + button_style?: ButtonStyle + id: string + title: string +} + +export type FormInputConfig + = | ({ + type: 'paragraph' + } & ParagraphInputConfig) + | ({ + type: 'select' + } & SelectInputConfig) + | ({ + type: 'file' + } & FileInputConfig) + | ({ + type: 'file-list' + } & FileListInputConfig) + +export type JsonValue2 = unknown + +export type AgentFeatureToggleConfig = { + enabled?: boolean + [key: string]: unknown +} + +export type AgentSensitiveWordAvoidanceFeatureConfig = { + config?: AgentModerationProviderConfig | null + enabled?: boolean + type?: string | null + [key: string]: unknown +} + +export type AgentSuggestedQuestionsAfterAnswerFeatureConfig = { + enabled?: boolean + model?: AgentSoulModelConfig | null + prompt?: string | null + [key: string]: unknown +} + +export type AgentTextToSpeechFeatureConfig = { + autoPlay?: string | null + enabled?: boolean + language?: string | null + voice?: string | null + [key: string]: unknown +} + export type AgentSecretRefConfig = { credential_id?: string | null env_name?: string | null id?: string | null key?: string | null name?: string | null - permission?: AgentPermissionConfig + permission?: AgentPermissionConfig | null permission_status?: string | null provider?: string | null provider_credential_id?: string | null ref?: string | null type?: string | null + value?: string | null variable?: string | null [key: string]: unknown } export type AgentEnvVariableConfig = { - default?: unknown + default?: + | string + | number + | number + | boolean + | Array + | Array + | Array + | Array + | null env_name?: string | null key?: string | null name?: string | null required?: boolean type?: string | null - value?: unknown + value?: + | string + | number + | number + | boolean + | Array + | Array + | Array + | Array + | null variable?: string | null [key: string]: unknown } @@ -1952,7 +2416,7 @@ export type AgentSoulModelSettings = { frequency_penalty?: number | null max_tokens?: number | null presence_penalty?: number | null - response_format?: AgentModelResponseFormatConfig + response_format?: AgentModelResponseFormatConfig | null stop?: Array | null temperature?: number | null top_p?: number | null @@ -1967,6 +2431,7 @@ export type AgentSandboxProviderConfig = { } export type AgentFileRefConfig = { + drive_key?: string | null file_id?: string | null id?: string | null name?: string | null @@ -1980,17 +2445,8 @@ export type AgentFileRefConfig = { [key: string]: unknown } -export type AgentSkillRefConfig = { - description?: string | null - file_id?: string | null - id?: string | null - name?: string | null - path?: string | null - [key: string]: unknown -} - export type AgentSoulDifyToolConfig = { - credential_ref?: AgentSoulDifyToolCredentialRef + credential_ref?: AgentSoulDifyToolCredentialRef | null credential_type?: 'api-key' | 'oauth2' | 'unauthorized' description?: string | null enabled?: boolean @@ -2000,9 +2456,26 @@ export type AgentSoulDifyToolConfig = { provider_id?: string | null provider_type?: string runtime_parameters?: { - [key: string]: unknown + [key: string]: + | string + | number + | number + | boolean + | Array + | Array + | Array + | Array + | null } - tool_name: string + tool_name?: string | null +} + +export type OutputErrorStrategy = 'default_value' | 'fail_branch' | 'stop' + +export type DeclaredOutputRetryConfig = { + enabled?: boolean + max_retries?: number + retry_interval_ms?: number } export type AgentCliToolAuthorizationStatus @@ -2015,6 +2488,11 @@ export type AgentCliToolAuthorizationStatus | 'pre_authorized' | 'unauthorized' +export type AgentCliToolEnvConfig = { + secret_refs?: Array + variables?: Array +} + export type AgentPermissionConfig = { allowed?: boolean | null state?: string | null @@ -2023,30 +2501,45 @@ export type AgentPermissionConfig = { export type AgentCliToolRiskLevel = 'dangerous' | 'safe' | 'unknown' -export type AgentModerationIoConfig = { - enabled?: boolean - preset_response?: string | null +export type ButtonStyle = 'accent' | 'default' | 'ghost' | 'primary' + +export type ParagraphInputConfig = { + default?: StringSource | null + output_variable_name: string + type?: 'paragraph' +} + +export type SelectInputConfig = { + option_source: StringListSource + output_variable_name: string + type?: 'select' +} + +export type FileInputConfig = { + allowed_file_extensions?: Array + allowed_file_types?: Array + allowed_file_upload_methods?: Array + output_variable_name: string + type?: 'file' +} + +export type FileListInputConfig = { + allowed_file_extensions?: Array + allowed_file_types?: Array + allowed_file_upload_methods?: Array + number_limits?: number + output_variable_name: string + type?: 'file-list' +} + +export type AgentModerationProviderConfig = { + api_based_extension_id?: string | null + inputs_config?: AgentModerationIoConfig | null + keywords?: string | null + outputs_config?: AgentModerationIoConfig | null [key: string]: unknown } -export type UserActionConfig = { - button_style?: ButtonStyle - id: string - title: string -} - -export type FormInputConfig = unknown - -export type JsonValue2 = unknown - -export type OutputErrorStrategy = 'default_value' | 'fail_branch' | 'stop' - -export type DeclaredOutputRetryConfig = { - enabled?: boolean - max_retries?: number - retry_interval_ms?: number -} - export type AgentModelResponseFormatConfig = { type?: string | null [key: string]: unknown @@ -2058,37 +2551,6 @@ export type AgentSoulDifyToolCredentialRef = { type?: 'provider' | 'tool' } -export type ButtonStyle = 'accent' | 'default' | 'ghost' | 'primary' - -export type ParagraphInputConfig = { - default?: StringSource - output_variable_name: string - type?: string -} - -export type SelectInputConfig = { - option_source: StringListSource - output_variable_name: string - type?: string -} - -export type FileInputConfig = { - allowed_file_extensions?: Array - allowed_file_types?: Array - allowed_file_upload_methods?: Array - output_variable_name: string - type?: string -} - -export type FileListInputConfig = { - allowed_file_extensions?: Array - allowed_file_types?: Array - allowed_file_upload_methods?: Array - number_limits?: number - output_variable_name: string - type?: string -} - export type StringSource = { selector?: Array type: ValueSourceType @@ -2105,8 +2567,46 @@ export type FileType = 'audio' | 'custom' | 'document' | 'image' | 'video' export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url' | 'tool_file' +export type AgentModerationIoConfig = { + enabled?: boolean + preset_response?: string | null + [key: string]: unknown +} + export type ValueSourceType = 'constant' | 'variable' +export type AppDetailWithSiteWritable = { + access_mode?: string | null + active_config_is_published?: boolean + api_base_url?: string | null + app_id?: string | null + bound_agent_id?: string | null + created_at?: number | null + created_by?: string | null + deleted_tools?: Array + description?: string | null + enable_api: boolean + enable_site: boolean + icon?: string | null + icon_background?: string | null + icon_type?: string | null + id: string + max_active_requests?: number | null + mode: string + model_config?: ModelConfig | null + name: string + role?: string | null + site?: SiteWritable | null + tags?: Array + tracing?: JsonValue | null + updated_at?: number | null + updated_by?: string | null + use_icon_as_answer_icon?: boolean | null + workflow?: WorkflowPartial | null +} + +export type GeneratedAppResponseWritable = JsonValue + export type WorkflowCommentBasicListWritable = { data: Array } @@ -2115,7 +2615,7 @@ export type WorkflowCommentDetailWritable = { content: string created_at?: number | null created_by: string - created_by_account?: WorkflowCommentAccountWritable + created_by_account?: WorkflowCommentAccountWritable | null id: string mentions: Array position_x: number @@ -2124,15 +2624,31 @@ export type WorkflowCommentDetailWritable = { resolved: boolean resolved_at?: number | null resolved_by?: string | null - resolved_by_account?: WorkflowCommentAccountWritable + resolved_by_account?: WorkflowCommentAccountWritable | null updated_at?: number | null } +export type SiteWritable = { + chat_color_theme?: string | null + chat_color_theme_inverted: boolean + copyright?: string | null + custom_disclaimer?: string | null + default_language: string + description?: string | null + icon?: string | null + icon_background?: string | null + icon_type?: string | null + privacy_policy?: string | null + show_workflow_steps: boolean + title: string + use_icon_as_answer_icon: boolean +} + export type WorkflowCommentBasicWritable = { content: string created_at?: number | null created_by: string - created_by_account?: WorkflowCommentAccountWritable + created_by_account?: WorkflowCommentAccountWritable | null id: string mention_count: number participants: Array @@ -2142,7 +2658,7 @@ export type WorkflowCommentBasicWritable = { resolved: boolean resolved_at?: number | null resolved_by?: string | null - resolved_by_account?: WorkflowCommentAccountWritable + resolved_by_account?: WorkflowCommentAccountWritable | null updated_at?: number | null } @@ -2153,7 +2669,7 @@ export type WorkflowCommentAccountWritable = { } export type WorkflowCommentMentionWritable = { - mentioned_user_account?: WorkflowCommentAccountWritable + mentioned_user_account?: WorkflowCommentAccountWritable | null mentioned_user_id: string reply_id?: string | null } @@ -2162,7 +2678,7 @@ export type WorkflowCommentReplyWritable = { content: string created_at?: number | null created_by: string - created_by_account?: WorkflowCommentAccountWritable + created_by_account?: WorkflowCommentAccountWritable | null id: string } @@ -2170,8 +2686,8 @@ export type GetAppsData = { body?: never path?: never query?: { - creator_ids?: Array | null - is_created_by_me?: boolean | null + creator_ids?: Array + is_created_by_me?: boolean limit?: number mode?: | 'advanced-chat' @@ -2182,9 +2698,10 @@ export type GetAppsData = { | 'chat' | 'completion' | 'workflow' - name?: string | null + name?: string page?: number - tag_ids?: Array | null + sort_by?: 'earliest_created' | 'last_modified' | 'recently_created' + tag_ids?: Array } url: '/apps' } @@ -2203,18 +2720,12 @@ export type PostAppsData = { } export type PostAppsErrors = { - 400: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } + 400: unknown + 403: unknown } -export type PostAppsError = PostAppsErrors[keyof PostAppsErrors] - export type PostAppsResponses = { - 201: AppDetail + 201: AppDetailWithSite } export type PostAppsResponse = PostAppsResponses[keyof PostAppsResponses] @@ -2278,6 +2789,36 @@ export type PostAppsImportsByImportIdConfirmResponses = { export type PostAppsImportsByImportIdConfirmResponse = PostAppsImportsByImportIdConfirmResponses[keyof PostAppsImportsByImportIdConfirmResponses] +export type GetAppsStarredData = { + body?: never + path?: never + query?: { + creator_ids?: Array + is_created_by_me?: boolean + limit?: number + mode?: + | 'advanced-chat' + | 'agent' + | 'agent-chat' + | 'all' + | 'channel' + | 'chat' + | 'completion' + | 'workflow' + name?: string + page?: number + sort_by?: 'earliest_created' | 'last_modified' | 'recently_created' + tag_ids?: Array + } + url: '/apps/starred' +} + +export type GetAppsStarredResponses = { + 200: AppPagination +} + +export type GetAppsStarredResponse = GetAppsStarredResponses[keyof GetAppsStarredResponses] + export type PostAppsWorkflowsOnlineUsersData = { body: WorkflowOnlineUsersPayload path?: never @@ -2302,17 +2843,11 @@ export type DeleteAppsByAppIdData = { } export type DeleteAppsByAppIdErrors = { - 403: { - [key: string]: unknown - } + 403: unknown } -export type DeleteAppsByAppIdError = DeleteAppsByAppIdErrors[keyof DeleteAppsByAppIdErrors] - export type DeleteAppsByAppIdResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteAppsByAppIdResponse = DeleteAppsByAppIdResponses[keyof DeleteAppsByAppIdResponses] @@ -2342,16 +2877,10 @@ export type PutAppsByAppIdData = { } export type PutAppsByAppIdErrors = { - 400: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } + 400: unknown + 403: unknown } -export type PutAppsByAppIdError = PutAppsByAppIdErrors[keyof PutAppsByAppIdErrors] - export type PutAppsByAppIdResponses = { 200: AppDetailWithSite } @@ -2410,9 +2939,7 @@ export type PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdForm } export type PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponses = { - 200: { - [key: string]: unknown - } + 200: HumanInputFormPreviewResponse } export type PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponse @@ -2429,9 +2956,7 @@ export type PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdForm } export type PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRunResponses = { - 200: { - [key: string]: unknown - } + 200: HumanInputFormSubmitResponse } export type PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRunResponse @@ -2448,21 +2973,12 @@ export type PostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRunDa } export type PostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRunErrors = { - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 403: unknown + 404: unknown } -export type PostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRunError - = PostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRunErrors[keyof PostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRunErrors] - export type PostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRunResponses = { - 200: { - [key: string]: unknown - } + 200: GeneratedAppResponse } export type PostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRunResponse @@ -2479,21 +2995,12 @@ export type PostAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRunData = } export type PostAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRunErrors = { - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 403: unknown + 404: unknown } -export type PostAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRunError - = PostAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRunErrors[keyof PostAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRunErrors] - export type PostAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRunResponses = { - 200: { - [key: string]: unknown - } + 200: GeneratedAppResponse } export type PostAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRunResponse @@ -2509,208 +3016,110 @@ export type PostAppsByAppIdAdvancedChatWorkflowsDraftRunData = { } export type PostAppsByAppIdAdvancedChatWorkflowsDraftRunErrors = { - 400: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } + 400: unknown + 403: unknown } -export type PostAppsByAppIdAdvancedChatWorkflowsDraftRunError - = PostAppsByAppIdAdvancedChatWorkflowsDraftRunErrors[keyof PostAppsByAppIdAdvancedChatWorkflowsDraftRunErrors] - export type PostAppsByAppIdAdvancedChatWorkflowsDraftRunResponses = { - 200: { - [key: string]: unknown - } + 200: GeneratedAppResponse } export type PostAppsByAppIdAdvancedChatWorkflowsDraftRunResponse = PostAppsByAppIdAdvancedChatWorkflowsDraftRunResponses[keyof PostAppsByAppIdAdvancedChatWorkflowsDraftRunResponses] -export type GetAppsByAppIdAgentComposerData = { +export type GetAppsByAppIdAgentDriveFilesData = { body?: never path: { app_id: string } - query?: never - url: '/apps/{app_id}/agent-composer' -} - -export type GetAppsByAppIdAgentComposerResponses = { - 200: AgentAppComposerResponse -} - -export type GetAppsByAppIdAgentComposerResponse - = GetAppsByAppIdAgentComposerResponses[keyof GetAppsByAppIdAgentComposerResponses] - -export type PutAppsByAppIdAgentComposerData = { - body: ComposerSavePayload - path: { - app_id: string + query?: { + node_id?: string + prefix?: string } - query?: never - url: '/apps/{app_id}/agent-composer' + url: '/apps/{app_id}/agent/drive/files' } -export type PutAppsByAppIdAgentComposerResponses = { - 200: AgentAppComposerResponse +export type GetAppsByAppIdAgentDriveFilesResponses = { + 200: AgentDriveListResponse } -export type PutAppsByAppIdAgentComposerResponse - = PutAppsByAppIdAgentComposerResponses[keyof PutAppsByAppIdAgentComposerResponses] +export type GetAppsByAppIdAgentDriveFilesResponse + = GetAppsByAppIdAgentDriveFilesResponses[keyof GetAppsByAppIdAgentDriveFilesResponses] -export type GetAppsByAppIdAgentComposerCandidatesData = { - body?: never - path: { - app_id: string - } - query?: never - url: '/apps/{app_id}/agent-composer/candidates' -} - -export type GetAppsByAppIdAgentComposerCandidatesResponses = { - 200: AgentComposerCandidatesResponse -} - -export type GetAppsByAppIdAgentComposerCandidatesResponse - = GetAppsByAppIdAgentComposerCandidatesResponses[keyof GetAppsByAppIdAgentComposerCandidatesResponses] - -export type PostAppsByAppIdAgentComposerValidateData = { - body: ComposerSavePayload - path: { - app_id: string - } - query?: never - url: '/apps/{app_id}/agent-composer/validate' -} - -export type PostAppsByAppIdAgentComposerValidateResponses = { - 200: AgentComposerValidateResponse -} - -export type PostAppsByAppIdAgentComposerValidateResponse - = PostAppsByAppIdAgentComposerValidateResponses[keyof PostAppsByAppIdAgentComposerValidateResponses] - -export type PostAppsByAppIdAgentFeaturesData = { - body: AgentAppFeaturesPayload - path: { - app_id: string - } - query?: never - url: '/apps/{app_id}/agent-features' -} - -export type PostAppsByAppIdAgentFeaturesErrors = { - 400: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } -} - -export type PostAppsByAppIdAgentFeaturesError - = PostAppsByAppIdAgentFeaturesErrors[keyof PostAppsByAppIdAgentFeaturesErrors] - -export type PostAppsByAppIdAgentFeaturesResponses = { - 200: SimpleResultResponse -} - -export type PostAppsByAppIdAgentFeaturesResponse - = PostAppsByAppIdAgentFeaturesResponses[keyof PostAppsByAppIdAgentFeaturesResponses] - -export type GetAppsByAppIdAgentReferencingWorkflowsData = { - body?: never - path: { - app_id: string - } - query?: never - url: '/apps/{app_id}/agent-referencing-workflows' -} - -export type GetAppsByAppIdAgentReferencingWorkflowsErrors = { - 404: { - [key: string]: unknown - } -} - -export type GetAppsByAppIdAgentReferencingWorkflowsError - = GetAppsByAppIdAgentReferencingWorkflowsErrors[keyof GetAppsByAppIdAgentReferencingWorkflowsErrors] - -export type GetAppsByAppIdAgentReferencingWorkflowsResponses = { - 200: AgentReferencingWorkflowsResponse -} - -export type GetAppsByAppIdAgentReferencingWorkflowsResponse - = GetAppsByAppIdAgentReferencingWorkflowsResponses[keyof GetAppsByAppIdAgentReferencingWorkflowsResponses] - -export type GetAppsByAppIdAgentWorkspaceFilesData = { +export type GetAppsByAppIdAgentDriveFilesDownloadData = { body?: never path: { app_id: string } query: { - conversation_id: string - path?: string + key: string + node_id?: string } - url: '/apps/{app_id}/agent-workspace/files' + url: '/apps/{app_id}/agent/drive/files/download' } -export type GetAppsByAppIdAgentWorkspaceFilesResponses = { - 200: WorkspaceListResponse +export type GetAppsByAppIdAgentDriveFilesDownloadResponses = { + 200: AgentDriveDownloadResponse } -export type GetAppsByAppIdAgentWorkspaceFilesResponse - = GetAppsByAppIdAgentWorkspaceFilesResponses[keyof GetAppsByAppIdAgentWorkspaceFilesResponses] +export type GetAppsByAppIdAgentDriveFilesDownloadResponse + = GetAppsByAppIdAgentDriveFilesDownloadResponses[keyof GetAppsByAppIdAgentDriveFilesDownloadResponses] -export type GetAppsByAppIdAgentWorkspaceFilesDownloadData = { +export type GetAppsByAppIdAgentDriveFilesPreviewData = { body?: never path: { app_id: string } query: { - conversation_id: string - path: string + key: string + node_id?: string } - url: '/apps/{app_id}/agent-workspace/files/download' + url: '/apps/{app_id}/agent/drive/files/preview' } -export type GetAppsByAppIdAgentWorkspaceFilesDownloadErrors = { - 413: { - [key: string]: unknown - } +export type GetAppsByAppIdAgentDriveFilesPreviewResponses = { + 200: AgentDrivePreviewResponse } -export type GetAppsByAppIdAgentWorkspaceFilesDownloadError - = GetAppsByAppIdAgentWorkspaceFilesDownloadErrors[keyof GetAppsByAppIdAgentWorkspaceFilesDownloadErrors] +export type GetAppsByAppIdAgentDriveFilesPreviewResponse + = GetAppsByAppIdAgentDriveFilesPreviewResponses[keyof GetAppsByAppIdAgentDriveFilesPreviewResponses] -export type GetAppsByAppIdAgentWorkspaceFilesDownloadResponses = { - 200: Blob | File -} - -export type GetAppsByAppIdAgentWorkspaceFilesDownloadResponse - = GetAppsByAppIdAgentWorkspaceFilesDownloadResponses[keyof GetAppsByAppIdAgentWorkspaceFilesDownloadResponses] - -export type GetAppsByAppIdAgentWorkspaceFilesPreviewData = { +export type DeleteAppsByAppIdAgentFilesData = { body?: never path: { app_id: string } query: { - conversation_id: string - path: string + key: string + node_id?: string } - url: '/apps/{app_id}/agent-workspace/files/preview' + url: '/apps/{app_id}/agent/files' } -export type GetAppsByAppIdAgentWorkspaceFilesPreviewResponses = { - 200: WorkspacePreviewResponse +export type DeleteAppsByAppIdAgentFilesResponses = { + 200: AgentDriveDeleteResponse } -export type GetAppsByAppIdAgentWorkspaceFilesPreviewResponse - = GetAppsByAppIdAgentWorkspaceFilesPreviewResponses[keyof GetAppsByAppIdAgentWorkspaceFilesPreviewResponses] +export type DeleteAppsByAppIdAgentFilesResponse + = DeleteAppsByAppIdAgentFilesResponses[keyof DeleteAppsByAppIdAgentFilesResponses] + +export type PostAppsByAppIdAgentFilesData = { + body: AgentDriveFilePayload + path: { + app_id: string + } + query?: { + node_id?: string + } + url: '/apps/{app_id}/agent/files' +} + +export type PostAppsByAppIdAgentFilesResponses = { + 201: AgentDriveFileCommitResponse +} + +export type PostAppsByAppIdAgentFilesResponse + = PostAppsByAppIdAgentFilesResponses[keyof PostAppsByAppIdAgentFilesResponses] export type GetAppsByAppIdAgentLogsData = { body?: never @@ -2725,18 +3134,11 @@ export type GetAppsByAppIdAgentLogsData = { } export type GetAppsByAppIdAgentLogsErrors = { - 400: { - [key: string]: unknown - } + 400: unknown } -export type GetAppsByAppIdAgentLogsError - = GetAppsByAppIdAgentLogsErrors[keyof GetAppsByAppIdAgentLogsErrors] - export type GetAppsByAppIdAgentLogsResponses = { - 200: Array<{ - [key: string]: unknown - }> + 200: AgentLogResponse } export type GetAppsByAppIdAgentLogsResponse @@ -2747,23 +3149,18 @@ export type PostAppsByAppIdAgentSkillsStandardizeData = { path: { app_id: string } - query?: never + query?: { + node_id?: string + } url: '/apps/{app_id}/agent/skills/standardize' } export type PostAppsByAppIdAgentSkillsStandardizeErrors = { - 400: { - [key: string]: unknown - } + 400: unknown } -export type PostAppsByAppIdAgentSkillsStandardizeError - = PostAppsByAppIdAgentSkillsStandardizeErrors[keyof PostAppsByAppIdAgentSkillsStandardizeErrors] - export type PostAppsByAppIdAgentSkillsStandardizeResponses = { - 201: { - [key: string]: unknown - } + 201: AgentSkillStandardizeResponse } export type PostAppsByAppIdAgentSkillsStandardizeResponse @@ -2779,23 +3176,54 @@ export type PostAppsByAppIdAgentSkillsUploadData = { } export type PostAppsByAppIdAgentSkillsUploadErrors = { - 400: { - [key: string]: unknown - } + 400: unknown } -export type PostAppsByAppIdAgentSkillsUploadError - = PostAppsByAppIdAgentSkillsUploadErrors[keyof PostAppsByAppIdAgentSkillsUploadErrors] - export type PostAppsByAppIdAgentSkillsUploadResponses = { - 201: { - [key: string]: unknown - } + 201: AgentSkillUploadResponse } export type PostAppsByAppIdAgentSkillsUploadResponse = PostAppsByAppIdAgentSkillsUploadResponses[keyof PostAppsByAppIdAgentSkillsUploadResponses] +export type DeleteAppsByAppIdAgentSkillsBySlugData = { + body?: never + path: { + app_id: string + slug: string + } + query?: { + node_id?: string + } + url: '/apps/{app_id}/agent/skills/{slug}' +} + +export type DeleteAppsByAppIdAgentSkillsBySlugResponses = { + 200: AgentDriveDeleteResponse +} + +export type DeleteAppsByAppIdAgentSkillsBySlugResponse + = DeleteAppsByAppIdAgentSkillsBySlugResponses[keyof DeleteAppsByAppIdAgentSkillsBySlugResponses] + +export type PostAppsByAppIdAgentSkillsBySlugInferToolsData = { + body?: never + path: { + app_id: string + slug: string + } + query?: { + node_id?: string + } + url: '/apps/{app_id}/agent/skills/{slug}/infer-tools' +} + +export type PostAppsByAppIdAgentSkillsBySlugInferToolsResponses = { + 200: SkillToolInferenceResult +} + +export type PostAppsByAppIdAgentSkillsBySlugInferToolsResponse + = PostAppsByAppIdAgentSkillsBySlugInferToolsResponses[keyof PostAppsByAppIdAgentSkillsBySlugInferToolsResponses] + export type PostAppsByAppIdAnnotationReplyByActionData = { body: AnnotationReplyPayload path: { @@ -2807,18 +3235,11 @@ export type PostAppsByAppIdAnnotationReplyByActionData = { } export type PostAppsByAppIdAnnotationReplyByActionErrors = { - 403: { - [key: string]: unknown - } + 403: unknown } -export type PostAppsByAppIdAnnotationReplyByActionError - = PostAppsByAppIdAnnotationReplyByActionErrors[keyof PostAppsByAppIdAnnotationReplyByActionErrors] - export type PostAppsByAppIdAnnotationReplyByActionResponses = { - 200: { - [key: string]: unknown - } + 200: AnnotationJobStatusResponse } export type PostAppsByAppIdAnnotationReplyByActionResponse @@ -2836,18 +3257,11 @@ export type GetAppsByAppIdAnnotationReplyByActionStatusByJobIdData = { } export type GetAppsByAppIdAnnotationReplyByActionStatusByJobIdErrors = { - 403: { - [key: string]: unknown - } + 403: unknown } -export type GetAppsByAppIdAnnotationReplyByActionStatusByJobIdError - = GetAppsByAppIdAnnotationReplyByActionStatusByJobIdErrors[keyof GetAppsByAppIdAnnotationReplyByActionStatusByJobIdErrors] - export type GetAppsByAppIdAnnotationReplyByActionStatusByJobIdResponses = { - 200: { - [key: string]: unknown - } + 200: AnnotationJobStatusResponse } export type GetAppsByAppIdAnnotationReplyByActionStatusByJobIdResponse @@ -2863,18 +3277,11 @@ export type GetAppsByAppIdAnnotationSettingData = { } export type GetAppsByAppIdAnnotationSettingErrors = { - 403: { - [key: string]: unknown - } + 403: unknown } -export type GetAppsByAppIdAnnotationSettingError - = GetAppsByAppIdAnnotationSettingErrors[keyof GetAppsByAppIdAnnotationSettingErrors] - export type GetAppsByAppIdAnnotationSettingResponses = { - 200: { - [key: string]: unknown - } + 200: AnnotationSettingResponse } export type GetAppsByAppIdAnnotationSettingResponse @@ -2891,18 +3298,11 @@ export type PostAppsByAppIdAnnotationSettingsByAnnotationSettingIdData = { } export type PostAppsByAppIdAnnotationSettingsByAnnotationSettingIdErrors = { - 403: { - [key: string]: unknown - } + 403: unknown } -export type PostAppsByAppIdAnnotationSettingsByAnnotationSettingIdError - = PostAppsByAppIdAnnotationSettingsByAnnotationSettingIdErrors[keyof PostAppsByAppIdAnnotationSettingsByAnnotationSettingIdErrors] - export type PostAppsByAppIdAnnotationSettingsByAnnotationSettingIdResponses = { - 200: { - [key: string]: unknown - } + 200: AnnotationSettingResponse } export type PostAppsByAppIdAnnotationSettingsByAnnotationSettingIdResponse @@ -2918,9 +3318,7 @@ export type DeleteAppsByAppIdAnnotationsData = { } export type DeleteAppsByAppIdAnnotationsResponses = { - 200: { - [key: string]: unknown - } + 204: void } export type DeleteAppsByAppIdAnnotationsResponse @@ -2940,18 +3338,11 @@ export type GetAppsByAppIdAnnotationsData = { } export type GetAppsByAppIdAnnotationsErrors = { - 403: { - [key: string]: unknown - } + 403: unknown } -export type GetAppsByAppIdAnnotationsError - = GetAppsByAppIdAnnotationsErrors[keyof GetAppsByAppIdAnnotationsErrors] - export type GetAppsByAppIdAnnotationsResponses = { - 200: { - [key: string]: unknown - } + 200: AnnotationList } export type GetAppsByAppIdAnnotationsResponse @@ -2967,14 +3358,9 @@ export type PostAppsByAppIdAnnotationsData = { } export type PostAppsByAppIdAnnotationsErrors = { - 403: { - [key: string]: unknown - } + 403: unknown } -export type PostAppsByAppIdAnnotationsError - = PostAppsByAppIdAnnotationsErrors[keyof PostAppsByAppIdAnnotationsErrors] - export type PostAppsByAppIdAnnotationsResponses = { 201: Annotation } @@ -2992,27 +3378,14 @@ export type PostAppsByAppIdAnnotationsBatchImportData = { } export type PostAppsByAppIdAnnotationsBatchImportErrors = { - 400: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } - 413: { - [key: string]: unknown - } - 429: { - [key: string]: unknown - } + 400: unknown + 403: unknown + 413: unknown + 429: unknown } -export type PostAppsByAppIdAnnotationsBatchImportError - = PostAppsByAppIdAnnotationsBatchImportErrors[keyof PostAppsByAppIdAnnotationsBatchImportErrors] - export type PostAppsByAppIdAnnotationsBatchImportResponses = { - 200: { - [key: string]: unknown - } + 200: AnnotationJobStatusResponse } export type PostAppsByAppIdAnnotationsBatchImportResponse @@ -3029,18 +3402,11 @@ export type GetAppsByAppIdAnnotationsBatchImportStatusByJobIdData = { } export type GetAppsByAppIdAnnotationsBatchImportStatusByJobIdErrors = { - 403: { - [key: string]: unknown - } + 403: unknown } -export type GetAppsByAppIdAnnotationsBatchImportStatusByJobIdError - = GetAppsByAppIdAnnotationsBatchImportStatusByJobIdErrors[keyof GetAppsByAppIdAnnotationsBatchImportStatusByJobIdErrors] - export type GetAppsByAppIdAnnotationsBatchImportStatusByJobIdResponses = { - 200: { - [key: string]: unknown - } + 200: AnnotationJobStatusResponse } export type GetAppsByAppIdAnnotationsBatchImportStatusByJobIdResponse @@ -3072,14 +3438,9 @@ export type GetAppsByAppIdAnnotationsExportData = { } export type GetAppsByAppIdAnnotationsExportErrors = { - 403: { - [key: string]: unknown - } + 403: unknown } -export type GetAppsByAppIdAnnotationsExportError - = GetAppsByAppIdAnnotationsExportErrors[keyof GetAppsByAppIdAnnotationsExportErrors] - export type GetAppsByAppIdAnnotationsExportResponses = { 200: AnnotationExportList } @@ -3098,9 +3459,7 @@ export type DeleteAppsByAppIdAnnotationsByAnnotationIdData = { } export type DeleteAppsByAppIdAnnotationsByAnnotationIdResponses = { - 200: { - [key: string]: unknown - } + 204: void } export type DeleteAppsByAppIdAnnotationsByAnnotationIdResponse @@ -3117,19 +3476,12 @@ export type PostAppsByAppIdAnnotationsByAnnotationIdData = { } export type PostAppsByAppIdAnnotationsByAnnotationIdErrors = { - 403: { - [key: string]: unknown - } + 403: unknown } -export type PostAppsByAppIdAnnotationsByAnnotationIdError - = PostAppsByAppIdAnnotationsByAnnotationIdErrors[keyof PostAppsByAppIdAnnotationsByAnnotationIdErrors] - export type PostAppsByAppIdAnnotationsByAnnotationIdResponses = { 200: Annotation - 204: { - [key: string]: never - } + 204: void } export type PostAppsByAppIdAnnotationsByAnnotationIdResponse @@ -3149,14 +3501,9 @@ export type GetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesData = { } export type GetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesErrors = { - 403: { - [key: string]: unknown - } + 403: unknown } -export type GetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesError - = GetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesErrors[keyof GetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesErrors] - export type GetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesResponses = { 200: AnnotationHitHistoryList } @@ -3174,14 +3521,9 @@ export type PostAppsByAppIdApiEnableData = { } export type PostAppsByAppIdApiEnableErrors = { - 403: { - [key: string]: unknown - } + 403: unknown } -export type PostAppsByAppIdApiEnableError - = PostAppsByAppIdApiEnableErrors[keyof PostAppsByAppIdApiEnableErrors] - export type PostAppsByAppIdApiEnableResponses = { 200: AppDetail } @@ -3199,17 +3541,10 @@ export type PostAppsByAppIdAudioToTextData = { } export type PostAppsByAppIdAudioToTextErrors = { - 400: { - [key: string]: unknown - } - 413: { - [key: string]: unknown - } + 400: unknown + 413: unknown } -export type PostAppsByAppIdAudioToTextError - = PostAppsByAppIdAudioToTextErrors[keyof PostAppsByAppIdAudioToTextErrors] - export type PostAppsByAppIdAudioToTextResponses = { 200: AudioTranscriptResponse } @@ -3224,25 +3559,20 @@ export type GetAppsByAppIdChatConversationsData = { } query?: { annotation_status?: 'all' | 'annotated' | 'not_annotated' - end?: string | null - keyword?: string | null + end?: string + keyword?: string limit?: number page?: number sort_by?: '-created_at' | '-updated_at' | 'created_at' | 'updated_at' - start?: string | null + start?: string } url: '/apps/{app_id}/chat-conversations' } export type GetAppsByAppIdChatConversationsErrors = { - 403: { - [key: string]: unknown - } + 403: unknown } -export type GetAppsByAppIdChatConversationsError - = GetAppsByAppIdChatConversationsErrors[keyof GetAppsByAppIdChatConversationsErrors] - export type GetAppsByAppIdChatConversationsResponses = { 200: ConversationWithSummaryPagination } @@ -3261,21 +3591,12 @@ export type DeleteAppsByAppIdChatConversationsByConversationIdData = { } export type DeleteAppsByAppIdChatConversationsByConversationIdErrors = { - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 403: unknown + 404: unknown } -export type DeleteAppsByAppIdChatConversationsByConversationIdError - = DeleteAppsByAppIdChatConversationsByConversationIdErrors[keyof DeleteAppsByAppIdChatConversationsByConversationIdErrors] - export type DeleteAppsByAppIdChatConversationsByConversationIdResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteAppsByAppIdChatConversationsByConversationIdResponse @@ -3292,17 +3613,10 @@ export type GetAppsByAppIdChatConversationsByConversationIdData = { } export type GetAppsByAppIdChatConversationsByConversationIdErrors = { - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 403: unknown + 404: unknown } -export type GetAppsByAppIdChatConversationsByConversationIdError - = GetAppsByAppIdChatConversationsByConversationIdErrors[keyof GetAppsByAppIdChatConversationsByConversationIdErrors] - export type GetAppsByAppIdChatConversationsByConversationIdResponses = { 200: ConversationDetail } @@ -3317,21 +3631,16 @@ export type GetAppsByAppIdChatMessagesData = { } query: { conversation_id: string - first_id?: string | null + first_id?: string limit?: number } url: '/apps/{app_id}/chat-messages' } export type GetAppsByAppIdChatMessagesErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type GetAppsByAppIdChatMessagesError - = GetAppsByAppIdChatMessagesErrors[keyof GetAppsByAppIdChatMessagesErrors] - export type GetAppsByAppIdChatMessagesResponses = { 200: MessageInfiniteScrollPaginationResponse } @@ -3350,14 +3659,9 @@ export type GetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsData = { } export type GetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type GetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsError - = GetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsErrors[keyof GetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsErrors] - export type GetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsResponses = { 200: SuggestedQuestionsResponse } @@ -3389,24 +3693,19 @@ export type GetAppsByAppIdCompletionConversationsData = { } query?: { annotation_status?: 'all' | 'annotated' | 'not_annotated' - end?: string | null - keyword?: string | null + end?: string + keyword?: string limit?: number page?: number - start?: string | null + start?: string } url: '/apps/{app_id}/completion-conversations' } export type GetAppsByAppIdCompletionConversationsErrors = { - 403: { - [key: string]: unknown - } + 403: unknown } -export type GetAppsByAppIdCompletionConversationsError - = GetAppsByAppIdCompletionConversationsErrors[keyof GetAppsByAppIdCompletionConversationsErrors] - export type GetAppsByAppIdCompletionConversationsResponses = { 200: ConversationPagination } @@ -3425,21 +3724,12 @@ export type DeleteAppsByAppIdCompletionConversationsByConversationIdData = { } export type DeleteAppsByAppIdCompletionConversationsByConversationIdErrors = { - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 403: unknown + 404: unknown } -export type DeleteAppsByAppIdCompletionConversationsByConversationIdError - = DeleteAppsByAppIdCompletionConversationsByConversationIdErrors[keyof DeleteAppsByAppIdCompletionConversationsByConversationIdErrors] - export type DeleteAppsByAppIdCompletionConversationsByConversationIdResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteAppsByAppIdCompletionConversationsByConversationIdResponse @@ -3456,17 +3746,10 @@ export type GetAppsByAppIdCompletionConversationsByConversationIdData = { } export type GetAppsByAppIdCompletionConversationsByConversationIdErrors = { - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 403: unknown + 404: unknown } -export type GetAppsByAppIdCompletionConversationsByConversationIdError - = GetAppsByAppIdCompletionConversationsByConversationIdErrors[keyof GetAppsByAppIdCompletionConversationsByConversationIdErrors] - export type GetAppsByAppIdCompletionConversationsByConversationIdResponses = { 200: ConversationMessageDetail } @@ -3484,21 +3767,12 @@ export type PostAppsByAppIdCompletionMessagesData = { } export type PostAppsByAppIdCompletionMessagesErrors = { - 400: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 400: unknown + 404: unknown } -export type PostAppsByAppIdCompletionMessagesError - = PostAppsByAppIdCompletionMessagesErrors[keyof PostAppsByAppIdCompletionMessagesErrors] - export type PostAppsByAppIdCompletionMessagesResponses = { - 200: { - [key: string]: unknown - } + 200: GeneratedAppResponse } export type PostAppsByAppIdCompletionMessagesResponse @@ -3549,17 +3823,10 @@ export type PostAppsByAppIdConvertToWorkflowData = { } export type PostAppsByAppIdConvertToWorkflowErrors = { - 400: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } + 400: unknown + 403: unknown } -export type PostAppsByAppIdConvertToWorkflowError - = PostAppsByAppIdConvertToWorkflowErrors[keyof PostAppsByAppIdConvertToWorkflowErrors] - export type PostAppsByAppIdConvertToWorkflowResponses = { 200: NewAppResponse } @@ -3577,13 +3844,9 @@ export type PostAppsByAppIdCopyData = { } export type PostAppsByAppIdCopyErrors = { - 403: { - [key: string]: unknown - } + 403: unknown } -export type PostAppsByAppIdCopyError = PostAppsByAppIdCopyErrors[keyof PostAppsByAppIdCopyErrors] - export type PostAppsByAppIdCopyResponses = { 201: AppDetailWithSite } @@ -3598,19 +3861,15 @@ export type GetAppsByAppIdExportData = { } query?: { include_secret?: boolean - workflow_id?: string | null + workflow_id?: string } url: '/apps/{app_id}/export' } export type GetAppsByAppIdExportErrors = { - 403: { - [key: string]: unknown - } + 403: unknown } -export type GetAppsByAppIdExportError = GetAppsByAppIdExportErrors[keyof GetAppsByAppIdExportErrors] - export type GetAppsByAppIdExportResponses = { 200: AppExportResponse } @@ -3628,17 +3887,10 @@ export type PostAppsByAppIdFeedbacksData = { } export type PostAppsByAppIdFeedbacksErrors = { - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 403: unknown + 404: unknown } -export type PostAppsByAppIdFeedbacksError - = PostAppsByAppIdFeedbacksErrors[keyof PostAppsByAppIdFeedbacksErrors] - export type PostAppsByAppIdFeedbacksResponses = { 200: SimpleResultResponse } @@ -3652,32 +3904,23 @@ export type GetAppsByAppIdFeedbacksExportData = { app_id: string } query?: { - end_date?: string | null + end_date?: string format?: 'csv' | 'json' - from_source?: 'admin' | 'user' | null - has_comment?: boolean | null - rating?: 'dislike' | 'like' | null - start_date?: string | null + from_source?: 'admin' | 'user' + has_comment?: boolean + rating?: 'dislike' | 'like' + start_date?: string } url: '/apps/{app_id}/feedbacks/export' } export type GetAppsByAppIdFeedbacksExportErrors = { - 400: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 400: unknown + 500: unknown } -export type GetAppsByAppIdFeedbacksExportError - = GetAppsByAppIdFeedbacksExportErrors[keyof GetAppsByAppIdFeedbacksExportErrors] - export type GetAppsByAppIdFeedbacksExportResponses = { - 200: { - [key: string]: unknown - } + 200: TextFileResponse } export type GetAppsByAppIdFeedbacksExportResponse @@ -3693,17 +3936,11 @@ export type PostAppsByAppIdIconData = { } export type PostAppsByAppIdIconErrors = { - 403: { - [key: string]: unknown - } + 403: unknown } -export type PostAppsByAppIdIconError = PostAppsByAppIdIconErrors[keyof PostAppsByAppIdIconErrors] - export type PostAppsByAppIdIconResponses = { - 200: { - [key: string]: unknown - } + 200: AppDetail } export type PostAppsByAppIdIconResponse @@ -3720,14 +3957,9 @@ export type GetAppsByAppIdMessagesByMessageIdData = { } export type GetAppsByAppIdMessagesByMessageIdErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type GetAppsByAppIdMessagesByMessageIdError - = GetAppsByAppIdMessagesByMessageIdErrors[keyof GetAppsByAppIdMessagesByMessageIdErrors] - export type GetAppsByAppIdMessagesByMessageIdResponses = { 200: MessageDetailResponse } @@ -3745,21 +3977,12 @@ export type PostAppsByAppIdModelConfigData = { } export type PostAppsByAppIdModelConfigErrors = { - 400: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 400: unknown + 404: unknown } -export type PostAppsByAppIdModelConfigError - = PostAppsByAppIdModelConfigErrors[keyof PostAppsByAppIdModelConfigErrors] - export type PostAppsByAppIdModelConfigResponses = { - 200: { - [key: string]: unknown - } + 200: SimpleResultResponse } export type PostAppsByAppIdModelConfigResponse @@ -3823,14 +4046,9 @@ export type PostAppsByAppIdServerData = { } export type PostAppsByAppIdServerErrors = { - 403: { - [key: string]: unknown - } + 403: unknown } -export type PostAppsByAppIdServerError - = PostAppsByAppIdServerErrors[keyof PostAppsByAppIdServerErrors] - export type PostAppsByAppIdServerResponses = { 201: AppMcpServerResponse } @@ -3848,16 +4066,10 @@ export type PutAppsByAppIdServerData = { } export type PutAppsByAppIdServerErrors = { - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 403: unknown + 404: unknown } -export type PutAppsByAppIdServerError = PutAppsByAppIdServerErrors[keyof PutAppsByAppIdServerErrors] - export type PutAppsByAppIdServerResponses = { 200: AppMcpServerResponse } @@ -3875,16 +4087,10 @@ export type PostAppsByAppIdSiteData = { } export type PostAppsByAppIdSiteErrors = { - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 403: unknown + 404: unknown } -export type PostAppsByAppIdSiteError = PostAppsByAppIdSiteErrors[keyof PostAppsByAppIdSiteErrors] - export type PostAppsByAppIdSiteResponses = { 200: AppSiteResponse } @@ -3902,14 +4108,9 @@ export type PostAppsByAppIdSiteEnableData = { } export type PostAppsByAppIdSiteEnableErrors = { - 403: { - [key: string]: unknown - } + 403: unknown } -export type PostAppsByAppIdSiteEnableError - = PostAppsByAppIdSiteEnableErrors[keyof PostAppsByAppIdSiteEnableErrors] - export type PostAppsByAppIdSiteEnableResponses = { 200: AppDetail } @@ -3927,17 +4128,10 @@ export type PostAppsByAppIdSiteAccessTokenResetData = { } export type PostAppsByAppIdSiteAccessTokenResetErrors = { - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 403: unknown + 404: unknown } -export type PostAppsByAppIdSiteAccessTokenResetError - = PostAppsByAppIdSiteAccessTokenResetErrors[keyof PostAppsByAppIdSiteAccessTokenResetErrors] - export type PostAppsByAppIdSiteAccessTokenResetResponses = { 200: AppSiteResponse } @@ -3945,22 +4139,60 @@ export type PostAppsByAppIdSiteAccessTokenResetResponses = { export type PostAppsByAppIdSiteAccessTokenResetResponse = PostAppsByAppIdSiteAccessTokenResetResponses[keyof PostAppsByAppIdSiteAccessTokenResetResponses] +export type DeleteAppsByAppIdStarData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/star' +} + +export type DeleteAppsByAppIdStarErrors = { + 404: unknown +} + +export type DeleteAppsByAppIdStarResponses = { + 200: SimpleResultResponse +} + +export type DeleteAppsByAppIdStarResponse + = DeleteAppsByAppIdStarResponses[keyof DeleteAppsByAppIdStarResponses] + +export type PostAppsByAppIdStarData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/star' +} + +export type PostAppsByAppIdStarErrors = { + 404: unknown +} + +export type PostAppsByAppIdStarResponses = { + 200: SimpleResultResponse +} + +export type PostAppsByAppIdStarResponse + = PostAppsByAppIdStarResponses[keyof PostAppsByAppIdStarResponses] + export type GetAppsByAppIdStatisticsAverageResponseTimeData = { body?: never path: { app_id: string } query?: { - end?: string | null - start?: string | null + end?: string + start?: string } url: '/apps/{app_id}/statistics/average-response-time' } export type GetAppsByAppIdStatisticsAverageResponseTimeResponses = { - 200: Array<{ - [key: string]: unknown - }> + 200: AverageResponseTimeStatisticResponse } export type GetAppsByAppIdStatisticsAverageResponseTimeResponse @@ -3972,16 +4204,14 @@ export type GetAppsByAppIdStatisticsAverageSessionInteractionsData = { app_id: string } query?: { - end?: string | null - start?: string | null + end?: string + start?: string } url: '/apps/{app_id}/statistics/average-session-interactions' } export type GetAppsByAppIdStatisticsAverageSessionInteractionsResponses = { - 200: Array<{ - [key: string]: unknown - }> + 200: AverageSessionInteractionStatisticResponse } export type GetAppsByAppIdStatisticsAverageSessionInteractionsResponse @@ -3993,16 +4223,14 @@ export type GetAppsByAppIdStatisticsDailyConversationsData = { app_id: string } query?: { - end?: string | null - start?: string | null + end?: string + start?: string } url: '/apps/{app_id}/statistics/daily-conversations' } export type GetAppsByAppIdStatisticsDailyConversationsResponses = { - 200: Array<{ - [key: string]: unknown - }> + 200: DailyConversationStatisticResponse } export type GetAppsByAppIdStatisticsDailyConversationsResponse @@ -4014,16 +4242,14 @@ export type GetAppsByAppIdStatisticsDailyEndUsersData = { app_id: string } query?: { - end?: string | null - start?: string | null + end?: string + start?: string } url: '/apps/{app_id}/statistics/daily-end-users' } export type GetAppsByAppIdStatisticsDailyEndUsersResponses = { - 200: Array<{ - [key: string]: unknown - }> + 200: DailyTerminalStatisticResponse } export type GetAppsByAppIdStatisticsDailyEndUsersResponse @@ -4035,16 +4261,14 @@ export type GetAppsByAppIdStatisticsDailyMessagesData = { app_id: string } query?: { - end?: string | null - start?: string | null + end?: string + start?: string } url: '/apps/{app_id}/statistics/daily-messages' } export type GetAppsByAppIdStatisticsDailyMessagesResponses = { - 200: Array<{ - [key: string]: unknown - }> + 200: DailyMessageStatisticResponse } export type GetAppsByAppIdStatisticsDailyMessagesResponse @@ -4056,16 +4280,14 @@ export type GetAppsByAppIdStatisticsTokenCostsData = { app_id: string } query?: { - end?: string | null - start?: string | null + end?: string + start?: string } url: '/apps/{app_id}/statistics/token-costs' } export type GetAppsByAppIdStatisticsTokenCostsResponses = { - 200: Array<{ - [key: string]: unknown - }> + 200: DailyTokenCostStatisticResponse } export type GetAppsByAppIdStatisticsTokenCostsResponse @@ -4077,16 +4299,14 @@ export type GetAppsByAppIdStatisticsTokensPerSecondData = { app_id: string } query?: { - end?: string | null - start?: string | null + end?: string + start?: string } url: '/apps/{app_id}/statistics/tokens-per-second' } export type GetAppsByAppIdStatisticsTokensPerSecondResponses = { - 200: Array<{ - [key: string]: unknown - }> + 200: TokensPerSecondStatisticResponse } export type GetAppsByAppIdStatisticsTokensPerSecondResponse @@ -4098,16 +4318,14 @@ export type GetAppsByAppIdStatisticsUserSatisfactionRateData = { app_id: string } query?: { - end?: string | null - start?: string | null + end?: string + start?: string } url: '/apps/{app_id}/statistics/user-satisfaction-rate' } export type GetAppsByAppIdStatisticsUserSatisfactionRateResponses = { - 200: Array<{ - [key: string]: unknown - }> + 200: UserSatisfactionRateStatisticResponse } export type GetAppsByAppIdStatisticsUserSatisfactionRateResponse @@ -4123,18 +4341,11 @@ export type PostAppsByAppIdTextToAudioData = { } export type PostAppsByAppIdTextToAudioErrors = { - 400: { - [key: string]: unknown - } + 400: unknown } -export type PostAppsByAppIdTextToAudioError - = PostAppsByAppIdTextToAudioErrors[keyof PostAppsByAppIdTextToAudioErrors] - export type PostAppsByAppIdTextToAudioResponses = { - 200: { - [key: string]: unknown - } + 200: AudioBinaryResponse } export type PostAppsByAppIdTextToAudioResponse @@ -4152,18 +4363,11 @@ export type GetAppsByAppIdTextToAudioVoicesData = { } export type GetAppsByAppIdTextToAudioVoicesErrors = { - 400: { - [key: string]: unknown - } + 400: unknown } -export type GetAppsByAppIdTextToAudioVoicesError - = GetAppsByAppIdTextToAudioVoicesErrors[keyof GetAppsByAppIdTextToAudioVoicesErrors] - export type GetAppsByAppIdTextToAudioVoicesResponses = { - 200: Array<{ - [key: string]: unknown - }> + 200: TextToSpeechVoiceListResponse } export type GetAppsByAppIdTextToAudioVoicesResponse @@ -4179,9 +4383,7 @@ export type GetAppsByAppIdTraceData = { } export type GetAppsByAppIdTraceResponses = { - 200: { - [key: string]: unknown - } + 200: AppTraceResponse } export type GetAppsByAppIdTraceResponse @@ -4197,13 +4399,9 @@ export type PostAppsByAppIdTraceData = { } export type PostAppsByAppIdTraceErrors = { - 403: { - [key: string]: unknown - } + 403: unknown } -export type PostAppsByAppIdTraceError = PostAppsByAppIdTraceErrors[keyof PostAppsByAppIdTraceErrors] - export type PostAppsByAppIdTraceResponses = { 200: SimpleResultResponse } @@ -4212,27 +4410,22 @@ export type PostAppsByAppIdTraceResponse = PostAppsByAppIdTraceResponses[keyof PostAppsByAppIdTraceResponses] export type DeleteAppsByAppIdTraceConfigData = { - body: TraceProviderQuery + body?: never path: { app_id: string } - query?: never + query: { + tracing_provider: string + } url: '/apps/{app_id}/trace-config' } export type DeleteAppsByAppIdTraceConfigErrors = { - 400: { - [key: string]: unknown - } + 400: unknown } -export type DeleteAppsByAppIdTraceConfigError - = DeleteAppsByAppIdTraceConfigErrors[keyof DeleteAppsByAppIdTraceConfigErrors] - export type DeleteAppsByAppIdTraceConfigResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteAppsByAppIdTraceConfigResponse @@ -4250,18 +4443,11 @@ export type GetAppsByAppIdTraceConfigData = { } export type GetAppsByAppIdTraceConfigErrors = { - 400: { - [key: string]: unknown - } + 400: unknown } -export type GetAppsByAppIdTraceConfigError - = GetAppsByAppIdTraceConfigErrors[keyof GetAppsByAppIdTraceConfigErrors] - export type GetAppsByAppIdTraceConfigResponses = { - 200: { - [key: string]: unknown - } + 200: TraceAppConfigResponse } export type GetAppsByAppIdTraceConfigResponse @@ -4277,18 +4463,11 @@ export type PatchAppsByAppIdTraceConfigData = { } export type PatchAppsByAppIdTraceConfigErrors = { - 400: { - [key: string]: unknown - } + 400: unknown } -export type PatchAppsByAppIdTraceConfigError - = PatchAppsByAppIdTraceConfigErrors[keyof PatchAppsByAppIdTraceConfigErrors] - export type PatchAppsByAppIdTraceConfigResponses = { - 200: { - [key: string]: unknown - } + 200: TraceAppConfigResponse } export type PatchAppsByAppIdTraceConfigResponse @@ -4304,18 +4483,11 @@ export type PostAppsByAppIdTraceConfigData = { } export type PostAppsByAppIdTraceConfigErrors = { - 400: { - [key: string]: unknown - } + 400: unknown } -export type PostAppsByAppIdTraceConfigError - = PostAppsByAppIdTraceConfigErrors[keyof PostAppsByAppIdTraceConfigErrors] - export type PostAppsByAppIdTraceConfigResponses = { - 201: { - [key: string]: unknown - } + 201: TraceAppConfigResponse } export type PostAppsByAppIdTraceConfigResponse @@ -4359,15 +4531,22 @@ export type GetAppsByAppIdWorkflowAppLogsData = { app_id: string } query?: { - created_at__after?: string | null - created_at__before?: string | null - created_by_account?: string | null - created_by_end_user_session_id?: string | null + created_at__after?: string + created_at__before?: string + created_by_account?: string + created_by_end_user_session_id?: string detail?: boolean - keyword?: string | null + keyword?: string limit?: number page?: number - status?: string | null + status?: + | 'failed' + | 'partial-succeeded' + | 'paused' + | 'running' + | 'scheduled' + | 'stopped' + | 'succeeded' } url: '/apps/{app_id}/workflow-app-logs' } @@ -4385,15 +4564,22 @@ export type GetAppsByAppIdWorkflowArchivedLogsData = { app_id: string } query?: { - created_at__after?: string | null - created_at__before?: string | null - created_by_account?: string | null - created_by_end_user_session_id?: string | null + created_at__after?: string + created_at__before?: string + created_by_account?: string + created_by_end_user_session_id?: string detail?: boolean - keyword?: string | null + keyword?: string limit?: number page?: number - status?: string | null + status?: + | 'failed' + | 'partial-succeeded' + | 'paused' + | 'running' + | 'scheduled' + | 'stopped' + | 'succeeded' } url: '/apps/{app_id}/workflow-archived-logs' } @@ -4457,17 +4643,10 @@ export type PostAppsByAppIdWorkflowRunsTasksByTaskIdStopData = { } export type PostAppsByAppIdWorkflowRunsTasksByTaskIdStopErrors = { - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 403: unknown + 404: unknown } -export type PostAppsByAppIdWorkflowRunsTasksByTaskIdStopError - = PostAppsByAppIdWorkflowRunsTasksByTaskIdStopErrors[keyof PostAppsByAppIdWorkflowRunsTasksByTaskIdStopErrors] - export type PostAppsByAppIdWorkflowRunsTasksByTaskIdStopResponses = { 200: SimpleResultResponse } @@ -4486,14 +4665,9 @@ export type GetAppsByAppIdWorkflowRunsByRunIdData = { } export type GetAppsByAppIdWorkflowRunsByRunIdErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type GetAppsByAppIdWorkflowRunsByRunIdError - = GetAppsByAppIdWorkflowRunsByRunIdErrors[keyof GetAppsByAppIdWorkflowRunsByRunIdErrors] - export type GetAppsByAppIdWorkflowRunsByRunIdResponses = { 200: WorkflowRunDetailResponse } @@ -4529,14 +4703,9 @@ export type GetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsData = { } export type GetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type GetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsError - = GetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsErrors[keyof GetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsErrors] - export type GetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponses = { 200: WorkflowRunNodeExecutionListResponse } @@ -4544,7 +4713,7 @@ export type GetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponses = { export type GetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponse = GetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponses[keyof GetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponses] -export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesData = { +export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesData = { body?: never path: { app_id: string @@ -4555,50 +4724,17 @@ export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspace node_execution_id?: string path?: string } - url: '/apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/workspace/files' + url: '/apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files' } -export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesResponses = { - 200: WorkspaceListResponse +export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponses = { + 200: SandboxListResponse } -export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesResponse - = GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesResponses[keyof GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesResponses] +export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponse + = GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponses[keyof GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponses] -export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadData - = { - body?: never - path: { - app_id: string - node_id: string - workflow_run_id: string - } - query: { - node_execution_id?: string - path: string - } - url: '/apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/workspace/files/download' - } - -export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadErrors - = { - 413: { - [key: string]: unknown - } - } - -export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadError - = GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadErrors[keyof GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadErrors] - -export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadResponses - = { - 200: Blob | File - } - -export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadResponse - = GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadResponses[keyof GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadResponses] - -export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewData = { +export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadData = { body?: never path: { app_id: string @@ -4609,16 +4745,34 @@ export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspace node_execution_id?: string path: string } - url: '/apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/workspace/files/preview' + url: '/apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files/read' } -export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewResponses +export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponses = { + 200: SandboxReadResponse +} + +export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponse + = GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponses[keyof GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponses] + +export type PostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadData = { + body: WorkflowAgentSandboxUploadPayload + path: { + app_id: string + node_id: string + workflow_run_id: string + } + query?: never + url: '/apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files/upload' +} + +export type PostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponses = { - 200: WorkspacePreviewResponse + 200: SandboxUploadResponse } -export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewResponse - = GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewResponses[keyof GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewResponses] +export type PostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponse + = PostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponses[keyof PostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponses] export type GetAppsByAppIdWorkflowCommentsData = { body?: never @@ -4679,9 +4833,7 @@ export type DeleteAppsByAppIdWorkflowCommentsByCommentIdData = { } export type DeleteAppsByAppIdWorkflowCommentsByCommentIdResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteAppsByAppIdWorkflowCommentsByCommentIdResponse @@ -4750,9 +4902,7 @@ export type DeleteAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdData = { } export type DeleteAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdResponse @@ -4799,16 +4949,14 @@ export type GetAppsByAppIdWorkflowStatisticsAverageAppInteractionsData = { app_id: string } query?: { - end?: string | null - start?: string | null + end?: string + start?: string } url: '/apps/{app_id}/workflow/statistics/average-app-interactions' } export type GetAppsByAppIdWorkflowStatisticsAverageAppInteractionsResponses = { - 200: { - [key: string]: unknown - } + 200: WorkflowAverageAppInteractionStatisticResponse } export type GetAppsByAppIdWorkflowStatisticsAverageAppInteractionsResponse @@ -4820,16 +4968,14 @@ export type GetAppsByAppIdWorkflowStatisticsDailyConversationsData = { app_id: string } query?: { - end?: string | null - start?: string | null + end?: string + start?: string } url: '/apps/{app_id}/workflow/statistics/daily-conversations' } export type GetAppsByAppIdWorkflowStatisticsDailyConversationsResponses = { - 200: { - [key: string]: unknown - } + 200: WorkflowDailyRunsStatisticResponse } export type GetAppsByAppIdWorkflowStatisticsDailyConversationsResponse @@ -4841,16 +4987,14 @@ export type GetAppsByAppIdWorkflowStatisticsDailyTerminalsData = { app_id: string } query?: { - end?: string | null - start?: string | null + end?: string + start?: string } url: '/apps/{app_id}/workflow/statistics/daily-terminals' } export type GetAppsByAppIdWorkflowStatisticsDailyTerminalsResponses = { - 200: { - [key: string]: unknown - } + 200: WorkflowDailyTerminalsStatisticResponse } export type GetAppsByAppIdWorkflowStatisticsDailyTerminalsResponse @@ -4862,16 +5006,14 @@ export type GetAppsByAppIdWorkflowStatisticsTokenCostsData = { app_id: string } query?: { - end?: string | null - start?: string | null + end?: string + start?: string } url: '/apps/{app_id}/workflow/statistics/token-costs' } export type GetAppsByAppIdWorkflowStatisticsTokenCostsResponses = { - 200: { - [key: string]: unknown - } + 200: WorkflowDailyTokenCostStatisticResponse } export type GetAppsByAppIdWorkflowStatisticsTokenCostsResponse @@ -4886,7 +5028,7 @@ export type GetAppsByAppIdWorkflowsData = { limit?: number named_only?: boolean page?: number - user_id?: string | null + user_id?: string } url: '/apps/{app_id}/workflows' } @@ -4908,9 +5050,7 @@ export type GetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsData = { } export type GetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsResponses = { - 200: { - [key: string]: unknown - } + 200: DefaultBlockConfigsResponse } export type GetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsResponse @@ -4923,24 +5063,17 @@ export type GetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeData = block_type: string } query?: { - q?: string | null + q?: string } url: '/apps/{app_id}/workflows/default-workflow-block-configs/{block_type}' } export type GetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type GetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeError - = GetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeErrors[keyof GetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeErrors] - export type GetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponses = { - 200: { - [key: string]: unknown - } + 200: DefaultBlockConfigResponse } export type GetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponse @@ -4956,14 +5089,9 @@ export type GetAppsByAppIdWorkflowsDraftData = { } export type GetAppsByAppIdWorkflowsDraftErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type GetAppsByAppIdWorkflowsDraftError - = GetAppsByAppIdWorkflowsDraftErrors[keyof GetAppsByAppIdWorkflowsDraftErrors] - export type GetAppsByAppIdWorkflowsDraftResponses = { 200: WorkflowResponse } @@ -4981,17 +5109,10 @@ export type PostAppsByAppIdWorkflowsDraftData = { } export type PostAppsByAppIdWorkflowsDraftErrors = { - 400: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } + 400: unknown + 403: unknown } -export type PostAppsByAppIdWorkflowsDraftError - = PostAppsByAppIdWorkflowsDraftErrors[keyof PostAppsByAppIdWorkflowsDraftErrors] - export type PostAppsByAppIdWorkflowsDraftResponses = { 200: SyncDraftWorkflowResponse } @@ -5009,14 +5130,9 @@ export type GetAppsByAppIdWorkflowsDraftConversationVariablesData = { } export type GetAppsByAppIdWorkflowsDraftConversationVariablesErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type GetAppsByAppIdWorkflowsDraftConversationVariablesError - = GetAppsByAppIdWorkflowsDraftConversationVariablesErrors[keyof GetAppsByAppIdWorkflowsDraftConversationVariablesErrors] - export type GetAppsByAppIdWorkflowsDraftConversationVariablesResponses = { 200: WorkflowDraftVariableList } @@ -5034,9 +5150,7 @@ export type PostAppsByAppIdWorkflowsDraftConversationVariablesData = { } export type PostAppsByAppIdWorkflowsDraftConversationVariablesResponses = { - 200: { - [key: string]: unknown - } + 200: SimpleResultResponse } export type PostAppsByAppIdWorkflowsDraftConversationVariablesResponse @@ -5052,18 +5166,11 @@ export type GetAppsByAppIdWorkflowsDraftEnvironmentVariablesData = { } export type GetAppsByAppIdWorkflowsDraftEnvironmentVariablesErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type GetAppsByAppIdWorkflowsDraftEnvironmentVariablesError - = GetAppsByAppIdWorkflowsDraftEnvironmentVariablesErrors[keyof GetAppsByAppIdWorkflowsDraftEnvironmentVariablesErrors] - export type GetAppsByAppIdWorkflowsDraftEnvironmentVariablesResponses = { - 200: { - [key: string]: unknown - } + 200: EnvironmentVariableListResponse } export type GetAppsByAppIdWorkflowsDraftEnvironmentVariablesResponse @@ -5079,9 +5186,7 @@ export type PostAppsByAppIdWorkflowsDraftEnvironmentVariablesData = { } export type PostAppsByAppIdWorkflowsDraftEnvironmentVariablesResponses = { - 200: { - [key: string]: unknown - } + 200: SimpleResultResponse } export type PostAppsByAppIdWorkflowsDraftEnvironmentVariablesResponse @@ -5114,9 +5219,7 @@ export type PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTestData } export type PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTestResponses = { - 200: { - [key: string]: unknown - } + 200: EmptyObjectResponse } export type PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTestResponse @@ -5133,9 +5236,7 @@ export type PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewData } export type PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponses = { - 200: { - [key: string]: unknown - } + 200: HumanInputFormPreviewResponse } export type PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponse @@ -5152,9 +5253,7 @@ export type PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunData = { } export type PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunResponses = { - 200: { - [key: string]: unknown - } + 200: HumanInputFormSubmitResponse } export type PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunResponse @@ -5171,21 +5270,12 @@ export type PostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunData = { } export type PostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunErrors = { - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 403: unknown + 404: unknown } -export type PostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunError - = PostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunErrors[keyof PostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunErrors] - export type PostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunResponses = { - 200: { - [key: string]: unknown - } + 200: GeneratedAppResponse } export type PostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunResponse @@ -5202,21 +5292,12 @@ export type PostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunData = { } export type PostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunErrors = { - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 403: unknown + 404: unknown } -export type PostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunError - = PostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunErrors[keyof PostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunErrors] - export type PostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponses = { - 200: { - [key: string]: unknown - } + 200: GeneratedAppResponse } export type PostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponse @@ -5335,17 +5416,10 @@ export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunData = { } export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunErrors = { - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 403: unknown + 404: unknown } -export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunError - = GetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunErrors[keyof GetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunErrors] - export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunResponses = { 200: WorkflowRunNodeExecutionResponse } @@ -5364,17 +5438,10 @@ export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdRunData = { } export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdRunErrors = { - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 403: unknown + 404: unknown } -export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdRunError - = PostAppsByAppIdWorkflowsDraftNodesByNodeIdRunErrors[keyof PostAppsByAppIdWorkflowsDraftNodesByNodeIdRunErrors] - export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdRunResponses = { 200: WorkflowRunNodeExecutionResponse } @@ -5393,21 +5460,12 @@ export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunData = { } export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunErrors = { - 403: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 403: unknown + 500: unknown } -export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunError - = PostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunErrors[keyof PostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunErrors] - export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunResponses = { - 200: { - [key: string]: unknown - } + 200: GeneratedAppResponse } export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunResponse @@ -5424,9 +5482,7 @@ export type DeleteAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesData = { } export type DeleteAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesResponse @@ -5459,18 +5515,11 @@ export type PostAppsByAppIdWorkflowsDraftRunData = { } export type PostAppsByAppIdWorkflowsDraftRunErrors = { - 403: { - [key: string]: unknown - } + 403: unknown } -export type PostAppsByAppIdWorkflowsDraftRunError - = PostAppsByAppIdWorkflowsDraftRunErrors[keyof PostAppsByAppIdWorkflowsDraftRunErrors] - export type PostAppsByAppIdWorkflowsDraftRunResponses = { - 200: { - [key: string]: unknown - } + 200: GeneratedAppResponse } export type PostAppsByAppIdWorkflowsDraftRunResponse @@ -5487,14 +5536,9 @@ export type GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsData = { } export type GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsError - = GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsErrors[keyof GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsErrors] - export type GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsResponses = { 200: WorkflowRunSnapshotView } @@ -5513,18 +5557,11 @@ export type GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsEventsData = { } export type GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsEventsErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsEventsError - = GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsEventsErrors[keyof GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsEventsErrors] - export type GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsEventsResponses = { - 200: { - [key: string]: unknown - } + 200: EventStreamResponse } export type GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsEventsResponse @@ -5542,14 +5579,9 @@ export type GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdData = { } export type GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdError - = GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdErrors[keyof GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdErrors] - export type GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdResponses = { 200: NodeOutputsView } @@ -5570,14 +5602,9 @@ export type GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdByOutputNa } export type GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewError - = GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewErrors[keyof GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewErrors] - export type GetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponses = { 200: OutputPreviewView @@ -5612,21 +5639,12 @@ export type PostAppsByAppIdWorkflowsDraftTriggerRunData = { } export type PostAppsByAppIdWorkflowsDraftTriggerRunErrors = { - 403: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 403: unknown + 500: unknown } -export type PostAppsByAppIdWorkflowsDraftTriggerRunError - = PostAppsByAppIdWorkflowsDraftTriggerRunErrors[keyof PostAppsByAppIdWorkflowsDraftTriggerRunErrors] - export type PostAppsByAppIdWorkflowsDraftTriggerRunResponses = { - 200: { - [key: string]: unknown - } + 200: GeneratedAppResponse } export type PostAppsByAppIdWorkflowsDraftTriggerRunResponse @@ -5642,21 +5660,12 @@ export type PostAppsByAppIdWorkflowsDraftTriggerRunAllData = { } export type PostAppsByAppIdWorkflowsDraftTriggerRunAllErrors = { - 403: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 403: unknown + 500: unknown } -export type PostAppsByAppIdWorkflowsDraftTriggerRunAllError - = PostAppsByAppIdWorkflowsDraftTriggerRunAllErrors[keyof PostAppsByAppIdWorkflowsDraftTriggerRunAllErrors] - export type PostAppsByAppIdWorkflowsDraftTriggerRunAllResponses = { - 200: { - [key: string]: unknown - } + 200: GeneratedAppResponse } export type PostAppsByAppIdWorkflowsDraftTriggerRunAllResponse @@ -5672,9 +5681,7 @@ export type DeleteAppsByAppIdWorkflowsDraftVariablesData = { } export type DeleteAppsByAppIdWorkflowsDraftVariablesResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteAppsByAppIdWorkflowsDraftVariablesResponse @@ -5710,18 +5717,11 @@ export type DeleteAppsByAppIdWorkflowsDraftVariablesByVariableIdData = { } export type DeleteAppsByAppIdWorkflowsDraftVariablesByVariableIdErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type DeleteAppsByAppIdWorkflowsDraftVariablesByVariableIdError - = DeleteAppsByAppIdWorkflowsDraftVariablesByVariableIdErrors[keyof DeleteAppsByAppIdWorkflowsDraftVariablesByVariableIdErrors] - export type DeleteAppsByAppIdWorkflowsDraftVariablesByVariableIdResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteAppsByAppIdWorkflowsDraftVariablesByVariableIdResponse @@ -5738,14 +5738,9 @@ export type GetAppsByAppIdWorkflowsDraftVariablesByVariableIdData = { } export type GetAppsByAppIdWorkflowsDraftVariablesByVariableIdErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type GetAppsByAppIdWorkflowsDraftVariablesByVariableIdError - = GetAppsByAppIdWorkflowsDraftVariablesByVariableIdErrors[keyof GetAppsByAppIdWorkflowsDraftVariablesByVariableIdErrors] - export type GetAppsByAppIdWorkflowsDraftVariablesByVariableIdResponses = { 200: WorkflowDraftVariable } @@ -5764,14 +5759,9 @@ export type PatchAppsByAppIdWorkflowsDraftVariablesByVariableIdData = { } export type PatchAppsByAppIdWorkflowsDraftVariablesByVariableIdErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type PatchAppsByAppIdWorkflowsDraftVariablesByVariableIdError - = PatchAppsByAppIdWorkflowsDraftVariablesByVariableIdErrors[keyof PatchAppsByAppIdWorkflowsDraftVariablesByVariableIdErrors] - export type PatchAppsByAppIdWorkflowsDraftVariablesByVariableIdResponses = { 200: WorkflowDraftVariable } @@ -5790,19 +5780,12 @@ export type PutAppsByAppIdWorkflowsDraftVariablesByVariableIdResetData = { } export type PutAppsByAppIdWorkflowsDraftVariablesByVariableIdResetErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type PutAppsByAppIdWorkflowsDraftVariablesByVariableIdResetError - = PutAppsByAppIdWorkflowsDraftVariablesByVariableIdResetErrors[keyof PutAppsByAppIdWorkflowsDraftVariablesByVariableIdResetErrors] - export type PutAppsByAppIdWorkflowsDraftVariablesByVariableIdResetResponses = { 200: WorkflowDraftVariable - 204: { - [key: string]: never - } + 204: void } export type PutAppsByAppIdWorkflowsDraftVariablesByVariableIdResetResponse @@ -5834,9 +5817,7 @@ export type PostAppsByAppIdWorkflowsPublishData = { } export type PostAppsByAppIdWorkflowsPublishResponses = { - 200: { - [key: string]: unknown - } + 200: WorkflowPublishResponse } export type PostAppsByAppIdWorkflowsPublishResponse @@ -5853,14 +5834,9 @@ export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsData = { } export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsError - = GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsErrors[keyof GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsErrors] - export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsResponses = { 200: WorkflowRunSnapshotView } @@ -5879,18 +5855,11 @@ export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEventsData = { } export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEventsErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEventsError - = GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEventsErrors[keyof GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEventsErrors] - export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEventsResponses = { - 200: { - [key: string]: unknown - } + 200: EventStreamResponse } export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEventsResponse @@ -5908,14 +5877,9 @@ export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdData = } export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdError - = GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdErrors[keyof GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdErrors] - export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdResponses = { 200: NodeOutputsView } @@ -5938,14 +5902,9 @@ export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdByOutp export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewError - = GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewErrors[keyof GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewErrors] - export type GetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponses = { 200: OutputPreviewView @@ -5960,9 +5919,7 @@ export type GetAppsByAppIdWorkflowsTriggersWebhookData = { app_id: string } query: { - credential_id?: string | null - datasource_type: string - inputs: string + node_id: string } url: '/apps/{app_id}/workflows/triggers/webhook' } @@ -5985,9 +5942,7 @@ export type DeleteAppsByAppIdWorkflowsByWorkflowIdData = { } export type DeleteAppsByAppIdWorkflowsByWorkflowIdResponses = { - 200: { - [key: string]: unknown - } + 204: void } export type DeleteAppsByAppIdWorkflowsByWorkflowIdResponse @@ -6004,17 +5959,10 @@ export type PatchAppsByAppIdWorkflowsByWorkflowIdData = { } export type PatchAppsByAppIdWorkflowsByWorkflowIdErrors = { - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 403: unknown + 404: unknown } -export type PatchAppsByAppIdWorkflowsByWorkflowIdError - = PatchAppsByAppIdWorkflowsByWorkflowIdErrors[keyof PatchAppsByAppIdWorkflowsByWorkflowIdErrors] - export type PatchAppsByAppIdWorkflowsByWorkflowIdResponses = { 200: WorkflowResponse } @@ -6033,21 +5981,12 @@ export type PostAppsByAppIdWorkflowsByWorkflowIdRestoreData = { } export type PostAppsByAppIdWorkflowsByWorkflowIdRestoreErrors = { - 400: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 400: unknown + 404: unknown } -export type PostAppsByAppIdWorkflowsByWorkflowIdRestoreError - = PostAppsByAppIdWorkflowsByWorkflowIdRestoreErrors[keyof PostAppsByAppIdWorkflowsByWorkflowIdRestoreErrors] - export type PostAppsByAppIdWorkflowsByWorkflowIdRestoreResponses = { - 200: { - [key: string]: unknown - } + 200: WorkflowRestoreResponse } export type PostAppsByAppIdWorkflowsByWorkflowIdRestoreResponse @@ -6079,14 +6018,9 @@ export type PostAppsByResourceIdApiKeysData = { } export type PostAppsByResourceIdApiKeysErrors = { - 400: { - [key: string]: unknown - } + 400: unknown } -export type PostAppsByResourceIdApiKeysError - = PostAppsByResourceIdApiKeysErrors[keyof PostAppsByResourceIdApiKeysErrors] - export type PostAppsByResourceIdApiKeysResponses = { 201: ApiKeyItem } @@ -6105,9 +6039,7 @@ export type DeleteAppsByResourceIdApiKeysByApiKeyIdData = { } export type DeleteAppsByResourceIdApiKeysByApiKeyIdResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteAppsByResourceIdApiKeysByApiKeyIdResponse @@ -6123,17 +6055,10 @@ export type GetAppsByServerIdServerRefreshData = { } export type GetAppsByServerIdServerRefreshErrors = { - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 403: unknown + 404: unknown } -export type GetAppsByServerIdServerRefreshError - = GetAppsByServerIdServerRefreshErrors[keyof GetAppsByServerIdServerRefreshErrors] - export type GetAppsByServerIdServerRefreshResponses = { 200: AppMcpServerResponse } diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index a19377cba14..556f11f5521 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -43,6 +43,22 @@ export const zHumanInputFormPreviewPayload = z.object({ inputs: z.record(z.string(), z.unknown()).optional(), }) +/** + * HumanInputFormPreviewResponse + */ +export const zHumanInputFormPreviewResponse = z.object({ + actions: z.array(z.record(z.string(), z.unknown())).optional(), + display_in_ui: z.boolean().nullish(), + expiration_time: z.int().nullish(), + form_content: z.string(), + form_id: z.string(), + form_token: z.string().nullish(), + inputs: z.array(z.record(z.string(), z.unknown())).optional(), + node_id: z.string(), + node_title: z.string(), + resolved_default_values: z.record(z.string(), z.unknown()).optional(), +}) + /** * HumanInputFormSubmitPayload */ @@ -52,6 +68,11 @@ export const zHumanInputFormSubmitPayload = z.object({ inputs: z.record(z.string(), z.unknown()), }) +/** + * HumanInputFormSubmitResponse + */ +export const zHumanInputFormSubmitResponse = z.record(z.string(), z.unknown()) + /** * IterationNodeRunPayload */ @@ -78,21 +99,37 @@ export const zAdvancedChatWorkflowRunPayload = z.object({ }) /** - * SimpleResultResponse + * AgentDriveDownloadResponse */ -export const zSimpleResultResponse = z.object({ +export const zAgentDriveDownloadResponse = z.object({ + url: z.string(), +}) + +/** + * AgentDrivePreviewResponse + */ +export const zAgentDrivePreviewResponse = z.object({ + binary: z.boolean(), + key: z.string(), + size: z.int().nullish(), + text: z.string().nullish(), + truncated: z.boolean(), +}) + +/** + * AgentDriveDeleteResponse + */ +export const zAgentDriveDeleteResponse = z.object({ + config_version_id: z.string().nullish(), + removed_keys: z.array(z.string()).optional(), result: z.string(), }) /** - * WorkspacePreviewResponse + * AgentDriveFilePayload */ -export const zWorkspacePreviewResponse = z.object({ - binary: z.boolean(), - path: z.string(), - size: z.int(), - text: z.string().nullish(), - truncated: z.boolean(), +export const zAgentDriveFilePayload = z.object({ + upload_file_id: z.string(), }) /** @@ -104,6 +141,16 @@ export const zAnnotationReplyPayload = z.object({ score_threshold: z.number(), }) +/** + * AnnotationJobStatusResponse + */ +export const zAnnotationJobStatusResponse = z.object({ + error_msg: z.string().nullish(), + job_id: z.string().nullish(), + job_status: z.string().nullish(), + record_count: z.int().nullish(), +}) + /** * AnnotationSettingUpdatePayload */ @@ -126,13 +173,24 @@ export const zCreateAnnotationPayload = z.object({ * Annotation */ export const zAnnotation = z.object({ - content: z.string().nullish(), + answer: z.string().nullish(), created_at: z.int().nullish(), hit_count: z.int().nullish(), id: z.string(), question: z.string().nullish(), }) +/** + * AnnotationList + */ +export const zAnnotationList = z.object({ + data: z.array(zAnnotation), + has_more: z.boolean(), + limit: z.int(), + page: z.int(), + total: z.int(), +}) + /** * AnnotationCountResponse */ @@ -178,6 +236,13 @@ export const zSuggestedQuestionsResponse = z.object({ data: z.array(z.string()), }) +/** + * SimpleResultResponse + */ +export const zSimpleResultResponse = z.object({ + result: z.string(), +}) + /** * CompletionMessagePayload */ @@ -223,6 +288,11 @@ export const zMessageFeedbackPayload = z.object({ rating: z.enum(['dislike', 'like']).nullish(), }) +/** + * TextFileResponse + */ +export const zTextFileResponse = z.string() + /** * ModelConfigRequest */ @@ -333,6 +403,24 @@ export const zTextToSpeechPayload = z.object({ voice: z.string().nullish(), }) +/** + * AudioBinaryResponse + */ +export const zAudioBinaryResponse = z.custom() + +/** + * TextToSpeechVoiceListResponse + */ +export const zTextToSpeechVoiceListResponse = z.array(z.record(z.string(), z.unknown())) + +/** + * AppTraceResponse + */ +export const zAppTraceResponse = z.object({ + enabled: z.boolean(), + tracing_provider: z.string().nullish(), +}) + /** * AppTracePayload */ @@ -342,10 +430,19 @@ export const zAppTracePayload = z.object({ }) /** - * TraceProviderQuery + * TraceAppConfigResponse */ -export const zTraceProviderQuery = z.object({ - tracing_provider: z.string(), +export const zTraceAppConfigResponse = z.object({ + app_id: z.string().nullish(), + created_at: z.string().nullish(), + error: z.string().nullish(), + has_not_configured: z.boolean().nullish(), + id: z.string().nullish(), + is_active: z.boolean().nullish(), + result: z.string().nullish(), + tracing_config: z.record(z.string(), z.unknown()).nullish(), + tracing_provider: z.string().nullish(), + updated_at: z.string().nullish(), }) /** @@ -395,6 +492,25 @@ export const zWorkflowRunExportResponse = z.object({ status: z.string(), }) +/** + * SandboxReadResponse + */ +export const zSandboxReadResponse = z.object({ + binary: z.boolean(), + path: z.string(), + size: z.int().nullish(), + text: z.string().nullish(), + truncated: z.boolean(), +}) + +/** + * WorkflowAgentSandboxUploadPayload + */ +export const zWorkflowAgentSandboxUploadPayload = z.object({ + node_execution_id: z.string().nullish(), + path: z.string().min(1), +}) + /** * WorkflowCommentCreatePayload */ @@ -465,6 +581,16 @@ export const zWorkflowCommentResolve = z.object({ resolved_by: z.string().nullish(), }) +/** + * DefaultBlockConfigsResponse + */ +export const zDefaultBlockConfigsResponse = z.array(z.record(z.string(), z.unknown())) + +/** + * DefaultBlockConfigResponse + */ +export const zDefaultBlockConfigResponse = z.record(z.string(), z.unknown()) + /** * SyncDraftWorkflowPayload */ @@ -511,6 +637,11 @@ export const zHumanInputDeliveryTestPayload = z.object({ inputs: z.record(z.string(), z.unknown()).optional(), }) +/** + * EmptyObjectResponse + */ +export const zEmptyObjectResponse = z.record(z.string(), z.unknown()) + /** * DraftWorkflowNodeRunPayload */ @@ -530,6 +661,11 @@ export const zDraftWorkflowRunPayload = z.object({ start_node_id: z.string(), }) +/** + * EventStreamResponse + */ +export const zEventStreamResponse = z.string() + export const zDraftWorkflowTriggerRunRequest = z.object({ node_id: z.string(), }) @@ -550,7 +686,16 @@ export const zWorkflowDraftVariable = z.object({ name: z.string().optional(), selector: z.array(z.string()).optional(), type: z.string().optional(), - value: z.record(z.string(), z.unknown()).optional(), + value: z + .union([ + z.string(), + z.int(), + z.number(), + z.boolean(), + z.record(z.string(), z.unknown()), + z.array(z.unknown()), + ]) + .nullish(), value_type: z.string().optional(), visible: z.boolean().optional(), }) @@ -564,7 +709,7 @@ export const zWorkflowDraftVariableList = z.object({ */ export const zWorkflowDraftVariableUpdatePayload = z.object({ name: z.string().nullish(), - value: z.unknown().optional(), + value: z.unknown().nullish(), }) /** @@ -576,6 +721,14 @@ export const zPublishWorkflowPayload = z.object({ knowledge_base_setting: z.record(z.string(), z.unknown()).nullish(), }) +/** + * WorkflowPublishResponse + */ +export const zWorkflowPublishResponse = z.object({ + created_at: z.int(), + result: z.string(), +}) + /** * WebhookTriggerResponse */ @@ -596,6 +749,15 @@ export const zWorkflowUpdatePayload = z.object({ marked_name: z.string().max(20).nullish(), }) +/** + * WorkflowRestoreResponse + */ +export const zWorkflowRestoreResponse = z.object({ + hash: z.string(), + result: z.string(), + updated_at: z.int(), +}) + /** * ApiKeyItem */ @@ -626,8 +788,8 @@ export const zCreateAppPayload = z.object({ description: z.string().max(400).nullish(), icon: z.string().nullish(), icon_background: z.string().nullish(), - icon_type: zIconType.optional(), - mode: z.enum(['advanced-chat', 'agent', 'agent-chat', 'chat', 'completion', 'workflow']), + icon_type: zIconType.nullish(), + mode: z.enum(['advanced-chat', 'agent-chat', 'chat', 'completion', 'workflow']), name: z.string().min(1), }) @@ -638,7 +800,7 @@ export const zUpdateAppPayload = z.object({ description: z.string().max(400).nullish(), icon: z.string().nullish(), icon_background: z.string().nullish(), - icon_type: zIconType.optional(), + icon_type: zIconType.nullish(), max_active_requests: z.int().nullish(), name: z.string().min(1), use_icon_as_answer_icon: z.boolean().nullish(), @@ -651,7 +813,7 @@ export const zCopyAppPayload = z.object({ description: z.string().max(400).nullish(), icon: z.string().nullish(), icon_background: z.string().nullish(), - icon_type: zIconType.optional(), + icon_type: zIconType.nullish(), name: z.string().nullish(), }) @@ -661,7 +823,36 @@ export const zCopyAppPayload = z.object({ export const zAppIconPayload = z.object({ icon: z.string().nullish(), icon_background: z.string().nullish(), - icon_type: zIconType.optional(), + icon_type: zIconType.nullish(), +}) + +/** + * DeletedTool + */ +export const zDeletedTool = z.object({ + provider_id: z.string(), + tool_name: z.string(), + type: z.string(), +}) + +/** + * Site + */ +export const zSite = z.object({ + chat_color_theme: z.string().nullish(), + chat_color_theme_inverted: z.boolean(), + copyright: z.string().nullish(), + custom_disclaimer: z.string().nullish(), + default_language: z.string(), + description: z.string().nullish(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.string().nullish(), + icon_url: z.string().nullable(), + privacy_policy: z.string().nullish(), + show_workflow_steps: z.boolean(), + title: z.string(), + use_icon_as_answer_icon: z.boolean(), }) /** @@ -673,7 +864,21 @@ export const zTag = z.object({ type: z.string(), }) -export const zJsonValue = z.unknown() +export const zJsonValue = z + .union([ + z.string(), + z.int(), + z.number(), + z.boolean(), + z.record(z.string(), z.unknown()), + z.array(z.unknown()), + ]) + .nullable() + +/** + * GeneratedAppResponse + */ +export const zGeneratedAppResponse = zJsonValue /** * WorkflowPartial @@ -705,195 +910,129 @@ export const zImport = z.object({ }) /** - * DeletedTool + * AgentDriveItemResponse */ -export const zDeletedTool = z.object({ - provider_id: z.string(), - tool_name: z.string(), - type: z.string(), -}) - -/** - * Site - */ -export const zSite = z.object({ - app_base_url: z.string().nullish(), - chat_color_theme: z.string().nullish(), - chat_color_theme_inverted: z.boolean().nullish(), - code: z.string().nullish(), - copyright: z.string().nullish(), +export const zAgentDriveItemResponse = z.object({ created_at: z.int().nullish(), - created_by: z.string().nullish(), - custom_disclaimer: z.string().nullish(), - customize_domain: z.string().nullish(), - customize_token_strategy: z.string().nullish(), - default_language: z.string().nullish(), - description: z.string().nullish(), - icon: z.string().nullish(), - icon_background: z.string().nullish(), - icon_type: z.unknown().optional(), - privacy_policy: z.string().nullish(), - prompt_public: z.boolean().nullish(), - show_workflow_steps: z.boolean().nullish(), - title: z.string().nullish(), - updated_at: z.int().nullish(), - updated_by: z.string().nullish(), - use_icon_as_answer_icon: z.boolean().nullish(), + file_kind: z.string(), + hash: z.string().nullish(), + key: z.string(), + mime_type: z.string().nullish(), + size: z.int().nullish(), }) /** - * AgentConfigSnapshotSummaryResponse + * AgentDriveListResponse */ -export const zAgentConfigSnapshotSummaryResponse = z.object({ - agent_id: z.string().nullish(), - created_at: z.int().nullish(), - created_by: z.string().nullish(), - id: z.string(), - summary: z.string().nullish(), - version: z.int(), - version_note: z.string().nullish(), +export const zAgentDriveListResponse = z.object({ + items: z.array(zAgentDriveItemResponse).optional(), }) /** - * ComposerSaveStrategy + * AgentDriveFileResponse */ -export const zComposerSaveStrategy = z.enum([ - 'node_job_only', - 'save_as_new_agent', - 'save_as_new_version', - 'save_to_current_version', - 'save_to_roster', -]) - -/** - * ComposerBindingPayload - */ -export const zComposerBindingPayload = z.object({ - agent_id: z.string().nullish(), - binding_type: z.enum(['inline_agent', 'roster_agent']), - current_snapshot_id: z.string().nullish(), +export const zAgentDriveFileResponse = z.object({ + drive_key: z.string(), + file_id: z.string(), + mime_type: z.string().nullish(), + name: z.string(), + size: z.int().nullish(), }) /** - * ComposerSoulLockPayload + * AgentDriveFileCommitResponse */ -export const zComposerSoulLockPayload = z.object({ - locked: z.boolean().optional().default(true), - unlocked_from_version_id: z.string().nullish(), +export const zAgentDriveFileCommitResponse = z.object({ + config_version_id: z.string().nullish(), + file: zAgentDriveFileResponse, }) /** - * ComposerVariant + * AgentLogMetaResponse */ -export const zComposerVariant = z.enum(['agent_app', 'workflow']) - -/** - * ComposerCandidateCapabilities - */ -export const zComposerCandidateCapabilities = z.object({ - human_roster_available: z.boolean().optional().default(false), +export const zAgentLogMetaResponse = z.object({ + agent_mode: z.string(), + elapsed_time: z.number().nullish(), + executor: z.string(), + iterations: z.int(), + start_time: z.string(), + status: z.string(), + total_tokens: z.int(), }) /** - * ComposerKnowledgePlaceholderResponse + * SkillManifest + * + * Validated metadata extracted from a Skill package. */ -export const zComposerKnowledgePlaceholderResponse = z.object({ - id: z.string(), - placeholder_name: z.string(), -}) - -/** - * ComposerValidationWarningResponse - */ -export const zComposerValidationWarningResponse = z.object({ - code: z.string(), - id: z.string().nullish(), - kind: z.string().nullish(), - message: z.string().nullish(), - surface: z.string().nullish(), -}) - -/** - * AgentComposerValidateResponse - */ -export const zAgentComposerValidateResponse = z.object({ - errors: z.array(z.string()).optional(), - knowledge_retrieval_placeholder: z.array(zComposerKnowledgePlaceholderResponse).optional(), - result: z.string(), - warnings: z.array(zComposerValidationWarningResponse).optional(), -}) - -/** - * ComposerValidationFindingsResponse - */ -export const zComposerValidationFindingsResponse = z.object({ - knowledge_retrieval_placeholder: z.array(zComposerKnowledgePlaceholderResponse).optional(), - warnings: z.array(zComposerValidationWarningResponse).optional(), -}) - -/** - * AgentFeatureToggleConfig - */ -export const zAgentFeatureToggleConfig = z.object({ - enabled: z.boolean().optional().default(false), -}) - -/** - * AgentTextToSpeechFeatureConfig - */ -export const zAgentTextToSpeechFeatureConfig = z.object({ - autoPlay: z.string().nullish(), - enabled: z.boolean().optional().default(false), - language: z.string().nullish(), - voice: z.string().nullish(), -}) - -/** - * AgentReferencingWorkflowResponse - */ -export const zAgentReferencingWorkflowResponse = z.object({ - app_id: z.string(), - app_mode: z.string(), - app_name: z.string(), - node_ids: z.array(z.string()).optional(), - workflow_id: z.string(), -}) - -/** - * AgentReferencingWorkflowsResponse - */ -export const zAgentReferencingWorkflowsResponse = z.object({ - data: z.array(zAgentReferencingWorkflowResponse).optional(), -}) - -/** - * WorkspaceFileEntryResponse - */ -export const zWorkspaceFileEntryResponse = z.object({ - mtime: z.int(), +export const zSkillManifest = z.object({ + description: z.string(), + entry_path: z.string(), + files: z.array(z.string()), + hash: z.string(), name: z.string(), size: z.int(), - type: z.enum(['dir', 'file', 'symlink']), }) /** - * WorkspaceListResponse + * AgentSkillRefConfig */ -export const zWorkspaceListResponse = z.object({ - entries: z.array(zWorkspaceFileEntryResponse).optional(), - path: z.string(), - truncated: z.boolean().optional().default(false), +export const zAgentSkillRefConfig = z.object({ + description: z.string().nullish(), + file_id: z.string().max(255).nullish(), + full_archive_file_id: z.string().max(255).nullish(), + full_archive_key: z.string().max(512).nullish(), + id: z.string().max(255).nullish(), + manifest_files: z.array(z.string()).nullish(), + name: z.string().max(255).nullish(), + path: z.string().nullish(), + skill_md_file_id: z.string().max(255).nullish(), + skill_md_key: z.string().max(512).nullish(), +}) + +/** + * AgentSkillStandardizeResponse + */ +export const zAgentSkillStandardizeResponse = z.object({ + manifest: zSkillManifest, + skill: zAgentSkillRefConfig, +}) + +/** + * AgentSkillUploadResponse + */ +export const zAgentSkillUploadResponse = z.object({ + manifest: zSkillManifest, + skill: zAgentSkillRefConfig, +}) + +/** + * AnnotationEmbeddingModelResponse + */ +export const zAnnotationEmbeddingModelResponse = z.object({ + embedding_model_name: z.string().nullish(), + embedding_provider_name: z.string().nullish(), +}) + +/** + * AnnotationSettingResponse + */ +export const zAnnotationSettingResponse = z.object({ + embedding_model: zAnnotationEmbeddingModelResponse.nullish(), + enabled: z.boolean(), + id: z.string().nullish(), + score_threshold: z.number().nullish(), }) /** * AnnotationHitHistory */ export const zAnnotationHitHistory = z.object({ - annotation_content: z.string().nullish(), - annotation_question: z.string().nullish(), created_at: z.int().nullish(), id: z.string(), + match: z.string().nullish(), question: z.string().nullish(), + response: z.string().nullish(), score: z.number().nullish(), source: z.string().nullish(), }) @@ -989,12 +1128,134 @@ export const zAppMcpServerResponse = z.object({ description: z.string(), id: z.string(), name: z.string(), - parameters: z.unknown(), + parameters: z.union([z.record(z.string(), z.unknown()), z.array(z.unknown()), z.string()]), server_code: z.string(), status: zAppMcpServerStatus, updated_at: z.int().nullish(), }) +/** + * AverageResponseTimeStatisticItem + */ +export const zAverageResponseTimeStatisticItem = z.object({ + date: z.string(), + latency: z.number(), +}) + +/** + * AverageResponseTimeStatisticResponse + */ +export const zAverageResponseTimeStatisticResponse = z.object({ + data: z.array(zAverageResponseTimeStatisticItem), +}) + +/** + * AverageSessionInteractionStatisticItem + */ +export const zAverageSessionInteractionStatisticItem = z.object({ + date: z.string(), + interactions: z.number(), +}) + +/** + * AverageSessionInteractionStatisticResponse + */ +export const zAverageSessionInteractionStatisticResponse = z.object({ + data: z.array(zAverageSessionInteractionStatisticItem), +}) + +/** + * DailyConversationStatisticItem + */ +export const zDailyConversationStatisticItem = z.object({ + conversation_count: z.int(), + date: z.string(), +}) + +/** + * DailyConversationStatisticResponse + */ +export const zDailyConversationStatisticResponse = z.object({ + data: z.array(zDailyConversationStatisticItem), +}) + +/** + * DailyTerminalStatisticItem + */ +export const zDailyTerminalStatisticItem = z.object({ + date: z.string(), + terminal_count: z.int(), +}) + +/** + * DailyTerminalStatisticResponse + */ +export const zDailyTerminalStatisticResponse = z.object({ + data: z.array(zDailyTerminalStatisticItem), +}) + +/** + * DailyMessageStatisticItem + */ +export const zDailyMessageStatisticItem = z.object({ + date: z.string(), + message_count: z.int(), +}) + +/** + * DailyMessageStatisticResponse + */ +export const zDailyMessageStatisticResponse = z.object({ + data: z.array(zDailyMessageStatisticItem), +}) + +/** + * DailyTokenCostStatisticItem + */ +export const zDailyTokenCostStatisticItem = z.object({ + currency: z.string(), + date: z.string(), + token_count: z.int(), + total_price: z.union([z.string(), z.number()]), +}) + +/** + * DailyTokenCostStatisticResponse + */ +export const zDailyTokenCostStatisticResponse = z.object({ + data: z.array(zDailyTokenCostStatisticItem), +}) + +/** + * TokensPerSecondStatisticItem + */ +export const zTokensPerSecondStatisticItem = z.object({ + date: z.string(), + tps: z.number(), +}) + +/** + * TokensPerSecondStatisticResponse + */ +export const zTokensPerSecondStatisticResponse = z.object({ + data: z.array(zTokensPerSecondStatisticItem), +}) + +/** + * UserSatisfactionRateStatisticItem + */ +export const zUserSatisfactionRateStatisticItem = z.object({ + date: z.string(), + rate: z.number(), +}) + +/** + * UserSatisfactionRateStatisticResponse + */ +export const zUserSatisfactionRateStatisticResponse = z.object({ + data: z.array(zUserSatisfactionRateStatisticItem), +}) + /** * SimpleAccount */ @@ -1010,7 +1271,7 @@ export const zSimpleAccount = z.object({ export const zAdvancedChatWorkflowRunForListResponse = z.object({ conversation_id: z.string().nullish(), created_at: z.int().nullish(), - created_by_account: zSimpleAccount.optional(), + created_by_account: zSimpleAccount.nullish(), elapsed_time: z.number().nullish(), exceptions_count: z.int().nullish(), finished_at: z.int().nullish(), @@ -1036,7 +1297,7 @@ export const zAdvancedChatWorkflowRunPaginationResponse = z.object({ * ConversationAnnotation */ export const zConversationAnnotation = z.object({ - account: zSimpleAccount.optional(), + account: zSimpleAccount.nullish(), content: z.string(), created_at: z.int().nullish(), id: z.string(), @@ -1047,7 +1308,7 @@ export const zConversationAnnotation = z.object({ * ConversationAnnotationHitHistory */ export const zConversationAnnotationHitHistory = z.object({ - annotation_create_account: zSimpleAccount.optional(), + annotation_create_account: zSimpleAccount.nullish(), created_at: z.int().nullish(), id: z.string(), }) @@ -1057,7 +1318,7 @@ export const zConversationAnnotationHitHistory = z.object({ */ export const zFeedback = z.object({ content: z.string().nullish(), - from_account: zSimpleAccount.optional(), + from_account: zSimpleAccount.nullish(), from_end_user_id: z.string().nullish(), from_source: z.string(), rating: z.string(), @@ -1068,8 +1329,8 @@ export const zFeedback = z.object({ */ export const zMessageDetail = z.object({ agent_thoughts: z.array(zAgentThought), - annotation: zConversationAnnotation.optional(), - annotation_hit_history: zConversationAnnotationHitHistory.optional(), + annotation: zConversationAnnotation.nullish(), + annotation_hit_history: zConversationAnnotationHitHistory.nullish(), answer_tokens: z.int(), conversation_id: z.string(), created_at: z.int().nullish(), @@ -1097,7 +1358,7 @@ export const zMessageDetail = z.object({ */ export const zWorkflowRunForListResponse = z.object({ created_at: z.int().nullish(), - created_by_account: zSimpleAccount.optional(), + created_by_account: zSimpleAccount.nullish(), elapsed_time: z.number().nullish(), exceptions_count: z.int().nullish(), finished_at: z.int().nullish(), @@ -1133,8 +1394,8 @@ export const zSimpleEndUser = z.object({ */ export const zWorkflowRunDetailResponse = z.object({ created_at: z.int().nullish(), - created_by_account: zSimpleAccount.optional(), - created_by_end_user: zSimpleEndUser.optional(), + created_by_account: zSimpleAccount.nullish(), + created_by_end_user: zSimpleEndUser.nullish(), created_by_role: z.string().nullish(), elapsed_time: z.number().nullish(), error: z.string().nullish(), @@ -1155,8 +1416,8 @@ export const zWorkflowRunDetailResponse = z.object({ */ export const zWorkflowRunNodeExecutionResponse = z.object({ created_at: z.int().nullish(), - created_by_account: zSimpleAccount.optional(), - created_by_end_user: zSimpleEndUser.optional(), + created_by_account: zSimpleAccount.nullish(), + created_by_end_user: zSimpleEndUser.nullish(), created_by_role: z.string().nullish(), elapsed_time: z.number().nullish(), error: z.string().nullish(), @@ -1185,6 +1446,41 @@ export const zWorkflowRunNodeExecutionListResponse = z.object({ data: z.array(zWorkflowRunNodeExecutionResponse), }) +/** + * SandboxFileEntryResponse + */ +export const zSandboxFileEntryResponse = z.object({ + mtime: z.int().nullish(), + name: z.string(), + size: z.int().nullish(), + type: z.enum(['dir', 'file', 'other', 'symlink']), +}) + +/** + * SandboxListResponse + */ +export const zSandboxListResponse = z.object({ + entries: z.array(zSandboxFileEntryResponse).optional(), + path: z.string(), + truncated: z.boolean().optional().default(false), +}) + +/** + * SandboxToolFileResponse + */ +export const zSandboxToolFileResponse = z.object({ + reference: z.string(), + transfer_method: z.literal('tool_file').optional().default('tool_file'), +}) + +/** + * SandboxUploadResponse + */ +export const zSandboxUploadResponse = z.object({ + file: zSandboxToolFileResponse, + path: z.string(), +}) + /** * AccountWithRole */ @@ -1211,7 +1507,7 @@ export const zWorkflowCommentMentionUsersPayload = z.object({ * WorkflowCommentAccount */ export const zWorkflowCommentAccount = z.object({ - avatar_url: z.string().readonly().nullable(), + avatar_url: z.string().nullable(), email: z.string(), id: z.string(), name: z.string(), @@ -1224,7 +1520,7 @@ export const zWorkflowCommentBasic = z.object({ content: z.string(), created_at: z.int().nullish(), created_by: z.string(), - created_by_account: zWorkflowCommentAccount.optional(), + created_by_account: zWorkflowCommentAccount.nullish(), id: z.string(), mention_count: z.int(), participants: z.array(zWorkflowCommentAccount), @@ -1234,7 +1530,7 @@ export const zWorkflowCommentBasic = z.object({ resolved: z.boolean(), resolved_at: z.int().nullish(), resolved_by: z.string().nullish(), - resolved_by_account: zWorkflowCommentAccount.optional(), + resolved_by_account: zWorkflowCommentAccount.nullish(), updated_at: z.int().nullish(), }) @@ -1249,7 +1545,7 @@ export const zWorkflowCommentBasicList = z.object({ * WorkflowCommentMention */ export const zWorkflowCommentMention = z.object({ - mentioned_user_account: zWorkflowCommentAccount.optional(), + mentioned_user_account: zWorkflowCommentAccount.nullish(), mentioned_user_id: z.string(), reply_id: z.string().nullish(), }) @@ -1261,7 +1557,7 @@ export const zWorkflowCommentReply = z.object({ content: z.string(), created_at: z.int().nullish(), created_by: z.string(), - created_by_account: zWorkflowCommentAccount.optional(), + created_by_account: zWorkflowCommentAccount.nullish(), id: z.string(), }) @@ -1272,7 +1568,7 @@ export const zWorkflowCommentDetail = z.object({ content: z.string(), created_at: z.int().nullish(), created_by: z.string(), - created_by_account: zWorkflowCommentAccount.optional(), + created_by_account: zWorkflowCommentAccount.nullish(), id: z.string(), mentions: z.array(zWorkflowCommentMention), position_x: z.number(), @@ -1281,10 +1577,70 @@ export const zWorkflowCommentDetail = z.object({ resolved: z.boolean(), resolved_at: z.int().nullish(), resolved_by: z.string().nullish(), - resolved_by_account: zWorkflowCommentAccount.optional(), + resolved_by_account: zWorkflowCommentAccount.nullish(), updated_at: z.int().nullish(), }) +/** + * WorkflowAverageAppInteractionStatisticItem + */ +export const zWorkflowAverageAppInteractionStatisticItem = z.object({ + date: z.string(), + interactions: z.number(), +}) + +/** + * WorkflowAverageAppInteractionStatisticResponse + */ +export const zWorkflowAverageAppInteractionStatisticResponse = z.object({ + data: z.array(zWorkflowAverageAppInteractionStatisticItem), +}) + +/** + * WorkflowDailyRunsStatisticItem + */ +export const zWorkflowDailyRunsStatisticItem = z.object({ + date: z.string(), + runs: z.int(), +}) + +/** + * WorkflowDailyRunsStatisticResponse + */ +export const zWorkflowDailyRunsStatisticResponse = z.object({ + data: z.array(zWorkflowDailyRunsStatisticItem), +}) + +/** + * WorkflowDailyTerminalsStatisticItem + */ +export const zWorkflowDailyTerminalsStatisticItem = z.object({ + date: z.string(), + terminal_count: z.int(), +}) + +/** + * WorkflowDailyTerminalsStatisticResponse + */ +export const zWorkflowDailyTerminalsStatisticResponse = z.object({ + data: z.array(zWorkflowDailyTerminalsStatisticItem), +}) + +/** + * WorkflowDailyTokenCostStatisticItem + */ +export const zWorkflowDailyTokenCostStatisticItem = z.object({ + date: z.string(), + token_count: z.int(), +}) + +/** + * WorkflowDailyTokenCostStatisticResponse + */ +export const zWorkflowDailyTokenCostStatisticResponse = z.object({ + data: z.array(zWorkflowDailyTokenCostStatisticItem), +}) + /** * WorkflowConversationVariableResponse */ @@ -1292,7 +1648,7 @@ export const zWorkflowConversationVariableResponse = z.object({ description: z.string(), id: z.string(), name: z.string(), - value: z.record(z.string(), z.unknown()), + value: z.unknown(), value_type: z.string(), }) @@ -1303,7 +1659,7 @@ export const zWorkflowEnvironmentVariableResponse = z.object({ description: z.string(), id: z.string(), name: z.string(), - value: z.record(z.string(), z.unknown()), + value: z.unknown(), value_type: z.string(), }) @@ -1315,7 +1671,7 @@ export const zPipelineVariableResponse = z.object({ allowed_file_types: z.array(z.string()).nullish(), allowed_file_upload_methods: z.array(z.string()).nullish(), belong_to_node_id: z.string(), - default_value: z.record(z.string(), z.unknown()).optional(), + default_value: z.unknown().optional(), label: z.string(), max_length: z.int().nullish(), options: z.array(z.string()).nullish(), @@ -1333,7 +1689,7 @@ export const zPipelineVariableResponse = z.object({ export const zWorkflowResponse = z.object({ conversation_variables: z.array(zWorkflowConversationVariableResponse), created_at: z.int(), - created_by: zSimpleAccount.optional(), + created_by: zSimpleAccount.nullish(), environment_variables: z.array(zWorkflowEnvironmentVariableResponse), features: z.record(z.string(), z.unknown()), graph: z.record(z.string(), z.unknown()), @@ -1344,7 +1700,7 @@ export const zWorkflowResponse = z.object({ rag_pipeline_variables: z.array(zPipelineVariableResponse), tool_published: z.boolean(), updated_at: z.int(), - updated_by: zSimpleAccount.optional(), + updated_by: zSimpleAccount.nullish(), version: z.string(), }) @@ -1358,6 +1714,53 @@ export const zWorkflowPaginationResponse = z.object({ page: z.int(), }) +/** + * EnvironmentVariableItemResponse + */ +export const zEnvironmentVariableItemResponse = z.object({ + description: z.string().nullish(), + editable: z.boolean(), + edited: z.boolean(), + id: z.string(), + name: z.string(), + selector: z.array(z.string()), + type: z.string(), + value: z.unknown(), + value_type: z.string(), + visible: z.boolean(), +}) + +/** + * EnvironmentVariableListResponse + */ +export const zEnvironmentVariableListResponse = z.object({ + items: z.array(zEnvironmentVariableItemResponse), +}) + +/** + * AgentConfigSnapshotSummaryResponse + */ +export const zAgentConfigSnapshotSummaryResponse = z.object({ + agent_id: z.string().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + id: z.string(), + summary: z.string().nullish(), + version: z.int(), + version_note: z.string().nullish(), +}) + +/** + * ComposerSaveStrategy + */ +export const zComposerSaveStrategy = z.enum([ + 'node_job_only', + 'save_as_new_agent', + 'save_as_new_version', + 'save_to_current_version', + 'save_to_roster', +]) + /** * AgentComposerSoulLockResponse */ @@ -1367,6 +1770,35 @@ export const zAgentComposerSoulLockResponse = z.object({ reason: z.string().nullish(), }) +/** + * ComposerBindingPayload + */ +export const zComposerBindingPayload = z.object({ + agent_id: z.string().nullish(), + binding_type: z.enum(['inline_agent', 'roster_agent']), + current_snapshot_id: z.string().nullish(), +}) + +/** + * ComposerSoulLockPayload + */ +export const zComposerSoulLockPayload = z.object({ + locked: z.boolean().optional().default(true), + unlocked_from_version_id: z.string().nullish(), +}) + +/** + * ComposerVariant + */ +export const zComposerVariant = z.enum(['agent_app', 'workflow']) + +/** + * ComposerCandidateCapabilities + */ +export const zComposerCandidateCapabilities = z.object({ + human_roster_available: z.boolean().optional().default(false), +}) + /** * AgentComposerImpactBindingResponse */ @@ -1385,6 +1817,43 @@ export const zAgentComposerImpactResponse = z.object({ workflow_node_count: z.int(), }) +/** + * ComposerKnowledgePlaceholderResponse + */ +export const zComposerKnowledgePlaceholderResponse = z.object({ + id: z.string(), + placeholder_name: z.string(), +}) + +/** + * ComposerValidationWarningResponse + */ +export const zComposerValidationWarningResponse = z.object({ + code: z.string(), + id: z.string().nullish(), + kind: z.string().nullish(), + message: z.string().nullish(), + surface: z.string().nullish(), +}) + +/** + * AgentComposerValidateResponse + */ +export const zAgentComposerValidateResponse = z.object({ + errors: z.array(z.string()).optional(), + knowledge_retrieval_placeholder: z.array(zComposerKnowledgePlaceholderResponse).optional(), + result: z.literal('success'), + warnings: z.array(zComposerValidationWarningResponse).optional(), +}) + +/** + * ComposerValidationFindingsResponse + */ +export const zComposerValidationFindingsResponse = z.object({ + knowledge_retrieval_placeholder: z.array(zComposerKnowledgePlaceholderResponse).optional(), + warnings: z.array(zComposerValidationWarningResponse).optional(), +}) + /** * WorkflowExecutionStatus */ @@ -1439,7 +1908,7 @@ export const zOutputPreviewView = z.object({ node_id: z.string(), output_name: z.string(), status: zNodeOutputStatus, - type: zDeclaredOutputType.optional(), + type: zDeclaredOutputType.nullish(), value: z.unknown().optional(), }) @@ -1457,7 +1926,7 @@ export const zWorkflowDraftVariableWithoutValue = z.object({ export const zWorkflowDraftVariableListWithoutValue = z.object({ items: z.array(zWorkflowDraftVariableWithoutValue).optional(), - total: z.record(z.string(), z.unknown()).optional(), + total: z.int().optional(), }) /** @@ -1466,7 +1935,7 @@ export const zWorkflowDraftVariableListWithoutValue = z.object({ export const zModelConfigPartial = z.object({ created_at: z.int().nullish(), created_by: z.string().nullish(), - model_dict: zJsonValue.optional(), + model: zJsonValue.nullish(), pre_prompt: z.string().nullish(), updated_at: z.int().nullish(), updated_by: z.string().nullish(), @@ -1477,8 +1946,11 @@ export const zModelConfigPartial = z.object({ */ export const zAppPartial = z.object({ access_mode: z.string().nullish(), - app_model_config: zModelConfigPartial.optional(), + active_config_is_published: z.boolean().optional().default(false), + app_id: z.string().nullish(), + app_model_config: zModelConfigPartial.nullish(), author_name: z.string().nullish(), + bound_agent_id: z.string().nullish(), create_user_name: z.string().nullish(), created_at: z.int().nullish(), created_by: z.string().nullish(), @@ -1488,14 +1960,16 @@ export const zAppPartial = z.object({ icon_background: z.string().nullish(), icon_type: z.string().nullish(), id: z.string(), + is_starred: z.boolean().optional().default(false), max_active_requests: z.int().nullish(), mode_compatible_with_agent: z.string(), name: z.string(), + role: z.string().nullish(), tags: z.array(zTag).optional(), updated_at: z.int().nullish(), updated_by: z.string().nullish(), use_icon_as_answer_icon: z.boolean().nullish(), - workflow: zWorkflowPartial.optional(), + workflow: zWorkflowPartial.nullish(), }) /** @@ -1526,37 +2000,14 @@ export const zModelConfig = z.object({ provider: z.string(), }) -/** - * AppDetail - */ -export const zAppDetail = z.object({ - access_mode: z.string().nullish(), - app_model_config: zModelConfig.optional(), - created_at: z.int().nullish(), - created_by: z.string().nullish(), - description: z.string().nullish(), - enable_api: z.boolean(), - enable_site: z.boolean(), - icon: z.string().nullish(), - icon_background: z.string().nullish(), - id: z.string(), - mode_compatible_with_agent: z.string(), - name: z.string(), - tags: z.array(zTag).optional(), - tracing: zJsonValue.optional(), - updated_at: z.int().nullish(), - updated_by: z.string().nullish(), - use_icon_as_answer_icon: z.boolean().nullish(), - workflow: zWorkflowPartial.optional(), -}) - /** * AppDetailWithSite */ export const zAppDetailWithSite = z.object({ access_mode: z.string().nullish(), + active_config_is_published: z.boolean().optional().default(false), api_base_url: z.string().nullish(), - app_model_config: zModelConfig.optional(), + app_id: z.string().nullish(), bound_agent_id: z.string().nullish(), created_at: z.int().nullish(), created_by: z.string().nullish(), @@ -1567,24 +2018,51 @@ export const zAppDetailWithSite = z.object({ icon: z.string().nullish(), icon_background: z.string().nullish(), icon_type: z.string().nullish(), + icon_url: z.string().nullable(), id: z.string(), max_active_requests: z.int().nullish(), - mode_compatible_with_agent: z.string(), + mode: z.string(), + model_config: zModelConfig.nullish(), name: z.string(), - site: zSite.optional(), + role: z.string().nullish(), + site: zSite.nullish(), tags: z.array(zTag).optional(), - tracing: zJsonValue.optional(), + tracing: zJsonValue.nullish(), updated_at: z.int().nullish(), updated_by: z.string().nullish(), use_icon_as_answer_icon: z.boolean().nullish(), - workflow: zWorkflowPartial.optional(), + workflow: zWorkflowPartial.nullish(), +}) + +/** + * AppDetail + */ +export const zAppDetail = z.object({ + access_mode: z.string().nullish(), + app_model_config: zModelConfig.nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + description: z.string().nullish(), + enable_api: z.boolean(), + enable_site: z.boolean(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + id: z.string(), + mode_compatible_with_agent: z.string(), + name: z.string(), + 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(), }) /** * ConversationDetail */ export const zConversationDetail = z.object({ - admin_feedback_stats: zFeedbackStat.optional(), + admin_feedback_stats: zFeedbackStat.nullish(), annotated: z.boolean(), created_at: z.int().nullish(), from_account_id: z.string().nullish(), @@ -1593,10 +2071,10 @@ export const zConversationDetail = z.object({ id: z.string(), introduction: z.string().nullish(), message_count: z.int(), - model_config: zModelConfig.optional(), + model_config: zModelConfig.nullish(), status: z.string(), updated_at: z.int().nullish(), - user_feedback_stats: zFeedbackStat.optional(), + user_feedback_stats: zFeedbackStat.nullish(), }) /** @@ -1604,12 +2082,12 @@ export const zConversationDetail = z.object({ */ export const zConversationMessageDetail = z.object({ created_at: z.int().nullish(), - first_message: zMessageDetail.optional(), + first_message: zMessageDetail.nullish(), from_account_id: z.string().nullish(), from_end_user_id: z.string().nullish(), from_source: z.string(), id: z.string(), - model_config: zModelConfig.optional(), + model_config: zModelConfig.nullish(), status: z.string(), }) @@ -1618,22 +2096,6 @@ export const zConversationMessageDetail = z.object({ */ export const zType = z.enum(['github', 'marketplace', 'package']) -/** - * PluginDependency - */ -export const zPluginDependency = z.object({ - current_identifier: z.string().nullish(), - type: zType, - value: z.unknown(), -}) - -/** - * CheckDependenciesResult - */ -export const zCheckDependenciesResult = z.object({ - leaked_dependencies: z.array(zPluginDependency).optional(), -}) - /** * Github */ @@ -1660,6 +2122,22 @@ export const zPackage = z.object({ version: z.string().nullish(), }) +/** + * PluginDependency + */ +export const zPluginDependency = z.object({ + current_identifier: z.string().nullish(), + type: zType, + value: z.union([zGithub, zMarketplace, zPackage]), +}) + +/** + * CheckDependenciesResult + */ +export const zCheckDependenciesResult = z.object({ + leaked_dependencies: z.array(zPluginDependency).optional(), +}) + /** * WorkflowOnlineUser */ @@ -1684,6 +2162,248 @@ export const zWorkflowOnlineUsersResponse = z.object({ data: z.array(zWorkflowOnlineUsersByApp), }) +/** + * AgentToolCallResponse + */ +export const zAgentToolCallResponse = z.object({ + error: z.string().nullish(), + status: z.string(), + time_cost: z.union([z.number(), z.int()]), + tool_icon: z.unknown().optional(), + tool_input: z.record(z.string(), z.unknown()), + tool_label: z.string(), + tool_name: z.string(), + tool_output: z.record(z.string(), z.unknown()), + tool_parameters: z.record(z.string(), z.unknown()), +}) + +/** + * AgentIterationLogResponse + */ +export const zAgentIterationLogResponse = z.object({ + created_at: z.string(), + files: z.array(z.unknown()).optional(), + thought: z.string().nullish(), + tokens: z.int(), + tool_calls: z.array(zAgentToolCallResponse), + tool_raw: z.record(z.string(), z.unknown()), +}) + +/** + * AgentLogResponse + */ +export const zAgentLogResponse = z.object({ + files: z.array(z.unknown()).optional(), + iterations: z.array(zAgentIterationLogResponse), + meta: zAgentLogMetaResponse, +}) + +/** + * EnvSuggestion + */ +export const zEnvSuggestion = z.object({ + key: z.string(), + reason: z.string().optional().default(''), + secret_likely: z.boolean().optional().default(false), +}) + +/** + * CliToolSuggestion + */ +export const zCliToolSuggestion = z.object({ + command: z.string().optional().default(''), + description: z.string().optional().default(''), + env_suggestions: z.array(zEnvSuggestion).optional(), + inferred_from: z.string().optional().default(''), + install_commands: z.array(z.string()).optional(), + name: z.string(), +}) + +/** + * SkillToolInferenceResult + */ +export const zSkillToolInferenceResult = z.object({ + cli_tools: z.array(zCliToolSuggestion).optional(), + inferable: z.boolean(), + reason: z.string().nullish(), +}) + +/** + * SimpleModelConfig + */ +export const zSimpleModelConfig = z.object({ + model_dict: zJsonValue.nullish(), + pre_prompt: z.string().nullish(), +}) + +/** + * StatusCount + */ +export const zStatusCount = z.object({ + failed: z.int(), + partial_success: z.int(), + paused: z.int(), + success: z.int(), +}) + +/** + * ConversationWithSummary + */ +export const zConversationWithSummary = z.object({ + admin_feedback_stats: zFeedbackStat.nullish(), + annotated: z.boolean(), + created_at: z.int().nullish(), + from_account_id: z.string().nullish(), + from_account_name: z.string().nullish(), + from_end_user_id: z.string().nullish(), + from_end_user_session_id: z.string().nullish(), + from_source: z.string(), + id: z.string(), + message_count: z.int(), + model_config: zSimpleModelConfig.nullish(), + name: z.string(), + read_at: z.int().nullish(), + status: z.string(), + status_count: zStatusCount.nullish(), + summary_or_query: z.string(), + updated_at: z.int().nullish(), + user_feedback_stats: zFeedbackStat.nullish(), +}) + +/** + * ConversationWithSummaryPagination + */ +export const zConversationWithSummaryPagination = z.object({ + has_next: z.boolean(), + items: z.array(zConversationWithSummary), + page: z.int(), + per_page: z.int(), + total: z.int(), +}) + +/** + * SimpleMessageDetail + */ +export const zSimpleMessageDetail = z.object({ + answer: z.string(), + inputs: z.record(z.string(), zJsonValue), + message: z.string(), + query: z.string(), +}) + +/** + * Conversation + */ +export const zConversation = z.object({ + admin_feedback_stats: zFeedbackStat.nullish(), + annotation: zConversationAnnotation.nullish(), + created_at: z.int().nullish(), + first_message: zSimpleMessageDetail.nullish(), + from_account_id: z.string().nullish(), + from_account_name: z.string().nullish(), + from_end_user_id: z.string().nullish(), + from_end_user_session_id: z.string().nullish(), + from_source: z.string(), + id: z.string(), + model_config: zSimpleModelConfig.nullish(), + read_at: z.int().nullish(), + status: z.string(), + updated_at: z.int().nullish(), + user_feedback_stats: zFeedbackStat.nullish(), +}) + +/** + * ConversationPagination + */ +export const zConversationPagination = z.object({ + has_next: z.boolean(), + items: z.array(zConversation), + page: z.int(), + per_page: z.int(), + total: z.int(), +}) + +/** + * ExecutionContentType + */ +export const zExecutionContentType = z.enum(['human_input']) + +/** + * WorkflowRunForLogResponse + */ +export const zWorkflowRunForLogResponse = z.object({ + created_at: z.int().nullish(), + elapsed_time: z.number().nullish(), + error: z.string().nullish(), + exceptions_count: z.int().nullish(), + finished_at: z.int().nullish(), + id: z.string(), + status: z.string().nullish(), + total_steps: z.int().nullish(), + total_tokens: z.int().nullish(), + triggered_from: z.string().nullish(), + version: z.string().nullish(), +}) + +/** + * WorkflowAppLogPartialResponse + */ +export const zWorkflowAppLogPartialResponse = z.object({ + created_at: z.int().nullish(), + created_by_account: zSimpleAccount.nullish(), + created_by_end_user: zSimpleEndUser.nullish(), + created_by_role: z.string().nullish(), + created_from: z.string().nullish(), + details: z.unknown().optional(), + id: z.string(), + workflow_run: zWorkflowRunForLogResponse.nullish(), +}) + +/** + * WorkflowAppLogPaginationResponse + */ +export const zWorkflowAppLogPaginationResponse = z.object({ + data: z.array(zWorkflowAppLogPartialResponse), + has_more: z.boolean(), + limit: z.int(), + page: z.int(), + total: z.int(), +}) + +/** + * WorkflowRunForArchivedLogResponse + */ +export const zWorkflowRunForArchivedLogResponse = z.object({ + elapsed_time: z.number().nullish(), + id: z.string(), + status: z.string().nullish(), + total_tokens: z.int().nullish(), + triggered_from: z.string().nullish(), +}) + +/** + * WorkflowArchivedLogPartialResponse + */ +export const zWorkflowArchivedLogPartialResponse = z.object({ + created_at: z.int().nullish(), + created_by_account: zSimpleAccount.nullish(), + created_by_end_user: zSimpleEndUser.nullish(), + id: z.string(), + trigger_metadata: z.unknown().optional(), + workflow_run: zWorkflowRunForArchivedLogResponse.nullish(), +}) + +/** + * WorkflowArchivedLogPaginationResponse + */ +export const zWorkflowArchivedLogPaginationResponse = z.object({ + data: z.array(zWorkflowArchivedLogPartialResponse), + has_more: z.boolean(), + limit: z.int(), + page: z.int(), + total: z.int(), +}) + /** * AgentScope * @@ -1727,275 +2447,6 @@ export const zAgentSoulPromptConfig = z.object({ system_prompt: z.string().optional().default(''), }) -/** - * AgentHumanContactConfig - */ -export const zAgentHumanContactConfig = z.object({ - channel: z.string().max(64).nullish(), - contact_id: z.string().max(255).nullish(), - contact_method: z.string().max(64).nullish(), - email: z.string().max(255).nullish(), - human_id: z.string().max(255).nullish(), - id: z.string().max(255).nullish(), - method: z.string().max(64).nullish(), - name: z.string().max(255).nullish(), - tenant_id: z.string().max(255).nullish(), -}) - -/** - * WorkflowNodeJobMode - */ -export const zWorkflowNodeJobMode = z.enum(['let_agent_figure_it_out', 'tell_agent_what_to_do']) - -/** - * WorkflowPreviousNodeOutputRef - */ -export const zWorkflowPreviousNodeOutputRef = z.object({ - key: z.string().max(255).nullish(), - name: z.string().max(255).nullish(), - node_id: z.string().max(255).nullish(), - output: z.string().max(255).nullish(), - selector: z.array(z.unknown()).nullish(), - value_selector: z.array(z.unknown()).nullish(), - variable: z.string().max(255).nullish(), - variable_selector: z.array(z.unknown()).nullish(), -}) - -/** - * AgentComposerNodeJobCandidatesResponse - */ -export const zAgentComposerNodeJobCandidatesResponse = z.object({ - declare_output_types: z.array(zDeclaredOutputType).optional(), - human_contacts: z.array(zAgentHumanContactConfig).optional(), - previous_node_outputs: z.array(zWorkflowPreviousNodeOutputRef).optional(), -}) - -/** - * AgentComposerDifyToolCandidateResponse - */ -export const zAgentComposerDifyToolCandidateResponse = z.object({ - description: z.string().nullish(), - id: z.string().nullish(), - name: z.string().nullish(), - plugin_id: z.string().nullish(), - provider: z.string().nullish(), - provider_id: z.string().nullish(), -}) - -/** - * AgentKnowledgeDatasetConfig - */ -export const zAgentKnowledgeDatasetConfig = z.object({ - description: z.string().nullish(), - id: z.string().max(255).nullish(), - name: z.string().max(255).nullish(), -}) - -/** - * AgentComposerSkillCandidateResponse - */ -export const zAgentComposerSkillCandidateResponse = z.object({ - description: z.string().nullish(), - file_id: z.string().max(255).nullish(), - id: z.string().max(255).nullish(), - kind: z.string().optional().default('skill'), - name: z.string().max(255).nullish(), - path: z.string().nullish(), -}) - -/** - * AgentComposerFileCandidateResponse - */ -export const zAgentComposerFileCandidateResponse = z.object({ - file_id: z.string().max(255).nullish(), - id: z.string().max(255).nullish(), - kind: z.string().optional().default('file'), - name: z.string().max(255).nullish(), - reference: z.string().max(255).nullish(), - remote_url: z.string().nullish(), - tenant_id: z.string().max(255).nullish(), - transfer_method: z.string().max(64).nullish(), - type: z.string().max(64).nullish(), - upload_file_id: z.string().max(255).nullish(), - url: z.string().nullish(), -}) - -/** - * SimpleModelConfig - */ -export const zSimpleModelConfig = z.object({ - model_dict: zJsonValue.optional(), - pre_prompt: z.string().nullish(), -}) - -/** - * StatusCount - */ -export const zStatusCount = z.object({ - failed: z.int(), - partial_success: z.int(), - paused: z.int(), - success: z.int(), -}) - -/** - * ConversationWithSummary - */ -export const zConversationWithSummary = z.object({ - admin_feedback_stats: zFeedbackStat.optional(), - annotated: z.boolean(), - created_at: z.int().nullish(), - from_account_id: z.string().nullish(), - from_account_name: z.string().nullish(), - from_end_user_id: z.string().nullish(), - from_end_user_session_id: z.string().nullish(), - from_source: z.string(), - id: z.string(), - message_count: z.int(), - model_config: zSimpleModelConfig.optional(), - name: z.string(), - read_at: z.int().nullish(), - status: z.string(), - status_count: zStatusCount.optional(), - summary_or_query: z.string(), - updated_at: z.int().nullish(), - user_feedback_stats: zFeedbackStat.optional(), -}) - -/** - * ConversationWithSummaryPagination - */ -export const zConversationWithSummaryPagination = z.object({ - has_next: z.boolean(), - items: z.array(zConversationWithSummary), - page: z.int(), - per_page: z.int(), - total: z.int(), -}) - -/** - * SimpleMessageDetail - */ -export const zSimpleMessageDetail = z.object({ - answer: z.string(), - inputs: z.record(z.string(), zJsonValue), - message: z.string(), - query: z.string(), -}) - -/** - * Conversation - */ -export const zConversation = z.object({ - admin_feedback_stats: zFeedbackStat.optional(), - annotation: zConversationAnnotation.optional(), - created_at: z.int().nullish(), - first_message: zSimpleMessageDetail.optional(), - from_account_id: z.string().nullish(), - from_account_name: z.string().nullish(), - from_end_user_id: z.string().nullish(), - from_end_user_session_id: z.string().nullish(), - from_source: z.string(), - id: z.string(), - model_config: zSimpleModelConfig.optional(), - read_at: z.int().nullish(), - status: z.string(), - updated_at: z.int().nullish(), - user_feedback_stats: zFeedbackStat.optional(), -}) - -/** - * ConversationPagination - */ -export const zConversationPagination = z.object({ - has_next: z.boolean(), - items: z.array(zConversation), - page: z.int(), - per_page: z.int(), - total: z.int(), -}) - -/** - * ExecutionContentType - */ -export const zExecutionContentType = z.enum(['human_input']) - -/** - * WorkflowRunForLogResponse - */ -export const zWorkflowRunForLogResponse = z.object({ - created_at: z.int().nullish(), - elapsed_time: z.number().nullish(), - error: z.string().nullish(), - exceptions_count: z.int().nullish(), - finished_at: z.int().nullish(), - id: z.string(), - status: z.string().nullish(), - total_steps: z.int().nullish(), - total_tokens: z.int().nullish(), - triggered_from: z.string().nullish(), - version: z.string().nullish(), -}) - -/** - * WorkflowAppLogPartialResponse - */ -export const zWorkflowAppLogPartialResponse = z.object({ - created_at: z.int().nullish(), - created_by_account: zSimpleAccount.optional(), - created_by_end_user: zSimpleEndUser.optional(), - created_by_role: z.string().nullish(), - created_from: z.string().nullish(), - details: z.unknown().optional(), - id: z.string(), - workflow_run: zWorkflowRunForLogResponse.optional(), -}) - -/** - * WorkflowAppLogPaginationResponse - */ -export const zWorkflowAppLogPaginationResponse = z.object({ - data: z.array(zWorkflowAppLogPartialResponse), - has_more: z.boolean(), - limit: z.int(), - page: z.int(), - total: z.int(), -}) - -/** - * WorkflowRunForArchivedLogResponse - */ -export const zWorkflowRunForArchivedLogResponse = z.object({ - elapsed_time: z.number().nullish(), - id: z.string(), - status: z.string().nullish(), - total_tokens: z.int().nullish(), - triggered_from: z.string().nullish(), -}) - -/** - * WorkflowArchivedLogPartialResponse - */ -export const zWorkflowArchivedLogPartialResponse = z.object({ - created_at: z.int().nullish(), - created_by_account: zSimpleAccount.optional(), - created_by_end_user: zSimpleEndUser.optional(), - id: z.string(), - trigger_metadata: z.unknown().optional(), - workflow_run: zWorkflowRunForArchivedLogResponse.optional(), -}) - -/** - * WorkflowArchivedLogPaginationResponse - */ -export const zWorkflowArchivedLogPaginationResponse = z.object({ - data: z.array(zWorkflowArchivedLogPartialResponse), - has_more: z.boolean(), - limit: z.int(), - page: z.int(), - total: z.int(), -}) - /** * WorkflowAgentBindingType * @@ -2025,6 +2476,25 @@ export const zAgentComposerBindingResponse = z.object({ * about. Stage 4 §4.2. */ export const zDeclaredArrayItem = z.object({ + children: z + .array( + z.object({ + array_item: z + .object({ + children: z.array(z.record(z.string(), z.unknown())).optional(), + description: z.string().nullish(), + type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(), + }) + .optional(), + children: z.array(z.record(z.string(), z.unknown())).optional(), + description: z.string().nullish(), + file: z.record(z.string(), z.unknown()).optional(), + name: z.string(), + required: z.boolean().optional(), + type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']), + }), + ) + .optional(), description: z.string().nullish(), type: zDeclaredOutputType, }) @@ -2039,6 +2509,111 @@ export const zDeclaredOutputFileConfig = z.object({ mime_types: z.array(z.string()).optional(), }) +/** + * AgentHumanContactConfig + */ +export const zAgentHumanContactConfig = z.object({ + channel: z.string().max(64).nullish(), + contact_id: z.string().max(255).nullish(), + contact_method: z.string().max(64).nullish(), + email: z.string().max(255).nullish(), + human_id: z.string().max(255).nullish(), + id: z.string().max(255).nullish(), + method: z.string().max(64).nullish(), + name: z.string().max(255).nullish(), + tenant_id: z.string().max(255).nullish(), +}) + +/** + * WorkflowNodeJobMode + */ +export const zWorkflowNodeJobMode = z.enum(['let_agent_figure_it_out', 'tell_agent_what_to_do']) + +/** + * WorkflowPreviousNodeOutputRef + */ +export const zWorkflowPreviousNodeOutputRef = z.object({ + key: z.string().max(255).nullish(), + name: z.string().max(255).nullish(), + node_id: z.string().max(255).nullish(), + output: z.string().max(255).nullish(), + selector: z.array(z.union([z.string(), z.int(), z.number(), z.boolean(), z.null()])).nullish(), + value_selector: z + .array(z.union([z.string(), z.int(), z.number(), z.boolean(), z.null()])) + .nullish(), + variable: z.string().max(255).nullish(), + variable_selector: z + .array(z.union([z.string(), z.int(), z.number(), z.boolean(), z.null()])) + .nullish(), +}) + +/** + * AgentComposerNodeJobCandidatesResponse + */ +export const zAgentComposerNodeJobCandidatesResponse = z.object({ + declare_output_types: z.array(zDeclaredOutputType).optional(), + human_contacts: z.array(zAgentHumanContactConfig).optional(), + previous_node_outputs: z.array(zWorkflowPreviousNodeOutputRef).optional(), +}) + +/** + * AgentComposerDifyToolCandidateResponse + */ +export const zAgentComposerDifyToolCandidateResponse = z.object({ + description: z.string().nullish(), + granularity: z.string().nullish(), + id: z.string().nullish(), + name: z.string().nullish(), + plugin_id: z.string().nullish(), + provider: z.string().nullish(), + provider_id: z.string().nullish(), + tools_count: z.int().nullish(), +}) + +/** + * AgentKnowledgeDatasetConfig + */ +export const zAgentKnowledgeDatasetConfig = z.object({ + description: z.string().nullish(), + id: z.string().max(255).nullish(), + name: z.string().max(255).nullish(), +}) + +/** + * AgentComposerSkillCandidateResponse + */ +export const zAgentComposerSkillCandidateResponse = z.object({ + description: z.string().nullish(), + file_id: z.string().max(255).nullish(), + full_archive_file_id: z.string().max(255).nullish(), + full_archive_key: z.string().max(512).nullish(), + id: z.string().max(255).nullish(), + kind: z.literal('skill').optional().default('skill'), + manifest_files: z.array(z.string()).nullish(), + name: z.string().max(255).nullish(), + path: z.string().nullish(), + skill_md_file_id: z.string().max(255).nullish(), + skill_md_key: z.string().max(512).nullish(), +}) + +/** + * AgentComposerFileCandidateResponse + */ +export const zAgentComposerFileCandidateResponse = z.object({ + drive_key: z.string().max(512).nullish(), + file_id: z.string().max(255).nullish(), + id: z.string().max(255).nullish(), + kind: z.literal('file').optional().default('file'), + name: z.string().max(255).nullish(), + reference: z.string().max(255).nullish(), + remote_url: z.string().nullish(), + tenant_id: z.string().max(255).nullish(), + transfer_method: z.string().max(64).nullish(), + type: z.string().max(64).nullish(), + upload_file_id: z.string().max(255).nullish(), + url: z.string().nullish(), +}) + /** * CheckResultView * @@ -2054,11 +2629,11 @@ export const zCheckResultView = z.object({ */ export const zNodeOutputView = z.object({ name: z.string(), - output_check: zCheckResultView.optional(), + output_check: zCheckResultView.nullish(), retried: z.int().optional().default(0), status: zNodeOutputStatus, - type: zDeclaredOutputType.optional(), - type_check: zCheckResultView.optional(), + type: zDeclaredOutputType.nullish(), + type_check: zCheckResultView.nullish(), value_preview: z.unknown().optional(), }) @@ -2084,17 +2659,70 @@ export const zWorkflowRunSnapshotView = z.object({ workflow_run_status: zWorkflowExecutionStatus, }) +export const zJsonValue2 = z.unknown() + +/** + * HumanInputFormSubmissionData + */ +export const zHumanInputFormSubmissionData = z.object({ + action_id: z.string(), + action_text: z.string(), + node_id: z.string(), + node_title: z.string(), + rendered_content: z.string(), + submitted_data: z.record(z.string(), zJsonValue2).nullish(), +}) + +/** + * AgentFeatureToggleConfig + */ +export const zAgentFeatureToggleConfig = z.object({ + enabled: z.boolean().optional().default(false), +}) + +/** + * AgentTextToSpeechFeatureConfig + */ +export const zAgentTextToSpeechFeatureConfig = z.object({ + autoPlay: z.string().nullish(), + enabled: z.boolean().optional().default(false), + language: z.string().nullish(), + voice: z.string().nullish(), +}) + /** * AgentEnvVariableConfig */ export const zAgentEnvVariableConfig = z.object({ - default: z.unknown().optional(), + default: z + .union([ + z.string(), + z.int(), + z.number(), + z.boolean(), + z.array(z.string()), + z.array(z.int()), + z.array(z.number()), + z.array(z.boolean()), + ]) + .nullish(), env_name: z.string().max(255).nullish(), key: z.string().max(255).nullish(), name: z.string().max(255).nullish(), required: z.boolean().optional().default(false), type: z.string().max(64).nullish(), - value: z.unknown().optional(), + value: z + .union([ + z.string(), + z.int(), + z.number(), + z.boolean(), + z.array(z.string()), + z.array(z.int()), + z.array(z.number()), + z.array(z.boolean()), + ]) + .nullish(), variable: z.string().max(255).nullish(), }) @@ -2136,7 +2764,7 @@ export const zAgentKnowledgeQueryMode = z.enum(['generated_query', 'user_query'] export const zAgentSoulKnowledgeConfig = z.object({ datasets: z.array(zAgentKnowledgeDatasetConfig).optional(), query_config: zAgentKnowledgeQueryConfig.optional(), - query_mode: zAgentKnowledgeQueryMode.optional(), + query_mode: zAgentKnowledgeQueryMode.nullish(), }) /** @@ -2191,6 +2819,7 @@ export const zAgentSoulSandboxConfig = z.object({ * AgentFileRefConfig */ export const zAgentFileRefConfig = z.object({ + drive_key: z.string().max(512).nullish(), file_id: z.string().max(255).nullish(), id: z.string().max(255).nullish(), name: z.string().max(255).nullish(), @@ -2203,6 +2832,14 @@ export const zAgentFileRefConfig = z.object({ url: z.string().nullish(), }) +/** + * AgentSoulSkillsFilesConfig + */ +export const zAgentSoulSkillsFilesConfig = z.object({ + files: z.array(zAgentFileRefConfig).optional(), + skills: z.array(zAgentSkillRefConfig).optional(), +}) + /** * WorkflowNodeJobMetadata */ @@ -2212,22 +2849,39 @@ export const zWorkflowNodeJobMetadata = z.object({ }) /** - * AgentSkillRefConfig + * OutputErrorStrategy + * + * Per-output failure handling strategy. + * + * Mirrors ``graphon.ErrorStrategy`` but scoped to a single declared output of + * a Workflow Agent Node. The runtime applies the strategy after type check or + * output check fails and any configured retry attempts have been exhausted. */ -export const zAgentSkillRefConfig = z.object({ - description: z.string().nullish(), - file_id: z.string().max(255).nullish(), - id: z.string().max(255).nullish(), - name: z.string().max(255).nullish(), - path: z.string().nullish(), +export const zOutputErrorStrategy = z.enum(['default_value', 'fail_branch', 'stop']) + +/** + * DeclaredOutputRetryConfig + * + * Per-output retry configuration that mirrors ``graphon.RetryConfig`` shape. + */ +export const zDeclaredOutputRetryConfig = z.object({ + enabled: z.boolean().optional().default(false), + max_retries: z.int().gte(0).lte(10).optional().default(0), + retry_interval_ms: z.int().gte(0).lte(60000).optional().default(0), }) /** - * AgentSoulSkillsFilesConfig + * DeclaredOutputFailureStrategy + * + * Per-output failure handling. + * + * A single strategy applies to both ``type_check`` and ``output_check`` failures + * (PRD does not distinguish them at the UX level). Stage 4 §4.4. */ -export const zAgentSoulSkillsFilesConfig = z.object({ - files: z.array(zAgentFileRefConfig).optional(), - skills: z.array(zAgentSkillRefConfig).optional(), +export const zDeclaredOutputFailureStrategy = z.object({ + default_value: z.unknown().optional(), + on_failure: zOutputErrorStrategy.optional().default('stop'), + retry: zDeclaredOutputRetryConfig.optional(), }) /** @@ -2268,12 +2922,13 @@ export const zAgentSecretRefConfig = z.object({ id: z.string().max(255).nullish(), key: z.string().max(255).nullish(), name: z.string().max(255).nullish(), - permission: zAgentPermissionConfig.optional(), + permission: zAgentPermissionConfig.nullish(), permission_status: z.string().max(64).nullish(), provider: z.string().max(255).nullish(), provider_credential_id: z.string().max(255).nullish(), ref: z.string().max(255).nullish(), type: z.string().max(64).nullish(), + value: z.string().max(255).nullish(), variable: z.string().max(255).nullish(), }) @@ -2285,6 +2940,14 @@ export const zAgentSoulEnvConfig = z.object({ variables: z.array(zAgentEnvVariableConfig).optional(), }) +/** + * AgentCliToolEnvConfig + */ +export const zAgentCliToolEnvConfig = z.object({ + secret_refs: z.array(zAgentSecretRefConfig).optional(), + variables: z.array(zAgentEnvVariableConfig).optional(), +}) + /** * AgentCliToolRiskLevel * @@ -2297,7 +2960,7 @@ export const zAgentCliToolRiskLevel = z.enum(['dangerous', 'safe', 'unknown']) */ export const zAgentCliToolConfig = z.object({ approved: z.boolean().optional().default(false), - authorization_status: zAgentCliToolAuthorizationStatus.optional(), + authorization_status: zAgentCliToolAuthorizationStatus.nullish(), command: z.string().nullish(), dangerous: z.boolean().optional().default(false), dangerous_accepted: z.boolean().optional().default(false), @@ -2305,17 +2968,20 @@ export const zAgentCliToolConfig = z.object({ dangerous_command: z.boolean().optional().default(false), description: z.string().nullish(), enabled: z.boolean().optional().default(true), + env: zAgentCliToolEnvConfig.optional(), + id: z.string().max(255).nullish(), + inferred_from: z.string().max(255).nullish(), install: z.string().nullish(), install_command: z.string().nullish(), install_commands: z.array(z.string()).optional(), invoke_metadata: z.record(z.string(), z.unknown()).optional(), label: z.string().max(255).nullish(), name: z.string().max(255).nullish(), - permission: zAgentPermissionConfig.optional(), + permission: zAgentPermissionConfig.nullish(), pre_authorized: z.boolean().nullish(), requires_confirmation: z.boolean().optional().default(false), risk_accepted: z.boolean().optional().default(false), - risk_level: zAgentCliToolRiskLevel.optional(), + risk_level: zAgentCliToolRiskLevel.nullish(), setup_command: z.string().nullish(), tool_name: z.string().max(255).nullish(), }) @@ -2328,7 +2994,22 @@ export const zAgentComposerSoulCandidatesResponse = z.object({ dify_tools: z.array(zAgentComposerDifyToolCandidateResponse).optional(), human_contacts: z.array(zAgentHumanContactConfig).optional(), knowledge_datasets: z.array(zAgentKnowledgeDatasetConfig).optional(), - skills_files: z.array(z.unknown()).optional(), + skills_files: z + .array( + z.union([ + z + .object({ + kind: z.literal('skill'), + }) + .and(zAgentComposerSkillCandidateResponse), + z + .object({ + kind: z.literal('file'), + }) + .and(zAgentComposerFileCandidateResponse), + ]), + ) + .optional(), }) /** @@ -2343,82 +3024,21 @@ export const zAgentComposerCandidatesResponse = z.object({ }) /** - * AgentModerationIOConfig - */ -export const zAgentModerationIoConfig = z.object({ - enabled: z.boolean().optional().default(false), - preset_response: z.string().nullish(), -}) - -/** - * AgentModerationProviderConfig - */ -export const zAgentModerationProviderConfig = z.object({ - api_based_extension_id: z.string().nullish(), - inputs_config: zAgentModerationIoConfig.optional(), - keywords: z.string().nullish(), - outputs_config: zAgentModerationIoConfig.optional(), -}) - -/** - * AgentSensitiveWordAvoidanceFeatureConfig - */ -export const zAgentSensitiveWordAvoidanceFeatureConfig = z.object({ - config: zAgentModerationProviderConfig.optional(), - enabled: z.boolean().optional().default(false), - type: z.string().nullish(), -}) - -export const zFormInputConfig = z.unknown() - -export const zJsonValue2 = z.unknown() - -/** - * HumanInputFormSubmissionData - */ -export const zHumanInputFormSubmissionData = z.object({ - action_id: z.string(), - action_text: z.string(), - node_id: z.string(), - node_title: z.string(), - rendered_content: z.string(), - submitted_data: z.record(z.string(), zJsonValue2).nullish(), -}) - -/** - * OutputErrorStrategy + * ButtonStyle * - * Per-output failure handling strategy. - * - * Mirrors ``graphon.ErrorStrategy`` but scoped to a single declared output of - * a Workflow Agent Node. The runtime applies the strategy after type check or - * output check fails and any configured retry attempts have been exhausted. + * Button styles for user actions. */ -export const zOutputErrorStrategy = z.enum(['default_value', 'fail_branch', 'stop']) +export const zButtonStyle = z.enum(['accent', 'default', 'ghost', 'primary']) /** - * DeclaredOutputRetryConfig + * UserActionConfig * - * Per-output retry configuration that mirrors ``graphon.RetryConfig`` shape. + * User action configuration. */ -export const zDeclaredOutputRetryConfig = z.object({ - enabled: z.boolean().optional().default(false), - max_retries: z.int().gte(0).lte(10).optional().default(0), - retry_interval_ms: z.int().gte(0).lte(60000).optional().default(0), -}) - -/** - * DeclaredOutputFailureStrategy - * - * Per-output failure handling. - * - * A single strategy applies to both ``type_check`` and ``output_check`` failures - * (PRD does not distinguish them at the UX level). Stage 4 §4.4. - */ -export const zDeclaredOutputFailureStrategy = z.object({ - default_value: z.unknown().optional(), - on_failure: zOutputErrorStrategy.optional(), - retry: zDeclaredOutputRetryConfig.optional(), +export const zUserActionConfig = z.object({ + button_style: zButtonStyle.optional().default('default'), + id: z.string().max(20), + title: z.string().max(100), }) /** @@ -2435,7 +3055,7 @@ export const zAgentSoulModelSettings = z.object({ frequency_penalty: z.number().nullish(), max_tokens: z.int().nullish(), presence_penalty: z.number().nullish(), - response_format: zAgentModelResponseFormatConfig.optional(), + response_format: zAgentModelResponseFormatConfig.nullish(), stop: z.array(z.string()).nullish(), temperature: z.number().nullish(), top_p: z.number().nullish(), @@ -2447,53 +3067,13 @@ export const zAgentSoulModelSettings = z.object({ * Stable model selection for Agent runtime without storing secret values. */ export const zAgentSoulModelConfig = z.object({ - credential_ref: zAgentSoulModelCredentialRef.optional(), + credential_ref: zAgentSoulModelCredentialRef.nullish(), model: z.string().min(1).max(255), model_provider: z.string().min(1).max(255), model_settings: zAgentSoulModelSettings.optional(), plugin_id: z.string().min(1).max(255), }) -/** - * AgentSuggestedQuestionsAfterAnswerFeatureConfig - */ -export const zAgentSuggestedQuestionsAfterAnswerFeatureConfig = z.object({ - enabled: z.boolean().optional().default(false), - model: zAgentSoulModelConfig.optional(), - prompt: z.string().nullish(), -}) - -/** - * AgentAppFeaturesPayload - * - * Presentation features configurable on an Agent App. - * - * All fields are optional; an omitted field is reset to its disabled/empty - * default (the config form sends the full desired feature state on save). - */ -export const zAgentAppFeaturesPayload = z.object({ - opening_statement: z.string().nullish(), - retriever_resource: zAgentFeatureToggleConfig.optional(), - sensitive_word_avoidance: zAgentSensitiveWordAvoidanceFeatureConfig.optional(), - speech_to_text: zAgentFeatureToggleConfig.optional(), - suggested_questions: z.array(z.string()).nullish(), - suggested_questions_after_answer: zAgentSuggestedQuestionsAfterAnswerFeatureConfig.optional(), - text_to_speech: zAgentTextToSpeechFeatureConfig.optional(), -}) - -/** - * AgentSoulAppFeaturesConfig - */ -export const zAgentSoulAppFeaturesConfig = z.object({ - opening_statement: z.string().nullish(), - retriever_resource: zAgentFeatureToggleConfig.optional(), - sensitive_word_avoidance: zAgentSensitiveWordAvoidanceFeatureConfig.optional(), - speech_to_text: zAgentFeatureToggleConfig.optional(), - suggested_questions: z.array(z.string()).nullish(), - suggested_questions_after_answer: zAgentSuggestedQuestionsAfterAnswerFeatureConfig.optional(), - text_to_speech: zAgentTextToSpeechFeatureConfig.optional(), -}) - /** * DeclaredOutputCheckConfig * @@ -2502,9 +3082,9 @@ export const zAgentSoulAppFeaturesConfig = z.object({ * Per PRD §OUTPUT 配置框, output check is **file-only** and optional. Stage 4 §4.3. */ export const zDeclaredOutputCheckConfig = z.object({ - benchmark_file_ref: zAgentFileRefConfig.optional(), + benchmark_file_ref: zAgentFileRefConfig.nullish(), enabled: z.boolean().optional().default(false), - model_ref: zAgentSoulModelConfig.optional(), + model_ref: zAgentSoulModelConfig.nullish(), prompt: z.string().nullish(), }) @@ -2518,11 +3098,30 @@ export const zDeclaredOutputCheckConfig = z.object({ * code can call ``output.failure_strategy.on_failure`` without None-guards. */ export const zDeclaredOutputConfig = z.object({ - array_item: zDeclaredArrayItem.optional(), - check: zDeclaredOutputCheckConfig.optional(), + array_item: zDeclaredArrayItem.nullish(), + check: zDeclaredOutputCheckConfig.nullish(), + children: z + .array( + z.object({ + array_item: z + .object({ + children: z.array(z.record(z.string(), z.unknown())).optional(), + description: z.string().nullish(), + type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(), + }) + .optional(), + children: z.array(z.record(z.string(), z.unknown())).optional(), + description: z.string().nullish(), + file: z.record(z.string(), z.unknown()).optional(), + name: z.string(), + required: z.boolean().optional(), + type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']), + }), + ) + .optional(), description: z.string().nullish(), failure_strategy: zDeclaredOutputFailureStrategy.optional(), - file: zDeclaredOutputFileConfig.optional(), + file: zDeclaredOutputFileConfig.nullish(), id: z.string().nullish(), name: z.string().min(1).max(255), required: z.boolean().optional().default(true), @@ -2536,12 +3135,21 @@ export const zWorkflowNodeJobConfig = z.object({ declared_outputs: z.array(zDeclaredOutputConfig).optional(), human_contacts: z.array(zAgentHumanContactConfig).optional(), metadata: zWorkflowNodeJobMetadata.optional(), - mode: zWorkflowNodeJobMode.optional(), + mode: zWorkflowNodeJobMode.optional().default('tell_agent_what_to_do'), previous_node_output_refs: z.array(zWorkflowPreviousNodeOutputRef).optional(), schema_version: z.int().optional().default(1), workflow_prompt: z.string().optional().default(''), }) +/** + * AgentSuggestedQuestionsAfterAnswerFeatureConfig + */ +export const zAgentSuggestedQuestionsAfterAnswerFeatureConfig = z.object({ + enabled: z.boolean().optional().default(false), + model: zAgentSoulModelConfig.nullish(), + prompt: z.string().nullish(), +}) + /** * AgentSoulDifyToolCredentialRef * @@ -2568,7 +3176,7 @@ export const zAgentSoulDifyToolCredentialRef = z.object({ * new callers should send ``plugin_id`` + ``provider`` when available. */ export const zAgentSoulDifyToolConfig = z.object({ - credential_ref: zAgentSoulDifyToolCredentialRef.optional(), + credential_ref: zAgentSoulDifyToolCredentialRef.nullish(), credential_type: z.enum(['api-key', 'oauth2', 'unauthorized']).optional().default('api-key'), description: z.string().nullish(), enabled: z.boolean().optional().default(true), @@ -2577,8 +3185,24 @@ export const zAgentSoulDifyToolConfig = z.object({ provider: z.string().max(255).nullish(), provider_id: z.string().max(255).nullish(), provider_type: z.string().optional().default('plugin'), - runtime_parameters: z.record(z.string(), z.unknown()).optional(), - tool_name: z.string().min(1).max(255), + runtime_parameters: z + .record( + z.string(), + z + .union([ + z.string(), + z.int(), + z.number(), + z.boolean(), + z.array(z.string()), + z.array(z.int()), + z.array(z.number()), + z.array(z.boolean()), + ]) + .nullable(), + ) + .optional(), + tool_name: z.string().min(1).max(255).nullish(), }) /** @@ -2589,157 +3213,6 @@ export const zAgentSoulToolsConfig = z.object({ dify_tools: z.array(zAgentSoulDifyToolConfig).optional(), }) -/** - * AgentSoulConfig - */ -export const zAgentSoulConfig = z.object({ - app_features: zAgentSoulAppFeaturesConfig.optional(), - app_variables: z.array(zAppVariableConfig).optional(), - env: zAgentSoulEnvConfig.optional(), - human: zAgentSoulHumanConfig.optional(), - knowledge: zAgentSoulKnowledgeConfig.optional(), - memory: zAgentSoulMemoryConfig.optional(), - misc_legacy: zAgentSoulAppFeaturesConfig.optional(), - model: zAgentSoulModelConfig.optional(), - prompt: zAgentSoulPromptConfig.optional(), - sandbox: zAgentSoulSandboxConfig.optional(), - schema_version: z.int().optional().default(1), - skills_files: zAgentSoulSkillsFilesConfig.optional(), - tools: zAgentSoulToolsConfig.optional(), -}) - -/** - * AgentAppComposerResponse - */ -export const zAgentAppComposerResponse = z.object({ - active_config_snapshot: zAgentConfigSnapshotSummaryResponse, - agent: zAgentComposerAgentResponse, - agent_soul: zAgentSoulConfig, - save_options: z.array(zComposerSaveStrategy), - validation: zComposerValidationFindingsResponse.optional(), - variant: z.string(), -}) - -/** - * ComposerSavePayload - */ -export const zComposerSavePayload = z.object({ - agent_soul: zAgentSoulConfig.optional(), - binding: zComposerBindingPayload.optional(), - client_revision_id: z.string().nullish(), - idempotency_key: z.string().nullish(), - new_agent_name: z.string().min(1).max(255).nullish(), - node_job: zWorkflowNodeJobConfig.optional(), - save_strategy: zComposerSaveStrategy, - soul_lock: zComposerSoulLockPayload.optional(), - variant: zComposerVariant, - version_note: z.string().nullish(), -}) - -/** - * WorkflowAgentComposerResponse - */ -export const zWorkflowAgentComposerResponse = z.object({ - active_config_snapshot: zAgentConfigSnapshotSummaryResponse.optional(), - agent: zAgentComposerAgentResponse.optional(), - agent_soul: zAgentSoulConfig, - app_id: z.string().nullish(), - binding: zAgentComposerBindingResponse.optional(), - effective_declared_outputs: z.array(zDeclaredOutputConfig).optional(), - impact_summary: zAgentComposerImpactResponse.optional(), - node_id: z.string().nullish(), - node_job: zWorkflowNodeJobConfig, - save_options: z.array(zComposerSaveStrategy), - soul_lock: zAgentComposerSoulLockResponse, - validation: zComposerValidationFindingsResponse.optional(), - variant: z.string(), - workflow_id: z.string().nullish(), -}) - -/** - * ButtonStyle - * - * Button styles for user actions. - */ -export const zButtonStyle = z.enum(['accent', 'default', 'ghost', 'primary']) - -/** - * UserActionConfig - * - * User action configuration. - */ -export const zUserActionConfig = z.object({ - button_style: zButtonStyle.optional(), - id: z.string().max(20), - title: z.string().max(100), -}) - -/** - * HumanInputFormDefinition - */ -export const zHumanInputFormDefinition = z.object({ - actions: z.array(zUserActionConfig).optional(), - display_in_ui: z.boolean().optional().default(false), - expiration_time: z.int(), - form_content: z.string(), - form_id: z.string(), - form_token: z.string().nullish(), - inputs: z.array(zFormInputConfig).optional(), - node_id: z.string(), - node_title: z.string(), - resolved_default_values: z.record(z.string(), z.unknown()).optional(), -}) - -/** - * HumanInputContent - */ -export const zHumanInputContent = z.object({ - form_definition: zHumanInputFormDefinition.optional(), - form_submission_data: zHumanInputFormSubmissionData.optional(), - submitted: z.boolean(), - type: zExecutionContentType.optional(), - workflow_run_id: z.string(), -}) - -/** - * MessageDetailResponse - */ -export const zMessageDetailResponse = z.object({ - agent_thoughts: z.array(zAgentThought).optional(), - annotation: zConversationAnnotation.optional(), - annotation_hit_history: zConversationAnnotationHitHistory.optional(), - answer_tokens: z.int().nullish(), - conversation_id: z.string(), - created_at: z.int().nullish(), - error: z.string().nullish(), - extra_contents: z.array(zHumanInputContent).optional(), - feedbacks: z.array(zFeedback).optional(), - 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.optional(), - message_files: z.array(zMessageFile).optional(), - message_metadata_dict: zJsonValue.optional(), - message_tokens: z.int().nullish(), - parent_message_id: z.string().nullish(), - provider_response_latency: z.number().nullish(), - query: z.string(), - re_sign_file_url_answer: z.string(), - status: z.string(), - workflow_run_id: z.string().nullish(), -}) - -/** - * MessageInfiniteScrollPaginationResponse - */ -export const zMessageInfiniteScrollPaginationResponse = z.object({ - data: z.array(zMessageDetailResponse), - has_more: z.boolean(), - limit: z.int(), -}) - /** * FileType */ @@ -2763,7 +3236,7 @@ export const zFileInputConfig = z.object({ allowed_file_types: z.array(zFileType).optional(), allowed_file_upload_methods: z.array(zFileTransferMethod).optional(), output_variable_name: z.string(), - type: z.string().optional().default('file'), + type: z.literal('file').optional().default('file'), }) /** @@ -2775,7 +3248,102 @@ export const zFileListInputConfig = z.object({ allowed_file_upload_methods: z.array(zFileTransferMethod).optional(), number_limits: z.int().gte(0).optional().default(0), output_variable_name: z.string(), - type: z.string().optional().default('file-list'), + type: z.literal('file-list').optional().default('file-list'), +}) + +/** + * AgentModerationIOConfig + */ +export const zAgentModerationIoConfig = z.object({ + enabled: z.boolean().optional().default(false), + preset_response: z.string().nullish(), +}) + +/** + * AgentModerationProviderConfig + */ +export const zAgentModerationProviderConfig = z.object({ + api_based_extension_id: z.string().nullish(), + inputs_config: zAgentModerationIoConfig.nullish(), + keywords: z.string().nullish(), + outputs_config: zAgentModerationIoConfig.nullish(), +}) + +/** + * AgentSensitiveWordAvoidanceFeatureConfig + */ +export const zAgentSensitiveWordAvoidanceFeatureConfig = z.object({ + config: zAgentModerationProviderConfig.nullish(), + enabled: z.boolean().optional().default(false), + type: z.string().nullish(), +}) + +/** + * AgentSoulAppFeaturesConfig + */ +export const zAgentSoulAppFeaturesConfig = z.object({ + opening_statement: z.string().nullish(), + retriever_resource: zAgentFeatureToggleConfig.nullish(), + sensitive_word_avoidance: zAgentSensitiveWordAvoidanceFeatureConfig.nullish(), + speech_to_text: zAgentFeatureToggleConfig.nullish(), + suggested_questions: z.array(z.string()).nullish(), + suggested_questions_after_answer: zAgentSuggestedQuestionsAfterAnswerFeatureConfig.nullish(), + text_to_speech: zAgentTextToSpeechFeatureConfig.nullish(), +}) + +/** + * AgentSoulConfig + */ +export const zAgentSoulConfig = z.object({ + app_features: zAgentSoulAppFeaturesConfig.optional(), + app_variables: z.array(zAppVariableConfig).optional(), + env: zAgentSoulEnvConfig.optional(), + human: zAgentSoulHumanConfig.optional(), + knowledge: zAgentSoulKnowledgeConfig.optional(), + memory: zAgentSoulMemoryConfig.optional(), + misc_legacy: zAgentSoulAppFeaturesConfig.optional(), + model: zAgentSoulModelConfig.nullish(), + prompt: zAgentSoulPromptConfig.optional(), + sandbox: zAgentSoulSandboxConfig.optional(), + schema_version: z.int().optional().default(1), + skills_files: zAgentSoulSkillsFilesConfig.optional(), + tools: zAgentSoulToolsConfig.optional(), +}) + +/** + * WorkflowAgentComposerResponse + */ +export const zWorkflowAgentComposerResponse = z.object({ + active_config_snapshot: zAgentConfigSnapshotSummaryResponse.nullish(), + agent: zAgentComposerAgentResponse.nullish(), + agent_soul: zAgentSoulConfig, + app_id: z.string().nullish(), + binding: zAgentComposerBindingResponse.nullish(), + effective_declared_outputs: z.array(zDeclaredOutputConfig).optional(), + impact_summary: zAgentComposerImpactResponse.nullish(), + node_id: z.string().nullish(), + node_job: zWorkflowNodeJobConfig, + save_options: z.array(zComposerSaveStrategy), + soul_lock: zAgentComposerSoulLockResponse, + validation: zComposerValidationFindingsResponse.nullish(), + variant: z.literal('workflow'), + workflow_id: z.string().nullish(), +}) + +/** + * ComposerSavePayload + */ +export const zComposerSavePayload = z.object({ + agent_soul: zAgentSoulConfig.nullish(), + binding: zComposerBindingPayload.nullish(), + client_revision_id: z.string().nullish(), + idempotency_key: z.string().nullish(), + new_agent_name: z.string().min(1).max(255).nullish(), + node_job: zWorkflowNodeJobConfig.nullish(), + save_strategy: zComposerSaveStrategy, + soul_lock: zComposerSoulLockPayload.optional(), + variant: zComposerVariant, + version_note: z.string().nullish(), }) /** @@ -2803,9 +3371,9 @@ export const zStringSource = z.object({ * Form input definition. */ export const zParagraphInputConfig = z.object({ - default: zStringSource.optional(), + default: zStringSource.nullish(), output_variable_name: z.string(), - type: z.string().optional().default('paragraph'), + type: z.literal('paragraph').optional().default('paragraph'), }) /** @@ -2823,7 +3391,137 @@ export const zStringListSource = z.object({ export const zSelectInputConfig = z.object({ option_source: zStringListSource, output_variable_name: z.string(), - type: z.string().optional().default('select'), + type: z.literal('select').optional().default('select'), +}) + +export const zFormInputConfig = z.discriminatedUnion('type', [ + zParagraphInputConfig.extend({ type: z.literal('paragraph') }), + zSelectInputConfig.extend({ type: z.literal('select') }), + zFileInputConfig.extend({ type: z.literal('file') }), + zFileListInputConfig.extend({ type: z.literal('file-list') }), +]) + +/** + * HumanInputFormDefinition + */ +export const zHumanInputFormDefinition = z.object({ + actions: z.array(zUserActionConfig).optional(), + display_in_ui: z.boolean().optional().default(false), + expiration_time: z.int(), + form_content: z.string(), + form_id: z.string(), + form_token: z.string().nullish(), + inputs: z.array(zFormInputConfig).optional(), + node_id: z.string(), + node_title: z.string(), + resolved_default_values: z.record(z.string(), z.unknown()).optional(), +}) + +/** + * HumanInputContent + */ +export const zHumanInputContent = z.object({ + form_definition: zHumanInputFormDefinition.nullish(), + form_submission_data: zHumanInputFormSubmissionData.nullish(), + submitted: z.boolean(), + type: zExecutionContentType.optional().default('human_input'), + workflow_run_id: z.string(), +}) + +/** + * MessageDetailResponse + */ +export const zMessageDetailResponse = z.object({ + agent_thoughts: z.array(zAgentThought).optional(), + annotation: zConversationAnnotation.nullish(), + annotation_hit_history: zConversationAnnotationHitHistory.nullish(), + answer_tokens: z.int().nullish(), + conversation_id: z.string(), + created_at: z.int().nullish(), + error: z.string().nullish(), + extra_contents: z.array(zHumanInputContent).optional(), + feedbacks: z.array(zFeedback).optional(), + 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(), + parent_message_id: z.string().nullish(), + provider_response_latency: z.number().nullish(), + query: z.string(), + re_sign_file_url_answer: z.string(), + status: z.string(), + workflow_run_id: z.string().nullish(), +}) + +/** + * MessageInfiniteScrollPaginationResponse + */ +export const zMessageInfiniteScrollPaginationResponse = z.object({ + data: z.array(zMessageDetailResponse), + has_more: z.boolean(), + limit: z.int(), +}) + +/** + * GeneratedAppResponse + */ +export const zGeneratedAppResponseWritable = zJsonValue + +/** + * Site + */ +export const zSiteWritable = z.object({ + chat_color_theme: z.string().nullish(), + chat_color_theme_inverted: z.boolean(), + copyright: z.string().nullish(), + custom_disclaimer: z.string().nullish(), + default_language: z.string(), + description: z.string().nullish(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.string().nullish(), + privacy_policy: z.string().nullish(), + show_workflow_steps: z.boolean(), + title: z.string(), + use_icon_as_answer_icon: z.boolean(), +}) + +/** + * AppDetailWithSite + */ +export const zAppDetailWithSiteWritable = 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(), + 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(), + id: z.string(), + max_active_requests: z.int().nullish(), + mode: z.string(), + model_config: zModelConfig.nullish(), + name: z.string(), + role: z.string().nullish(), + site: zSiteWritable.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(), }) /** @@ -2842,7 +3540,7 @@ export const zWorkflowCommentBasicWritable = z.object({ content: z.string(), created_at: z.int().nullish(), created_by: z.string(), - created_by_account: zWorkflowCommentAccountWritable.optional(), + created_by_account: zWorkflowCommentAccountWritable.nullish(), id: z.string(), mention_count: z.int(), participants: z.array(zWorkflowCommentAccountWritable), @@ -2852,7 +3550,7 @@ export const zWorkflowCommentBasicWritable = z.object({ resolved: z.boolean(), resolved_at: z.int().nullish(), resolved_by: z.string().nullish(), - resolved_by_account: zWorkflowCommentAccountWritable.optional(), + resolved_by_account: zWorkflowCommentAccountWritable.nullish(), updated_at: z.int().nullish(), }) @@ -2867,7 +3565,7 @@ export const zWorkflowCommentBasicListWritable = z.object({ * WorkflowCommentMention */ export const zWorkflowCommentMentionWritable = z.object({ - mentioned_user_account: zWorkflowCommentAccountWritable.optional(), + mentioned_user_account: zWorkflowCommentAccountWritable.nullish(), mentioned_user_id: z.string(), reply_id: z.string().nullish(), }) @@ -2879,7 +3577,7 @@ export const zWorkflowCommentReplyWritable = z.object({ content: z.string(), created_at: z.int().nullish(), created_by: z.string(), - created_by_account: zWorkflowCommentAccountWritable.optional(), + created_by_account: zWorkflowCommentAccountWritable.nullish(), id: z.string(), }) @@ -2890,7 +3588,7 @@ export const zWorkflowCommentDetailWritable = z.object({ content: z.string(), created_at: z.int().nullish(), created_by: z.string(), - created_by_account: zWorkflowCommentAccountWritable.optional(), + created_by_account: zWorkflowCommentAccountWritable.nullish(), id: z.string(), mentions: z.array(zWorkflowCommentMentionWritable), position_x: z.number(), @@ -2899,13 +3597,13 @@ export const zWorkflowCommentDetailWritable = z.object({ resolved: z.boolean(), resolved_at: z.int().nullish(), resolved_by: z.string().nullish(), - resolved_by_account: zWorkflowCommentAccountWritable.optional(), + resolved_by_account: zWorkflowCommentAccountWritable.nullish(), updated_at: z.int().nullish(), }) export const zGetAppsQuery = z.object({ - creator_ids: z.array(z.string()).nullish(), - is_created_by_me: z.boolean().nullish(), + creator_ids: z.array(z.string()).optional(), + is_created_by_me: z.boolean().optional(), limit: z.int().gte(1).lte(100).optional().default(20), mode: z .enum([ @@ -2920,9 +3618,13 @@ export const zGetAppsQuery = z.object({ ]) .optional() .default('all'), - name: z.string().nullish(), + name: z.string().optional(), page: z.int().gte(1).lte(99999).optional().default(1), - tag_ids: z.array(z.string()).nullish(), + sort_by: z + .enum(['earliest_created', 'last_modified', 'recently_created']) + .optional() + .default('last_modified'), + tag_ids: z.array(z.string()).optional(), }) /** @@ -2935,7 +3637,7 @@ export const zPostAppsBody = zCreateAppPayload /** * App created successfully */ -export const zPostAppsResponse = zAppDetail +export const zPostAppsResponse = zAppDetailWithSite export const zPostAppsImportsBody = zAppImportPayload @@ -2962,6 +3664,37 @@ export const zPostAppsImportsByImportIdConfirmPath = z.object({ */ export const zPostAppsImportsByImportIdConfirmResponse = zImport +export const zGetAppsStarredQuery = z.object({ + creator_ids: z.array(z.string()).optional(), + is_created_by_me: z.boolean().optional(), + limit: z.int().gte(1).lte(100).optional().default(20), + mode: z + .enum([ + 'advanced-chat', + 'agent', + 'agent-chat', + 'all', + 'channel', + 'chat', + 'completion', + 'workflow', + ]) + .optional() + .default('all'), + name: z.string().optional(), + page: z.int().gte(1).lte(99999).optional().default(1), + sort_by: z + .enum(['earliest_created', 'last_modified', 'recently_created']) + .optional() + .default('last_modified'), + tag_ids: z.array(z.string()).optional(), +}) + +/** + * Success + */ +export const zGetAppsStarredResponse = zAppPagination + export const zPostAppsWorkflowsOnlineUsersBody = zWorkflowOnlineUsersPayload /** @@ -2976,7 +3709,7 @@ export const zDeleteAppsByAppIdPath = z.object({ /** * App deleted successfully */ -export const zDeleteAppsByAppIdResponse = z.record(z.string(), z.never()) +export const zDeleteAppsByAppIdResponse = z.void() export const zGetAppsByAppIdPath = z.object({ app_id: z.string(), @@ -3040,10 +3773,10 @@ export const zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFo }) /** - * Success + * Human input form preview */ export const zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponse - = z.record(z.string(), z.unknown()) + = zHumanInputFormPreviewResponse export const zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRunBody = zHumanInputFormSubmitPayload @@ -3055,10 +3788,10 @@ export const zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFo }) /** - * Success + * Human input form submission result */ export const zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRunResponse - = z.record(z.string(), z.unknown()) + = zHumanInputFormSubmitResponse export const zPostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRunBody = zIterationNodeRunPayload @@ -3071,10 +3804,8 @@ export const zPostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRun /** * Iteration node run started successfully */ -export const zPostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRunResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRunResponse + = zGeneratedAppResponse export const zPostAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRunBody = zLoopNodeRunPayload @@ -3087,10 +3818,8 @@ export const zPostAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRunPath /** * Loop node run started successfully */ -export const zPostAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRunResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRunResponse + = zGeneratedAppResponse export const zPostAppsByAppIdAdvancedChatWorkflowsDraftRunBody = zAdvancedChatWorkflowRunPayload @@ -3101,112 +3830,78 @@ export const zPostAppsByAppIdAdvancedChatWorkflowsDraftRunPath = z.object({ /** * Workflow run started successfully */ -export const zPostAppsByAppIdAdvancedChatWorkflowsDraftRunResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostAppsByAppIdAdvancedChatWorkflowsDraftRunResponse = zGeneratedAppResponse -export const zGetAppsByAppIdAgentComposerPath = z.object({ +export const zGetAppsByAppIdAgentDriveFilesPath = z.object({ app_id: z.string(), }) +export const zGetAppsByAppIdAgentDriveFilesQuery = z.object({ + node_id: z.string().optional(), + prefix: z.string().optional().default(''), +}) + /** - * Agent app composer state + * Drive entries */ -export const zGetAppsByAppIdAgentComposerResponse = zAgentAppComposerResponse +export const zGetAppsByAppIdAgentDriveFilesResponse = zAgentDriveListResponse -export const zPutAppsByAppIdAgentComposerBody = zComposerSavePayload - -export const zPutAppsByAppIdAgentComposerPath = z.object({ +export const zGetAppsByAppIdAgentDriveFilesDownloadPath = z.object({ app_id: z.string(), }) -/** - * Agent app composer saved - */ -export const zPutAppsByAppIdAgentComposerResponse = zAgentAppComposerResponse +export const zGetAppsByAppIdAgentDriveFilesDownloadQuery = z.object({ + key: z.string().min(1), + node_id: z.string().optional(), +}) -export const zGetAppsByAppIdAgentComposerCandidatesPath = z.object({ +/** + * Signed URL + */ +export const zGetAppsByAppIdAgentDriveFilesDownloadResponse = zAgentDriveDownloadResponse + +export const zGetAppsByAppIdAgentDriveFilesPreviewPath = z.object({ app_id: z.string(), }) +export const zGetAppsByAppIdAgentDriveFilesPreviewQuery = z.object({ + key: z.string().min(1), + node_id: z.string().optional(), +}) + /** - * Agent app composer candidates + * Preview */ -export const zGetAppsByAppIdAgentComposerCandidatesResponse = zAgentComposerCandidatesResponse +export const zGetAppsByAppIdAgentDriveFilesPreviewResponse = zAgentDrivePreviewResponse -export const zPostAppsByAppIdAgentComposerValidateBody = zComposerSavePayload - -export const zPostAppsByAppIdAgentComposerValidatePath = z.object({ +export const zDeleteAppsByAppIdAgentFilesPath = z.object({ app_id: z.string(), }) +export const zDeleteAppsByAppIdAgentFilesQuery = z.object({ + key: z.string().min(1), + node_id: z.string().optional(), +}) + /** - * Agent app composer validation result + * File removed */ -export const zPostAppsByAppIdAgentComposerValidateResponse = zAgentComposerValidateResponse +export const zDeleteAppsByAppIdAgentFilesResponse = zAgentDriveDeleteResponse -export const zPostAppsByAppIdAgentFeaturesBody = zAgentAppFeaturesPayload +export const zPostAppsByAppIdAgentFilesBody = zAgentDriveFilePayload -export const zPostAppsByAppIdAgentFeaturesPath = z.object({ +export const zPostAppsByAppIdAgentFilesPath = z.object({ app_id: z.string(), }) -/** - * Features updated successfully - */ -export const zPostAppsByAppIdAgentFeaturesResponse = zSimpleResultResponse - -export const zGetAppsByAppIdAgentReferencingWorkflowsPath = z.object({ - app_id: z.string(), +export const zPostAppsByAppIdAgentFilesQuery = z.object({ + node_id: z.string().optional(), }) /** - * Referencing workflows listed successfully + * File committed into the agent drive */ -export const zGetAppsByAppIdAgentReferencingWorkflowsResponse = zAgentReferencingWorkflowsResponse - -export const zGetAppsByAppIdAgentWorkspaceFilesPath = z.object({ - app_id: z.string(), -}) - -export const zGetAppsByAppIdAgentWorkspaceFilesQuery = z.object({ - conversation_id: z.string().min(1), - path: z.string().optional().default('.'), -}) - -/** - * Listing returned - */ -export const zGetAppsByAppIdAgentWorkspaceFilesResponse = zWorkspaceListResponse - -export const zGetAppsByAppIdAgentWorkspaceFilesDownloadPath = z.object({ - app_id: z.string(), -}) - -export const zGetAppsByAppIdAgentWorkspaceFilesDownloadQuery = z.object({ - conversation_id: z.string().min(1), - path: z.string().min(1), -}) - -/** - * File bytes - */ -export const zGetAppsByAppIdAgentWorkspaceFilesDownloadResponse = z.custom() - -export const zGetAppsByAppIdAgentWorkspaceFilesPreviewPath = z.object({ - app_id: z.string(), -}) - -export const zGetAppsByAppIdAgentWorkspaceFilesPreviewQuery = z.object({ - conversation_id: z.string().min(1), - path: z.string().min(1), -}) - -/** - * Preview returned - */ -export const zGetAppsByAppIdAgentWorkspaceFilesPreviewResponse = zWorkspacePreviewResponse +export const zPostAppsByAppIdAgentFilesResponse = zAgentDriveFileCommitResponse export const zGetAppsByAppIdAgentLogsPath = z.object({ app_id: z.string(), @@ -3220,16 +3915,20 @@ export const zGetAppsByAppIdAgentLogsQuery = z.object({ /** * Agent logs retrieved successfully */ -export const zGetAppsByAppIdAgentLogsResponse = z.array(z.record(z.string(), z.unknown())) +export const zGetAppsByAppIdAgentLogsResponse = zAgentLogResponse export const zPostAppsByAppIdAgentSkillsStandardizePath = z.object({ app_id: z.string(), }) +export const zPostAppsByAppIdAgentSkillsStandardizeQuery = z.object({ + node_id: z.string().optional(), +}) + /** * Skill standardized into drive */ -export const zPostAppsByAppIdAgentSkillsStandardizeResponse = z.record(z.string(), z.unknown()) +export const zPostAppsByAppIdAgentSkillsStandardizeResponse = zAgentSkillStandardizeResponse export const zPostAppsByAppIdAgentSkillsUploadPath = z.object({ app_id: z.string(), @@ -3238,7 +3937,35 @@ export const zPostAppsByAppIdAgentSkillsUploadPath = z.object({ /** * Skill validated */ -export const zPostAppsByAppIdAgentSkillsUploadResponse = z.record(z.string(), z.unknown()) +export const zPostAppsByAppIdAgentSkillsUploadResponse = zAgentSkillUploadResponse + +export const zDeleteAppsByAppIdAgentSkillsBySlugPath = z.object({ + app_id: z.string(), + slug: z.string(), +}) + +export const zDeleteAppsByAppIdAgentSkillsBySlugQuery = z.object({ + node_id: z.string().optional(), +}) + +/** + * Skill removed + */ +export const zDeleteAppsByAppIdAgentSkillsBySlugResponse = zAgentDriveDeleteResponse + +export const zPostAppsByAppIdAgentSkillsBySlugInferToolsPath = z.object({ + app_id: z.string(), + slug: z.string(), +}) + +export const zPostAppsByAppIdAgentSkillsBySlugInferToolsQuery = z.object({ + node_id: z.string().optional(), +}) + +/** + * Inference result (draft suggestions, nothing persisted) + */ +export const zPostAppsByAppIdAgentSkillsBySlugInferToolsResponse = zSkillToolInferenceResult export const zPostAppsByAppIdAnnotationReplyByActionBody = zAnnotationReplyPayload @@ -3250,7 +3977,7 @@ export const zPostAppsByAppIdAnnotationReplyByActionPath = z.object({ /** * Action completed successfully */ -export const zPostAppsByAppIdAnnotationReplyByActionResponse = z.record(z.string(), z.unknown()) +export const zPostAppsByAppIdAnnotationReplyByActionResponse = zAnnotationJobStatusResponse export const zGetAppsByAppIdAnnotationReplyByActionStatusByJobIdPath = z.object({ action: z.string(), @@ -3261,10 +3988,8 @@ export const zGetAppsByAppIdAnnotationReplyByActionStatusByJobIdPath = z.object( /** * Job status retrieved successfully */ -export const zGetAppsByAppIdAnnotationReplyByActionStatusByJobIdResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetAppsByAppIdAnnotationReplyByActionStatusByJobIdResponse + = zAnnotationJobStatusResponse export const zGetAppsByAppIdAnnotationSettingPath = z.object({ app_id: z.string(), @@ -3273,7 +3998,7 @@ export const zGetAppsByAppIdAnnotationSettingPath = z.object({ /** * Annotation settings retrieved successfully */ -export const zGetAppsByAppIdAnnotationSettingResponse = z.record(z.string(), z.unknown()) +export const zGetAppsByAppIdAnnotationSettingResponse = zAnnotationSettingResponse export const zPostAppsByAppIdAnnotationSettingsByAnnotationSettingIdBody = zAnnotationSettingUpdatePayload @@ -3286,19 +4011,17 @@ export const zPostAppsByAppIdAnnotationSettingsByAnnotationSettingIdPath = z.obj /** * Settings updated successfully */ -export const zPostAppsByAppIdAnnotationSettingsByAnnotationSettingIdResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostAppsByAppIdAnnotationSettingsByAnnotationSettingIdResponse + = zAnnotationSettingResponse export const zDeleteAppsByAppIdAnnotationsPath = z.object({ app_id: z.string(), }) /** - * Success + * Annotations deleted successfully */ -export const zDeleteAppsByAppIdAnnotationsResponse = z.record(z.string(), z.unknown()) +export const zDeleteAppsByAppIdAnnotationsResponse = z.void() export const zGetAppsByAppIdAnnotationsPath = z.object({ app_id: z.string(), @@ -3313,7 +4036,7 @@ export const zGetAppsByAppIdAnnotationsQuery = z.object({ /** * Annotations retrieved successfully */ -export const zGetAppsByAppIdAnnotationsResponse = z.record(z.string(), z.unknown()) +export const zGetAppsByAppIdAnnotationsResponse = zAnnotationList export const zPostAppsByAppIdAnnotationsBody = zCreateAnnotationPayload @@ -3333,7 +4056,7 @@ export const zPostAppsByAppIdAnnotationsBatchImportPath = z.object({ /** * Batch import started successfully */ -export const zPostAppsByAppIdAnnotationsBatchImportResponse = z.record(z.string(), z.unknown()) +export const zPostAppsByAppIdAnnotationsBatchImportResponse = zAnnotationJobStatusResponse export const zGetAppsByAppIdAnnotationsBatchImportStatusByJobIdPath = z.object({ app_id: z.string(), @@ -3343,10 +4066,8 @@ export const zGetAppsByAppIdAnnotationsBatchImportStatusByJobIdPath = z.object({ /** * Job status retrieved successfully */ -export const zGetAppsByAppIdAnnotationsBatchImportStatusByJobIdResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetAppsByAppIdAnnotationsBatchImportStatusByJobIdResponse + = zAnnotationJobStatusResponse export const zGetAppsByAppIdAnnotationsCountPath = z.object({ app_id: z.string(), @@ -3372,9 +4093,9 @@ export const zDeleteAppsByAppIdAnnotationsByAnnotationIdPath = z.object({ }) /** - * Success + * Annotation deleted successfully */ -export const zDeleteAppsByAppIdAnnotationsByAnnotationIdResponse = z.record(z.string(), z.unknown()) +export const zDeleteAppsByAppIdAnnotationsByAnnotationIdResponse = z.void() export const zPostAppsByAppIdAnnotationsByAnnotationIdBody = zUpdateAnnotationPayload @@ -3383,10 +4104,7 @@ export const zPostAppsByAppIdAnnotationsByAnnotationIdPath = z.object({ app_id: z.string(), }) -export const zPostAppsByAppIdAnnotationsByAnnotationIdResponse = z.union([ - zAnnotation, - z.record(z.string(), z.never()), -]) +export const zPostAppsByAppIdAnnotationsByAnnotationIdResponse = z.union([zAnnotation, z.void()]) export const zGetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesPath = z.object({ annotation_id: z.string(), @@ -3394,8 +4112,8 @@ export const zGetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesPath = z.object }) export const zGetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesQuery = z.object({ - limit: z.int().optional().default(20), - page: z.int().optional().default(1), + limit: z.int().gte(1).optional().default(20), + page: z.int().gte(1).optional().default(1), }) /** @@ -3430,15 +4148,15 @@ export const zGetAppsByAppIdChatConversationsPath = z.object({ export const zGetAppsByAppIdChatConversationsQuery = z.object({ annotation_status: z.enum(['all', 'annotated', 'not_annotated']).optional().default('all'), - end: z.string().nullish(), - keyword: z.string().nullish(), + end: z.string().optional(), + keyword: z.string().optional(), limit: z.int().gte(1).lte(100).optional().default(20), page: z.int().gte(1).lte(99999).optional().default(1), sort_by: z .enum(['-created_at', '-updated_at', 'created_at', 'updated_at']) .optional() .default('-updated_at'), - start: z.string().nullish(), + start: z.string().optional(), }) /** @@ -3454,10 +4172,7 @@ export const zDeleteAppsByAppIdChatConversationsByConversationIdPath = z.object( /** * Conversation deleted successfully */ -export const zDeleteAppsByAppIdChatConversationsByConversationIdResponse = z.record( - z.string(), - z.never(), -) +export const zDeleteAppsByAppIdChatConversationsByConversationIdResponse = z.void() export const zGetAppsByAppIdChatConversationsByConversationIdPath = z.object({ app_id: z.string(), @@ -3475,7 +4190,7 @@ export const zGetAppsByAppIdChatMessagesPath = z.object({ export const zGetAppsByAppIdChatMessagesQuery = z.object({ conversation_id: z.string(), - first_id: z.string().nullish(), + first_id: z.string().optional(), limit: z.int().gte(1).lte(100).optional().default(20), }) @@ -3511,11 +4226,11 @@ export const zGetAppsByAppIdCompletionConversationsPath = z.object({ export const zGetAppsByAppIdCompletionConversationsQuery = z.object({ annotation_status: z.enum(['all', 'annotated', 'not_annotated']).optional().default('all'), - end: z.string().nullish(), - keyword: z.string().nullish(), + end: z.string().optional(), + keyword: z.string().optional(), limit: z.int().gte(1).lte(100).optional().default(20), page: z.int().gte(1).lte(99999).optional().default(1), - start: z.string().nullish(), + start: z.string().optional(), }) /** @@ -3531,10 +4246,7 @@ export const zDeleteAppsByAppIdCompletionConversationsByConversationIdPath = z.o /** * Conversation deleted successfully */ -export const zDeleteAppsByAppIdCompletionConversationsByConversationIdResponse = z.record( - z.string(), - z.never(), -) +export const zDeleteAppsByAppIdCompletionConversationsByConversationIdResponse = z.void() export const zGetAppsByAppIdCompletionConversationsByConversationIdPath = z.object({ app_id: z.string(), @@ -3556,7 +4268,7 @@ export const zPostAppsByAppIdCompletionMessagesPath = z.object({ /** * Completion generated successfully */ -export const zPostAppsByAppIdCompletionMessagesResponse = z.record(z.string(), z.unknown()) +export const zPostAppsByAppIdCompletionMessagesResponse = zGeneratedAppResponse export const zPostAppsByAppIdCompletionMessagesByTaskIdStopPath = z.object({ app_id: z.string(), @@ -3609,7 +4321,7 @@ export const zGetAppsByAppIdExportPath = z.object({ export const zGetAppsByAppIdExportQuery = z.object({ include_secret: z.boolean().optional().default(false), - workflow_id: z.string().nullish(), + workflow_id: z.string().optional(), }) /** @@ -3633,18 +4345,18 @@ export const zGetAppsByAppIdFeedbacksExportPath = z.object({ }) export const zGetAppsByAppIdFeedbacksExportQuery = z.object({ - end_date: z.string().nullish(), + end_date: z.string().optional(), format: z.enum(['csv', 'json']).optional().default('csv'), - from_source: z.enum(['admin', 'user']).nullish(), - has_comment: z.boolean().nullish(), - rating: z.enum(['dislike', 'like']).nullish(), - start_date: z.string().nullish(), + from_source: z.enum(['admin', 'user']).optional(), + has_comment: z.boolean().optional(), + rating: z.enum(['dislike', 'like']).optional(), + start_date: z.string().optional(), }) /** * Feedback data exported successfully */ -export const zGetAppsByAppIdFeedbacksExportResponse = z.record(z.string(), z.unknown()) +export const zGetAppsByAppIdFeedbacksExportResponse = zTextFileResponse export const zPostAppsByAppIdIconBody = zAppIconPayload @@ -3655,7 +4367,7 @@ export const zPostAppsByAppIdIconPath = z.object({ /** * Icon updated successfully */ -export const zPostAppsByAppIdIconResponse = z.record(z.string(), z.unknown()) +export const zPostAppsByAppIdIconResponse = zAppDetail export const zGetAppsByAppIdMessagesByMessageIdPath = z.object({ app_id: z.string(), @@ -3676,7 +4388,7 @@ export const zPostAppsByAppIdModelConfigPath = z.object({ /** * Model configuration updated successfully */ -export const zPostAppsByAppIdModelConfigResponse = z.record(z.string(), z.unknown()) +export const zPostAppsByAppIdModelConfigResponse = zSimpleResultResponse export const zPostAppsByAppIdNameBody = zAppNamePayload @@ -3760,133 +4472,139 @@ export const zPostAppsByAppIdSiteAccessTokenResetPath = z.object({ */ export const zPostAppsByAppIdSiteAccessTokenResetResponse = zAppSiteResponse +export const zDeleteAppsByAppIdStarPath = z.object({ + app_id: z.string(), +}) + +/** + * Success + */ +export const zDeleteAppsByAppIdStarResponse = zSimpleResultResponse + +export const zPostAppsByAppIdStarPath = z.object({ + app_id: z.string(), +}) + +/** + * Success + */ +export const zPostAppsByAppIdStarResponse = zSimpleResultResponse + export const zGetAppsByAppIdStatisticsAverageResponseTimePath = z.object({ app_id: z.string(), }) export const zGetAppsByAppIdStatisticsAverageResponseTimeQuery = z.object({ - end: z.string().nullish(), - start: z.string().nullish(), + end: z.string().optional(), + start: z.string().optional(), }) /** * Average response time statistics retrieved successfully */ -export const zGetAppsByAppIdStatisticsAverageResponseTimeResponse = z.array( - z.record(z.string(), z.unknown()), -) +export const zGetAppsByAppIdStatisticsAverageResponseTimeResponse + = zAverageResponseTimeStatisticResponse export const zGetAppsByAppIdStatisticsAverageSessionInteractionsPath = z.object({ app_id: z.string(), }) export const zGetAppsByAppIdStatisticsAverageSessionInteractionsQuery = z.object({ - end: z.string().nullish(), - start: z.string().nullish(), + end: z.string().optional(), + start: z.string().optional(), }) /** * Average session interaction statistics retrieved successfully */ -export const zGetAppsByAppIdStatisticsAverageSessionInteractionsResponse = z.array( - z.record(z.string(), z.unknown()), -) +export const zGetAppsByAppIdStatisticsAverageSessionInteractionsResponse + = zAverageSessionInteractionStatisticResponse export const zGetAppsByAppIdStatisticsDailyConversationsPath = z.object({ app_id: z.string(), }) export const zGetAppsByAppIdStatisticsDailyConversationsQuery = z.object({ - end: z.string().nullish(), - start: z.string().nullish(), + end: z.string().optional(), + start: z.string().optional(), }) /** * Daily conversation statistics retrieved successfully */ -export const zGetAppsByAppIdStatisticsDailyConversationsResponse = z.array( - z.record(z.string(), z.unknown()), -) +export const zGetAppsByAppIdStatisticsDailyConversationsResponse + = zDailyConversationStatisticResponse export const zGetAppsByAppIdStatisticsDailyEndUsersPath = z.object({ app_id: z.string(), }) export const zGetAppsByAppIdStatisticsDailyEndUsersQuery = z.object({ - end: z.string().nullish(), - start: z.string().nullish(), + end: z.string().optional(), + start: z.string().optional(), }) /** * Daily terminal statistics retrieved successfully */ -export const zGetAppsByAppIdStatisticsDailyEndUsersResponse = z.array( - z.record(z.string(), z.unknown()), -) +export const zGetAppsByAppIdStatisticsDailyEndUsersResponse = zDailyTerminalStatisticResponse export const zGetAppsByAppIdStatisticsDailyMessagesPath = z.object({ app_id: z.string(), }) export const zGetAppsByAppIdStatisticsDailyMessagesQuery = z.object({ - end: z.string().nullish(), - start: z.string().nullish(), + end: z.string().optional(), + start: z.string().optional(), }) /** * Daily message statistics retrieved successfully */ -export const zGetAppsByAppIdStatisticsDailyMessagesResponse = z.array( - z.record(z.string(), z.unknown()), -) +export const zGetAppsByAppIdStatisticsDailyMessagesResponse = zDailyMessageStatisticResponse export const zGetAppsByAppIdStatisticsTokenCostsPath = z.object({ app_id: z.string(), }) export const zGetAppsByAppIdStatisticsTokenCostsQuery = z.object({ - end: z.string().nullish(), - start: z.string().nullish(), + end: z.string().optional(), + start: z.string().optional(), }) /** * Daily token cost statistics retrieved successfully */ -export const zGetAppsByAppIdStatisticsTokenCostsResponse = z.array( - z.record(z.string(), z.unknown()), -) +export const zGetAppsByAppIdStatisticsTokenCostsResponse = zDailyTokenCostStatisticResponse export const zGetAppsByAppIdStatisticsTokensPerSecondPath = z.object({ app_id: z.string(), }) export const zGetAppsByAppIdStatisticsTokensPerSecondQuery = z.object({ - end: z.string().nullish(), - start: z.string().nullish(), + end: z.string().optional(), + start: z.string().optional(), }) /** * Tokens per second statistics retrieved successfully */ -export const zGetAppsByAppIdStatisticsTokensPerSecondResponse = z.array( - z.record(z.string(), z.unknown()), -) +export const zGetAppsByAppIdStatisticsTokensPerSecondResponse = zTokensPerSecondStatisticResponse export const zGetAppsByAppIdStatisticsUserSatisfactionRatePath = z.object({ app_id: z.string(), }) export const zGetAppsByAppIdStatisticsUserSatisfactionRateQuery = z.object({ - end: z.string().nullish(), - start: z.string().nullish(), + end: z.string().optional(), + start: z.string().optional(), }) /** * User satisfaction rate statistics retrieved successfully */ -export const zGetAppsByAppIdStatisticsUserSatisfactionRateResponse = z.array( - z.record(z.string(), z.unknown()), -) +export const zGetAppsByAppIdStatisticsUserSatisfactionRateResponse + = zUserSatisfactionRateStatisticResponse export const zPostAppsByAppIdTextToAudioBody = zTextToSpeechPayload @@ -3897,7 +4615,7 @@ export const zPostAppsByAppIdTextToAudioPath = z.object({ /** * Text to speech conversion successful */ -export const zPostAppsByAppIdTextToAudioResponse = z.record(z.string(), z.unknown()) +export const zPostAppsByAppIdTextToAudioResponse = zAudioBinaryResponse export const zGetAppsByAppIdTextToAudioVoicesPath = z.object({ app_id: z.string(), @@ -3910,7 +4628,7 @@ export const zGetAppsByAppIdTextToAudioVoicesQuery = z.object({ /** * TTS voices retrieved successfully */ -export const zGetAppsByAppIdTextToAudioVoicesResponse = z.array(z.record(z.string(), z.unknown())) +export const zGetAppsByAppIdTextToAudioVoicesResponse = zTextToSpeechVoiceListResponse export const zGetAppsByAppIdTracePath = z.object({ app_id: z.string(), @@ -3919,7 +4637,7 @@ export const zGetAppsByAppIdTracePath = z.object({ /** * Trace configuration retrieved successfully */ -export const zGetAppsByAppIdTraceResponse = z.record(z.string(), z.unknown()) +export const zGetAppsByAppIdTraceResponse = zAppTraceResponse export const zPostAppsByAppIdTraceBody = zAppTracePayload @@ -3932,16 +4650,18 @@ export const zPostAppsByAppIdTracePath = z.object({ */ export const zPostAppsByAppIdTraceResponse = zSimpleResultResponse -export const zDeleteAppsByAppIdTraceConfigBody = zTraceProviderQuery - export const zDeleteAppsByAppIdTraceConfigPath = z.object({ app_id: z.string(), }) +export const zDeleteAppsByAppIdTraceConfigQuery = z.object({ + tracing_provider: z.string(), +}) + /** * Tracing configuration deleted successfully */ -export const zDeleteAppsByAppIdTraceConfigResponse = z.record(z.string(), z.never()) +export const zDeleteAppsByAppIdTraceConfigResponse = z.void() export const zGetAppsByAppIdTraceConfigPath = z.object({ app_id: z.string(), @@ -3952,9 +4672,9 @@ export const zGetAppsByAppIdTraceConfigQuery = z.object({ }) /** - * Tracing configuration data + * Tracing configuration retrieved successfully */ -export const zGetAppsByAppIdTraceConfigResponse = z.record(z.string(), z.unknown()) +export const zGetAppsByAppIdTraceConfigResponse = zTraceAppConfigResponse export const zPatchAppsByAppIdTraceConfigBody = zTraceConfigPayload @@ -3963,9 +4683,9 @@ export const zPatchAppsByAppIdTraceConfigPath = z.object({ }) /** - * Success response + * Tracing configuration updated successfully */ -export const zPatchAppsByAppIdTraceConfigResponse = z.record(z.string(), z.unknown()) +export const zPatchAppsByAppIdTraceConfigResponse = zTraceAppConfigResponse export const zPostAppsByAppIdTraceConfigBody = zTraceConfigPayload @@ -3974,9 +4694,9 @@ export const zPostAppsByAppIdTraceConfigPath = z.object({ }) /** - * Created configuration data + * Tracing configuration created successfully */ -export const zPostAppsByAppIdTraceConfigResponse = z.record(z.string(), z.unknown()) +export const zPostAppsByAppIdTraceConfigResponse = zTraceAppConfigResponse export const zPostAppsByAppIdTriggerEnableBody = zParserEnable @@ -4003,15 +4723,17 @@ export const zGetAppsByAppIdWorkflowAppLogsPath = z.object({ }) export const zGetAppsByAppIdWorkflowAppLogsQuery = z.object({ - created_at__after: z.iso.datetime().nullish(), - created_at__before: z.iso.datetime().nullish(), - created_by_account: z.string().nullish(), - created_by_end_user_session_id: z.string().nullish(), + created_at__after: z.iso.datetime().optional(), + created_at__before: z.iso.datetime().optional(), + created_by_account: z.string().optional(), + created_by_end_user_session_id: z.string().optional(), detail: z.boolean().optional().default(false), - keyword: z.string().nullish(), + keyword: z.string().optional(), limit: z.int().gte(1).lte(100).optional().default(20), page: z.int().gte(1).lte(99999).optional().default(1), - status: z.string().nullish(), + status: z + .enum(['failed', 'partial-succeeded', 'paused', 'running', 'scheduled', 'stopped', 'succeeded']) + .optional(), }) /** @@ -4024,15 +4746,17 @@ export const zGetAppsByAppIdWorkflowArchivedLogsPath = z.object({ }) export const zGetAppsByAppIdWorkflowArchivedLogsQuery = z.object({ - created_at__after: z.iso.datetime().nullish(), - created_at__before: z.iso.datetime().nullish(), - created_by_account: z.string().nullish(), - created_by_end_user_session_id: z.string().nullish(), + created_at__after: z.iso.datetime().optional(), + created_at__before: z.iso.datetime().optional(), + created_by_account: z.string().optional(), + created_by_end_user_session_id: z.string().optional(), detail: z.boolean().optional().default(false), - keyword: z.string().nullish(), + keyword: z.string().optional(), limit: z.int().gte(1).lte(100).optional().default(20), page: z.int().gte(1).lte(99999).optional().default(1), - status: z.string().nullish(), + status: z + .enum(['failed', 'partial-succeeded', 'paused', 'running', 'scheduled', 'stopped', 'succeeded']) + .optional(), }) /** @@ -4112,14 +4836,14 @@ export const zGetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsPath = z.object({ export const zGetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponse = zWorkflowRunNodeExecutionListResponse -export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPath +export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesPath = z.object({ app_id: z.string(), node_id: z.string(), workflow_run_id: z.string(), }) -export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesQuery +export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesQuery = z.object({ node_execution_id: z.string().optional(), path: z.string().optional().default('.'), @@ -4128,36 +4852,17 @@ export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspa /** * Listing returned */ -export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesResponse - = zWorkspaceListResponse +export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponse + = zSandboxListResponse -export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadPath +export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadPath = z.object({ app_id: z.string(), node_id: z.string(), workflow_run_id: z.string(), }) -export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadQuery - = z.object({ - node_execution_id: z.string().optional(), - path: z.string().min(1), - }) - -/** - * File bytes - */ -export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadResponse - = z.custom() - -export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewPath - = z.object({ - app_id: z.string(), - node_id: z.string(), - workflow_run_id: z.string(), - }) - -export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewQuery +export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadQuery = z.object({ node_execution_id: z.string().optional(), path: z.string().min(1), @@ -4166,8 +4871,24 @@ export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspa /** * Preview returned */ -export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewResponse - = zWorkspacePreviewResponse +export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponse + = zSandboxReadResponse + +export const zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadBody + = zWorkflowAgentSandboxUploadPayload + +export const zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadPath + = z.object({ + app_id: z.string(), + node_id: z.string(), + workflow_run_id: z.string(), + }) + +/** + * Uploaded + */ +export const zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponse + = zSandboxUploadResponse export const zGetAppsByAppIdWorkflowCommentsPath = z.object({ app_id: z.string(), @@ -4207,7 +4928,7 @@ export const zDeleteAppsByAppIdWorkflowCommentsByCommentIdPath = z.object({ /** * Comment deleted successfully */ -export const zDeleteAppsByAppIdWorkflowCommentsByCommentIdResponse = z.record(z.string(), z.never()) +export const zDeleteAppsByAppIdWorkflowCommentsByCommentIdResponse = z.void() export const zGetAppsByAppIdWorkflowCommentsByCommentIdPath = z.object({ app_id: z.string(), @@ -4253,10 +4974,7 @@ export const zDeleteAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdPath = /** * Reply deleted successfully */ -export const zDeleteAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdResponse = z.record( - z.string(), - z.never(), -) +export const zDeleteAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdResponse = z.void() export const zPutAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdBody = zWorkflowCommentReplyPayload @@ -4288,65 +5006,60 @@ export const zGetAppsByAppIdWorkflowStatisticsAverageAppInteractionsPath = z.obj }) export const zGetAppsByAppIdWorkflowStatisticsAverageAppInteractionsQuery = z.object({ - end: z.string().nullish(), - start: z.string().nullish(), + end: z.string().optional(), + start: z.string().optional(), }) /** * Average app interaction statistics retrieved successfully */ -export const zGetAppsByAppIdWorkflowStatisticsAverageAppInteractionsResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetAppsByAppIdWorkflowStatisticsAverageAppInteractionsResponse + = zWorkflowAverageAppInteractionStatisticResponse export const zGetAppsByAppIdWorkflowStatisticsDailyConversationsPath = z.object({ app_id: z.string(), }) export const zGetAppsByAppIdWorkflowStatisticsDailyConversationsQuery = z.object({ - end: z.string().nullish(), - start: z.string().nullish(), + end: z.string().optional(), + start: z.string().optional(), }) /** * Daily runs statistics retrieved successfully */ -export const zGetAppsByAppIdWorkflowStatisticsDailyConversationsResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetAppsByAppIdWorkflowStatisticsDailyConversationsResponse + = zWorkflowDailyRunsStatisticResponse export const zGetAppsByAppIdWorkflowStatisticsDailyTerminalsPath = z.object({ app_id: z.string(), }) export const zGetAppsByAppIdWorkflowStatisticsDailyTerminalsQuery = z.object({ - end: z.string().nullish(), - start: z.string().nullish(), + end: z.string().optional(), + start: z.string().optional(), }) /** * Daily terminals statistics retrieved successfully */ -export const zGetAppsByAppIdWorkflowStatisticsDailyTerminalsResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetAppsByAppIdWorkflowStatisticsDailyTerminalsResponse + = zWorkflowDailyTerminalsStatisticResponse export const zGetAppsByAppIdWorkflowStatisticsTokenCostsPath = z.object({ app_id: z.string(), }) export const zGetAppsByAppIdWorkflowStatisticsTokenCostsQuery = z.object({ - end: z.string().nullish(), - start: z.string().nullish(), + end: z.string().optional(), + start: z.string().optional(), }) /** * Daily token cost statistics retrieved successfully */ -export const zGetAppsByAppIdWorkflowStatisticsTokenCostsResponse = z.record(z.string(), z.unknown()) +export const zGetAppsByAppIdWorkflowStatisticsTokenCostsResponse + = zWorkflowDailyTokenCostStatisticResponse export const zGetAppsByAppIdWorkflowsPath = z.object({ app_id: z.string(), @@ -4356,7 +5069,7 @@ export const zGetAppsByAppIdWorkflowsQuery = z.object({ limit: z.int().gte(1).lte(100).optional().default(10), named_only: z.boolean().optional().default(false), page: z.int().gte(1).lte(99999).optional().default(1), - user_id: z.string().nullish(), + user_id: z.string().optional(), }) /** @@ -4371,10 +5084,8 @@ export const zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsPath = z.object( /** * Default block configurations retrieved successfully */ -export const zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsResponse + = zDefaultBlockConfigsResponse export const zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypePath = z.object({ app_id: z.string(), @@ -4382,16 +5093,14 @@ export const zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypePath }) export const zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeQuery = z.object({ - q: z.string().nullish(), + q: z.string().optional(), }) /** * Default block configuration retrieved successfully */ -export const zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponse + = zDefaultBlockConfigResponse export const zGetAppsByAppIdWorkflowsDraftPath = z.object({ app_id: z.string(), @@ -4432,10 +5141,7 @@ export const zPostAppsByAppIdWorkflowsDraftConversationVariablesPath = z.object( /** * Conversation variables updated successfully */ -export const zPostAppsByAppIdWorkflowsDraftConversationVariablesResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostAppsByAppIdWorkflowsDraftConversationVariablesResponse = zSimpleResultResponse export const zGetAppsByAppIdWorkflowsDraftEnvironmentVariablesPath = z.object({ app_id: z.string(), @@ -4444,10 +5150,8 @@ export const zGetAppsByAppIdWorkflowsDraftEnvironmentVariablesPath = z.object({ /** * Environment variables retrieved successfully */ -export const zGetAppsByAppIdWorkflowsDraftEnvironmentVariablesResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetAppsByAppIdWorkflowsDraftEnvironmentVariablesResponse + = zEnvironmentVariableListResponse export const zPostAppsByAppIdWorkflowsDraftEnvironmentVariablesBody = zEnvironmentVariableUpdatePayload @@ -4459,10 +5163,7 @@ export const zPostAppsByAppIdWorkflowsDraftEnvironmentVariablesPath = z.object({ /** * Environment variables updated successfully */ -export const zPostAppsByAppIdWorkflowsDraftEnvironmentVariablesResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostAppsByAppIdWorkflowsDraftEnvironmentVariablesResponse = zSimpleResultResponse export const zPostAppsByAppIdWorkflowsDraftFeaturesBody = zWorkflowFeaturesPayload @@ -4484,12 +5185,10 @@ export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTestPa }) /** - * Success + * Human input delivery test result */ -export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTestResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTestResponse + = zEmptyObjectResponse export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewBody = zHumanInputFormPreviewPayload @@ -4500,12 +5199,10 @@ export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewPat }) /** - * Success + * Human input form preview */ -export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponse + = zHumanInputFormPreviewResponse export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunBody = zHumanInputFormSubmitPayload @@ -4516,12 +5213,10 @@ export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunPath = }) /** - * Success + * Human input form submission result */ -export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunResponse + = zHumanInputFormSubmitResponse export const zPostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunBody = zIterationNodeRunPayload @@ -4533,10 +5228,7 @@ export const zPostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunPath = z.obj /** * Workflow iteration node run started successfully */ -export const zPostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunResponse = zGeneratedAppResponse export const zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunBody = zLoopNodeRunPayload @@ -4548,10 +5240,7 @@ export const zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunPath = z.object({ /** * Workflow loop node run started successfully */ -export const zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponse = zGeneratedAppResponse export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerPath = z.object({ app_id: z.string(), @@ -4662,10 +5351,7 @@ export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunPath = z.objec /** * Trigger event received and node executed successfully */ -export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunResponse = zGeneratedAppResponse export const zDeleteAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesPath = z.object({ app_id: z.string(), @@ -4675,10 +5361,7 @@ export const zDeleteAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesPath = z.obje /** * Node variables deleted successfully */ -export const zDeleteAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesResponse = z.record( - z.string(), - z.never(), -) +export const zDeleteAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesResponse = z.void() export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesPath = z.object({ app_id: z.string(), @@ -4700,7 +5383,7 @@ export const zPostAppsByAppIdWorkflowsDraftRunPath = z.object({ /** * Draft workflow run started successfully */ -export const zPostAppsByAppIdWorkflowsDraftRunResponse = z.record(z.string(), z.unknown()) +export const zPostAppsByAppIdWorkflowsDraftRunResponse = zGeneratedAppResponse export const zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsPath = z.object({ app_id: z.string(), @@ -4720,10 +5403,8 @@ export const zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsEventsPath = z.o /** * Workflow run node output event stream */ -export const zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsEventsResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsEventsResponse + = zEventStreamResponse export const zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdPath = z.object({ app_id: z.string(), @@ -4768,7 +5449,7 @@ export const zPostAppsByAppIdWorkflowsDraftTriggerRunPath = z.object({ /** * Trigger event received and workflow executed successfully */ -export const zPostAppsByAppIdWorkflowsDraftTriggerRunResponse = z.record(z.string(), z.unknown()) +export const zPostAppsByAppIdWorkflowsDraftTriggerRunResponse = zGeneratedAppResponse export const zPostAppsByAppIdWorkflowsDraftTriggerRunAllBody = zDraftWorkflowTriggerRunAllPayload @@ -4779,7 +5460,7 @@ export const zPostAppsByAppIdWorkflowsDraftTriggerRunAllPath = z.object({ /** * Workflow executed successfully */ -export const zPostAppsByAppIdWorkflowsDraftTriggerRunAllResponse = z.record(z.string(), z.unknown()) +export const zPostAppsByAppIdWorkflowsDraftTriggerRunAllResponse = zGeneratedAppResponse export const zDeleteAppsByAppIdWorkflowsDraftVariablesPath = z.object({ app_id: z.string(), @@ -4788,7 +5469,7 @@ export const zDeleteAppsByAppIdWorkflowsDraftVariablesPath = z.object({ /** * Workflow variables deleted successfully */ -export const zDeleteAppsByAppIdWorkflowsDraftVariablesResponse = z.record(z.string(), z.never()) +export const zDeleteAppsByAppIdWorkflowsDraftVariablesResponse = z.void() export const zGetAppsByAppIdWorkflowsDraftVariablesPath = z.object({ app_id: z.string(), @@ -4812,10 +5493,7 @@ export const zDeleteAppsByAppIdWorkflowsDraftVariablesByVariableIdPath = z.objec /** * Variable deleted successfully */ -export const zDeleteAppsByAppIdWorkflowsDraftVariablesByVariableIdResponse = z.record( - z.string(), - z.never(), -) +export const zDeleteAppsByAppIdWorkflowsDraftVariablesByVariableIdResponse = z.void() export const zGetAppsByAppIdWorkflowsDraftVariablesByVariableIdPath = z.object({ app_id: z.string(), @@ -4847,7 +5525,7 @@ export const zPutAppsByAppIdWorkflowsDraftVariablesByVariableIdResetPath = z.obj export const zPutAppsByAppIdWorkflowsDraftVariablesByVariableIdResetResponse = z.union([ zWorkflowDraftVariable, - z.record(z.string(), z.never()), + z.void(), ]) export const zGetAppsByAppIdWorkflowsPublishPath = z.object({ @@ -4866,9 +5544,9 @@ export const zPostAppsByAppIdWorkflowsPublishPath = z.object({ }) /** - * Success + * Workflow published successfully */ -export const zPostAppsByAppIdWorkflowsPublishResponse = z.record(z.string(), z.unknown()) +export const zPostAppsByAppIdWorkflowsPublishResponse = zWorkflowPublishResponse export const zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsPath = z.object({ app_id: z.string(), @@ -4889,10 +5567,8 @@ export const zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEventsPath = /** * Workflow run node output event stream */ -export const zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEventsResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEventsResponse + = zEventStreamResponse export const zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdPath = z.object({ app_id: z.string(), @@ -4925,9 +5601,7 @@ export const zGetAppsByAppIdWorkflowsTriggersWebhookPath = z.object({ }) export const zGetAppsByAppIdWorkflowsTriggersWebhookQuery = z.object({ - credential_id: z.string().nullish(), - datasource_type: z.string(), - inputs: z.string(), + node_id: z.string(), }) /** @@ -4941,9 +5615,9 @@ export const zDeleteAppsByAppIdWorkflowsByWorkflowIdPath = z.object({ }) /** - * Success + * Workflow deleted successfully */ -export const zDeleteAppsByAppIdWorkflowsByWorkflowIdResponse = z.record(z.string(), z.unknown()) +export const zDeleteAppsByAppIdWorkflowsByWorkflowIdResponse = z.void() export const zPatchAppsByAppIdWorkflowsByWorkflowIdBody = zWorkflowUpdatePayload @@ -4965,10 +5639,7 @@ export const zPostAppsByAppIdWorkflowsByWorkflowIdRestorePath = z.object({ /** * Workflow restored successfully */ -export const zPostAppsByAppIdWorkflowsByWorkflowIdRestoreResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostAppsByAppIdWorkflowsByWorkflowIdRestoreResponse = zWorkflowRestoreResponse export const zGetAppsByResourceIdApiKeysPath = z.object({ resource_id: z.string(), @@ -4996,7 +5667,7 @@ export const zDeleteAppsByResourceIdApiKeysByApiKeyIdPath = z.object({ /** * API key deleted successfully */ -export const zDeleteAppsByResourceIdApiKeysByApiKeyIdResponse = z.record(z.string(), z.never()) +export const zDeleteAppsByResourceIdApiKeysByApiKeyIdResponse = z.void() export const zGetAppsByServerIdServerRefreshPath = z.object({ server_id: z.string(), diff --git a/packages/contracts/generated/api/console/auth/orpc.gen.ts b/packages/contracts/generated/api/console/auth/orpc.gen.ts index bc2f9fc410b..7acf024d4e4 100644 --- a/packages/contracts/generated/api/console/auth/orpc.gen.ts +++ b/packages/contracts/generated/api/console/auth/orpc.gen.ts @@ -30,16 +30,8 @@ import { zPostAuthPluginDatasourceByProviderIdUpdateResponse, } from './zod.gen' -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const get = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAuthPluginDatasourceDefaultList', @@ -52,16 +44,8 @@ export const defaultList = { get, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const get2 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAuthPluginDatasourceList', @@ -85,16 +69,8 @@ export const delete_ = oc .input(z.object({ params: zDeleteAuthPluginDatasourceByProviderIdCustomClientPath })) .output(zDeleteAuthPluginDatasourceByProviderIdCustomClientResponse) -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAuthPluginDatasourceByProviderIdCustomClient', @@ -154,20 +130,13 @@ export const delete2 = { post: post3, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post4 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAuthPluginDatasourceByProviderIdUpdate', path: '/auth/plugin/datasource/{provider_id}/update', + successStatus: 201, tags: ['console'], }) .input( @@ -202,16 +171,8 @@ export const updateName = { post: post5, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const get3 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAuthPluginDatasourceByProviderId', @@ -221,16 +182,8 @@ export const get3 = oc .input(z.object({ params: zGetAuthPluginDatasourceByProviderIdPath })) .output(zGetAuthPluginDatasourceByProviderIdResponse) -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post6 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAuthPluginDatasourceByProviderId', diff --git a/packages/contracts/generated/api/console/auth/types.gen.ts b/packages/contracts/generated/api/console/auth/types.gen.ts index 73b5064fa99..99a06128e14 100644 --- a/packages/contracts/generated/api/console/auth/types.gen.ts +++ b/packages/contracts/generated/api/console/auth/types.gen.ts @@ -4,6 +4,10 @@ export type ClientOptions = { baseUrl: `${string}://${string}/console/api` | (string & {}) } +export type DatasourceCredentialsResponse = { + result: unknown +} + export type DatasourceCredentialPayload = { credentials: { [key: string]: unknown @@ -51,9 +55,7 @@ export type GetAuthPluginDatasourceDefaultListData = { } export type GetAuthPluginDatasourceDefaultListResponses = { - 200: { - [key: string]: unknown - } + 200: DatasourceCredentialsResponse } export type GetAuthPluginDatasourceDefaultListResponse @@ -67,9 +69,7 @@ export type GetAuthPluginDatasourceListData = { } export type GetAuthPluginDatasourceListResponses = { - 200: { - [key: string]: unknown - } + 200: DatasourceCredentialsResponse } export type GetAuthPluginDatasourceListResponse @@ -85,9 +85,7 @@ export type GetAuthPluginDatasourceByProviderIdData = { } export type GetAuthPluginDatasourceByProviderIdResponses = { - 200: { - [key: string]: unknown - } + 200: DatasourceCredentialsResponse } export type GetAuthPluginDatasourceByProviderIdResponse @@ -103,9 +101,7 @@ export type PostAuthPluginDatasourceByProviderIdData = { } export type PostAuthPluginDatasourceByProviderIdResponses = { - 200: { - [key: string]: unknown - } + 200: SimpleResultResponse } export type PostAuthPluginDatasourceByProviderIdResponse @@ -137,9 +133,7 @@ export type PostAuthPluginDatasourceByProviderIdCustomClientData = { } export type PostAuthPluginDatasourceByProviderIdCustomClientResponses = { - 200: { - [key: string]: unknown - } + 200: SimpleResultResponse } export type PostAuthPluginDatasourceByProviderIdCustomClientResponse @@ -187,9 +181,7 @@ export type PostAuthPluginDatasourceByProviderIdUpdateData = { } export type PostAuthPluginDatasourceByProviderIdUpdateResponses = { - 200: { - [key: string]: unknown - } + 201: SimpleResultResponse } export type PostAuthPluginDatasourceByProviderIdUpdateResponse diff --git a/packages/contracts/generated/api/console/auth/zod.gen.ts b/packages/contracts/generated/api/console/auth/zod.gen.ts index 8248a9d858f..3a0d3f38ff4 100644 --- a/packages/contracts/generated/api/console/auth/zod.gen.ts +++ b/packages/contracts/generated/api/console/auth/zod.gen.ts @@ -2,6 +2,13 @@ import * as z from 'zod' +/** + * DatasourceCredentialsResponse + */ +export const zDatasourceCredentialsResponse = z.object({ + result: z.unknown(), +}) + /** * DatasourceCredentialPayload */ @@ -59,12 +66,12 @@ export const zDatasourceUpdateNamePayload = z.object({ /** * Success */ -export const zGetAuthPluginDatasourceDefaultListResponse = z.record(z.string(), z.unknown()) +export const zGetAuthPluginDatasourceDefaultListResponse = zDatasourceCredentialsResponse /** * Success */ -export const zGetAuthPluginDatasourceListResponse = z.record(z.string(), z.unknown()) +export const zGetAuthPluginDatasourceListResponse = zDatasourceCredentialsResponse export const zGetAuthPluginDatasourceByProviderIdPath = z.object({ provider_id: z.string(), @@ -73,7 +80,7 @@ export const zGetAuthPluginDatasourceByProviderIdPath = z.object({ /** * Success */ -export const zGetAuthPluginDatasourceByProviderIdResponse = z.record(z.string(), z.unknown()) +export const zGetAuthPluginDatasourceByProviderIdResponse = zDatasourceCredentialsResponse export const zPostAuthPluginDatasourceByProviderIdBody = zDatasourceCredentialPayload @@ -84,7 +91,7 @@ export const zPostAuthPluginDatasourceByProviderIdPath = z.object({ /** * Success */ -export const zPostAuthPluginDatasourceByProviderIdResponse = z.record(z.string(), z.unknown()) +export const zPostAuthPluginDatasourceByProviderIdResponse = zSimpleResultResponse export const zDeleteAuthPluginDatasourceByProviderIdCustomClientPath = z.object({ provider_id: z.string(), @@ -104,10 +111,7 @@ export const zPostAuthPluginDatasourceByProviderIdCustomClientPath = z.object({ /** * Success */ -export const zPostAuthPluginDatasourceByProviderIdCustomClientResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostAuthPluginDatasourceByProviderIdCustomClientResponse = zSimpleResultResponse export const zPostAuthPluginDatasourceByProviderIdDefaultBody = zDatasourceDefaultPayload @@ -140,7 +144,7 @@ export const zPostAuthPluginDatasourceByProviderIdUpdatePath = z.object({ /** * Success */ -export const zPostAuthPluginDatasourceByProviderIdUpdateResponse = z.record(z.string(), z.unknown()) +export const zPostAuthPluginDatasourceByProviderIdUpdateResponse = zSimpleResultResponse export const zPostAuthPluginDatasourceByProviderIdUpdateNameBody = zDatasourceUpdateNamePayload diff --git a/packages/contracts/generated/api/console/billing/orpc.gen.ts b/packages/contracts/generated/api/console/billing/orpc.gen.ts index 501f8a4e46e..9abd8e8fe1a 100644 --- a/packages/contracts/generated/api/console/billing/orpc.gen.ts +++ b/packages/contracts/generated/api/console/billing/orpc.gen.ts @@ -5,22 +5,15 @@ import * as z from 'zod' import { zGetBillingInvoicesResponse, + zGetBillingSubscriptionQuery, zGetBillingSubscriptionResponse, zPutBillingPartnersByPartnerKeyTenantsBody, zPutBillingPartnersByPartnerKeyTenantsPath, zPutBillingPartnersByPartnerKeyTenantsResponse, } from './zod.gen' -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const get = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getBillingInvoices', @@ -35,16 +28,10 @@ export const invoices = { /** * Sync partner tenants bindings - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const put = oc .route({ - deprecated: true, - description: - 'Sync partner tenants bindings\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Sync partner tenants bindings', inputStructure: 'detailed', method: 'PUT', operationId: 'putBillingPartnersByPartnerKeyTenants', @@ -71,22 +58,15 @@ export const partners = { byPartnerKey, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const get2 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getBillingSubscription', path: '/billing/subscription', tags: ['console'], }) + .input(z.object({ query: zGetBillingSubscriptionQuery })) .output(zGetBillingSubscriptionResponse) export const subscription = { diff --git a/packages/contracts/generated/api/console/billing/types.gen.ts b/packages/contracts/generated/api/console/billing/types.gen.ts index 7a9880c03e2..ffcc9835b20 100644 --- a/packages/contracts/generated/api/console/billing/types.gen.ts +++ b/packages/contracts/generated/api/console/billing/types.gen.ts @@ -4,6 +4,10 @@ export type ClientOptions = { baseUrl: `${string}://${string}/console/api` | (string & {}) } +export type BillingResponse = { + [key: string]: unknown +} + export type PartnerTenantsPayload = { click_id: string } @@ -16,9 +20,7 @@ export type GetBillingInvoicesData = { } export type GetBillingInvoicesResponses = { - 200: { - [key: string]: unknown - } + 200: BillingResponse } export type GetBillingInvoicesResponse @@ -34,18 +36,11 @@ export type PutBillingPartnersByPartnerKeyTenantsData = { } export type PutBillingPartnersByPartnerKeyTenantsErrors = { - 400: { - [key: string]: unknown - } + 400: unknown } -export type PutBillingPartnersByPartnerKeyTenantsError - = PutBillingPartnersByPartnerKeyTenantsErrors[keyof PutBillingPartnersByPartnerKeyTenantsErrors] - export type PutBillingPartnersByPartnerKeyTenantsResponses = { - 200: { - [key: string]: unknown - } + 200: BillingResponse } export type PutBillingPartnersByPartnerKeyTenantsResponse @@ -54,14 +49,15 @@ export type PutBillingPartnersByPartnerKeyTenantsResponse export type GetBillingSubscriptionData = { body?: never path?: never - query?: never + query: { + interval: 'month' | 'year' + plan: 'professional' | 'team' + } url: '/billing/subscription' } export type GetBillingSubscriptionResponses = { - 200: { - [key: string]: unknown - } + 200: BillingResponse } export type GetBillingSubscriptionResponse diff --git a/packages/contracts/generated/api/console/billing/zod.gen.ts b/packages/contracts/generated/api/console/billing/zod.gen.ts index 7b5412c7f4e..3292612c048 100644 --- a/packages/contracts/generated/api/console/billing/zod.gen.ts +++ b/packages/contracts/generated/api/console/billing/zod.gen.ts @@ -2,6 +2,11 @@ import * as z from 'zod' +/** + * BillingResponse + */ +export const zBillingResponse = z.record(z.string(), z.unknown()) + /** * PartnerTenantsPayload */ @@ -12,7 +17,7 @@ export const zPartnerTenantsPayload = z.object({ /** * Success */ -export const zGetBillingInvoicesResponse = z.record(z.string(), z.unknown()) +export const zGetBillingInvoicesResponse = zBillingResponse export const zPutBillingPartnersByPartnerKeyTenantsBody = zPartnerTenantsPayload @@ -23,9 +28,14 @@ export const zPutBillingPartnersByPartnerKeyTenantsPath = z.object({ /** * Tenants synced to partner successfully */ -export const zPutBillingPartnersByPartnerKeyTenantsResponse = z.record(z.string(), z.unknown()) +export const zPutBillingPartnersByPartnerKeyTenantsResponse = zBillingResponse + +export const zGetBillingSubscriptionQuery = z.object({ + interval: z.enum(['month', 'year']), + plan: z.enum(['professional', 'team']), +}) /** * Success */ -export const zGetBillingSubscriptionResponse = z.record(z.string(), z.unknown()) +export const zGetBillingSubscriptionResponse = zBillingResponse diff --git a/packages/contracts/generated/api/console/code-based-extension/orpc.gen.ts b/packages/contracts/generated/api/console/code-based-extension/orpc.gen.ts index b3baddafd4e..f60e6e2c8bc 100644 --- a/packages/contracts/generated/api/console/code-based-extension/orpc.gen.ts +++ b/packages/contracts/generated/api/console/code-based-extension/orpc.gen.ts @@ -17,7 +17,7 @@ export const get = oc path: '/code-based-extension', tags: ['console'], }) - .input(z.object({ query: zGetCodeBasedExtensionQuery.optional() })) + .input(z.object({ query: zGetCodeBasedExtensionQuery })) .output(zGetCodeBasedExtensionResponse) export const codeBasedExtension = { diff --git a/packages/contracts/generated/api/console/code-based-extension/types.gen.ts b/packages/contracts/generated/api/console/code-based-extension/types.gen.ts index 85d224f8d1f..f6abfd3212e 100644 --- a/packages/contracts/generated/api/console/code-based-extension/types.gen.ts +++ b/packages/contracts/generated/api/console/code-based-extension/types.gen.ts @@ -12,8 +12,8 @@ export type CodeBasedExtensionResponse = { export type GetCodeBasedExtensionData = { body?: never path?: never - query?: { - module?: string + query: { + module: string } url: '/code-based-extension' } diff --git a/packages/contracts/generated/api/console/code-based-extension/zod.gen.ts b/packages/contracts/generated/api/console/code-based-extension/zod.gen.ts index 3cd520cb97d..4be74576243 100644 --- a/packages/contracts/generated/api/console/code-based-extension/zod.gen.ts +++ b/packages/contracts/generated/api/console/code-based-extension/zod.gen.ts @@ -11,7 +11,7 @@ export const zCodeBasedExtensionResponse = z.object({ }) export const zGetCodeBasedExtensionQuery = z.object({ - module: z.string().optional(), + module: z.string(), }) /** diff --git a/packages/contracts/generated/api/console/compliance/orpc.gen.ts b/packages/contracts/generated/api/console/compliance/orpc.gen.ts index ec7a9be60fa..e68c87e7eba 100644 --- a/packages/contracts/generated/api/console/compliance/orpc.gen.ts +++ b/packages/contracts/generated/api/console/compliance/orpc.gen.ts @@ -7,16 +7,10 @@ import { zGetComplianceDownloadQuery, zGetComplianceDownloadResponse } from './z /** * Get compliance document download link - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get = oc .route({ - deprecated: true, - description: - 'Get compliance document download link\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get compliance document download link', inputStructure: 'detailed', method: 'GET', operationId: 'getComplianceDownload', diff --git a/packages/contracts/generated/api/console/compliance/types.gen.ts b/packages/contracts/generated/api/console/compliance/types.gen.ts index 12ab2a82a8a..71ffbba58de 100644 --- a/packages/contracts/generated/api/console/compliance/types.gen.ts +++ b/packages/contracts/generated/api/console/compliance/types.gen.ts @@ -4,6 +4,10 @@ export type ClientOptions = { baseUrl: `${string}://${string}/console/api` | (string & {}) } +export type ComplianceDownloadResponse = { + [key: string]: unknown +} + export type GetComplianceDownloadData = { body?: never path?: never @@ -14,9 +18,7 @@ export type GetComplianceDownloadData = { } export type GetComplianceDownloadResponses = { - 200: { - [key: string]: unknown - } + 200: ComplianceDownloadResponse } export type GetComplianceDownloadResponse diff --git a/packages/contracts/generated/api/console/compliance/zod.gen.ts b/packages/contracts/generated/api/console/compliance/zod.gen.ts index 2d42e75fbcf..167051fee89 100644 --- a/packages/contracts/generated/api/console/compliance/zod.gen.ts +++ b/packages/contracts/generated/api/console/compliance/zod.gen.ts @@ -2,6 +2,11 @@ import * as z from 'zod' +/** + * ComplianceDownloadResponse + */ +export const zComplianceDownloadResponse = z.record(z.string(), z.unknown()) + export const zGetComplianceDownloadQuery = z.object({ doc_name: z.string(), }) @@ -9,4 +14,4 @@ export const zGetComplianceDownloadQuery = z.object({ /** * Success */ -export const zGetComplianceDownloadResponse = z.record(z.string(), z.unknown()) +export const zGetComplianceDownloadResponse = zComplianceDownloadResponse diff --git a/packages/contracts/generated/api/console/data-source/types.gen.ts b/packages/contracts/generated/api/console/data-source/types.gen.ts index 6c2ef8db583..a065af49001 100644 --- a/packages/contracts/generated/api/console/data-source/types.gen.ts +++ b/packages/contracts/generated/api/console/data-source/types.gen.ts @@ -19,7 +19,7 @@ export type DataSourceIntegrateResponse = { is_bound: boolean link: string provider: string - source_info: DataSourceIntegrateWorkspaceResponse + source_info: DataSourceIntegrateWorkspaceResponse | null } export type DataSourceIntegrateWorkspaceResponse = { @@ -31,7 +31,7 @@ export type DataSourceIntegrateWorkspaceResponse = { } export type DataSourceIntegratePageResponse = { - page_icon: DataSourceIntegrateIconResponse + page_icon: DataSourceIntegrateIconResponse | null page_id: string page_name: string parent_id: string diff --git a/packages/contracts/generated/api/console/data-source/zod.gen.ts b/packages/contracts/generated/api/console/data-source/zod.gen.ts index 1511f3de868..e5cf1735c3f 100644 --- a/packages/contracts/generated/api/console/data-source/zod.gen.ts +++ b/packages/contracts/generated/api/console/data-source/zod.gen.ts @@ -22,7 +22,7 @@ export const zDataSourceIntegrateIconResponse = z.object({ * DataSourceIntegratePageResponse */ export const zDataSourceIntegratePageResponse = z.object({ - page_icon: zDataSourceIntegrateIconResponse, + page_icon: zDataSourceIntegrateIconResponse.nullable(), page_id: z.string(), page_name: z.string(), parent_id: z.string(), @@ -50,7 +50,7 @@ export const zDataSourceIntegrateResponse = z.object({ is_bound: z.boolean(), link: z.string(), provider: z.string(), - source_info: zDataSourceIntegrateWorkspaceResponse, + source_info: zDataSourceIntegrateWorkspaceResponse.nullable(), }) /** diff --git a/packages/contracts/generated/api/console/datasets/orpc.gen.ts b/packages/contracts/generated/api/console/datasets/orpc.gen.ts index 13ce55e275e..a8b096168bb 100644 --- a/packages/contracts/generated/api/console/datasets/orpc.gen.ts +++ b/packages/contracts/generated/api/console/datasets/orpc.gen.ts @@ -295,16 +295,10 @@ export const batchImportStatus = { /** * Create external knowledge dataset - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post3 = oc .route({ - deprecated: true, - description: - 'Create external knowledge dataset\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Create external knowledge dataset', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsExternal', @@ -352,16 +346,10 @@ export const delete2 = oc /** * Get external knowledge API template details - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get5 = oc .route({ - deprecated: true, - description: - 'Get external knowledge API template details\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get external knowledge API template details', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsExternalKnowledgeApiByExternalKnowledgeApiId', @@ -371,16 +359,8 @@ export const get5 = oc .input(z.object({ params: zGetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdPath })) .output(zGetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponse) -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const patch = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchDatasetsExternalKnowledgeApiByExternalKnowledgeApiId', @@ -404,16 +384,10 @@ export const byExternalKnowledgeApiId = { /** * Get external knowledge API templates - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get6 = oc .route({ - deprecated: true, - description: - 'Get external knowledge API templates\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get external knowledge API templates', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsExternalKnowledgeApi', @@ -423,20 +397,13 @@ export const get6 = oc .input(z.object({ query: zGetDatasetsExternalKnowledgeApiQuery.optional() })) .output(zGetDatasetsExternalKnowledgeApiResponse) -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post4 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsExternalKnowledgeApi', path: '/datasets/external-knowledge-api', + successStatus: 201, tags: ['console'], }) .input(z.object({ body: zPostDatasetsExternalKnowledgeApiBody })) @@ -450,16 +417,10 @@ export const externalKnowledgeApi = { /** * Estimate dataset indexing cost - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post5 = oc .route({ - deprecated: true, - description: - 'Estimate dataset indexing cost\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Estimate dataset indexing cost', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsIndexingEstimate', @@ -475,16 +436,10 @@ export const indexingEstimate = { /** * Initialize dataset with documents - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post6 = oc .route({ - deprecated: true, - description: - 'Initialize dataset with documents\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Initialize dataset with documents', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsInit', @@ -517,16 +472,8 @@ export const metadata = { builtIn, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post7 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsNotionIndexingEstimate', @@ -542,16 +489,10 @@ export const notionIndexingEstimate = { /** * Get dataset document processing rules - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get8 = oc .route({ - deprecated: true, - description: - 'Get dataset document processing rules\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get dataset document processing rules', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsProcessRule', @@ -641,16 +582,8 @@ export const autoDisableLogs = { get: get11, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const get12 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdBatchByBatchIndexingEstimate', @@ -692,16 +625,10 @@ export const batch = { * Stream a ZIP archive containing the requested uploaded documents * * Download selected dataset documents as a single ZIP archive (upload-file only) - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post9 = oc .route({ - deprecated: true, - description: - 'Download selected dataset documents as a single ZIP archive (upload-file only)\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Download selected dataset documents as a single ZIP archive (upload-file only)', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdDocumentsDownloadZip', @@ -817,16 +744,10 @@ export const download = { /** * Estimate document indexing cost - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get15 = oc .route({ - deprecated: true, - description: - 'Estimate document indexing cost\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Estimate document indexing cost', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdDocumentsByDocumentIdIndexingEstimate', @@ -902,16 +823,8 @@ export const notion = { sync, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const get18 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdDocumentsByDocumentIdPipelineExecutionLog', @@ -1272,16 +1185,11 @@ export const segments = { * - error: Number of summaries with errors * - not_started: Number of segments without summary records * - summaries: List of summary records with status and content preview - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get22 = oc .route({ - deprecated: true, description: - 'Get summary index generation status for a document\nReturns:\n- total_segments: Total number of segments in the document\n- summary_status: Dictionary with status counts\n - completed: Number of summaries completed\n - generating: Number of summaries being generated\n - error: Number of summaries with errors\n - not_started: Number of segments without summary records\n- summaries: List of summary records with status and content preview\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + 'Get summary index generation status for a document\nReturns:\n- total_segments: Total number of segments in the document\n- summary_status: Dictionary with status counts\n - completed: Number of summaries completed\n - generating: Number of summaries being generated\n - error: Number of summaries with errors\n - not_started: Number of segments without summary records\n- summaries: List of summary records with status and content preview', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdDocumentsByDocumentIdSummaryStatus', @@ -1329,16 +1237,10 @@ export const delete6 = oc /** * Get document details - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get24 = oc .route({ - deprecated: true, - description: - 'Get document details\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get document details', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdDocumentsByDocumentId', @@ -1402,16 +1304,8 @@ export const get25 = oc ) .output(zGetDatasetsByDatasetIdDocumentsResponse) -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post16 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdDocuments', @@ -1458,16 +1352,10 @@ export const errorDocs = { /** * Test external knowledge retrieval for dataset - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post17 = oc .route({ - deprecated: true, - description: - 'Test external knowledge retrieval for dataset\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Test external knowledge retrieval for dataset', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdExternalHitTesting', @@ -1488,16 +1376,10 @@ export const externalHitTesting = { /** * Test dataset knowledge retrieval - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post18 = oc .route({ - deprecated: true, - description: - 'Test dataset knowledge retrieval\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Test dataset knowledge retrieval', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdHitTesting', @@ -1772,16 +1654,10 @@ export const get34 = oc /** * Update dataset details - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const patch11 = oc .route({ - deprecated: true, - description: - 'Update dataset details\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Update dataset details', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchDatasetsByDatasetId', diff --git a/packages/contracts/generated/api/console/datasets/types.gen.ts b/packages/contracts/generated/api/console/datasets/types.gen.ts index a9170dd2619..a24c6cf92ae 100644 --- a/packages/contracts/generated/api/console/datasets/types.gen.ts +++ b/packages/contracts/generated/api/console/datasets/types.gen.ts @@ -18,7 +18,7 @@ export type DatasetCreatePayload = { external_knowledge_id?: string | null indexing_technique?: string | null name: string - permission?: PermissionEnum + permission?: PermissionEnum | null provider?: string } @@ -39,7 +39,7 @@ export type DatasetDetailResponse = { embedding_model_provider: string | null enable_api: boolean external_knowledge_info?: DatasetExternalKnowledgeInfoResponse - external_retrieval_model: DatasetExternalRetrievalModelResponse + external_retrieval_model: DatasetExternalRetrievalModelResponse | null icon_info?: DatasetIconInfoResponse id: string indexing_technique: string | null @@ -100,9 +100,7 @@ export type DatasetDetail = { author_name?: string built_in_field_enabled?: boolean chunk_structure?: string - created_at?: { - [key: string]: unknown - } + created_at?: number created_by?: string data_source_type?: string description?: string @@ -130,13 +128,19 @@ export type DatasetDetail = { tags?: Array total_available_documents?: number total_documents?: number - updated_at?: { - [key: string]: unknown - } + updated_at?: number updated_by?: string word_count?: number } +export type ExternalKnowledgeApiListResponse = { + data: Array + has_more: boolean + limit: number + page: number + total: number +} + export type ExternalKnowledgeApiPayload = { name: string settings: { @@ -144,6 +148,19 @@ export type ExternalKnowledgeApiPayload = { } } +export type ExternalKnowledgeApiResponse = { + created_at: string + created_by: string + dataset_bindings?: Array + description: string + id: string + name: string + settings?: { + [key: string]: unknown + } | null + tenant_id: string +} + export type UsageCountResponse = { count: number is_using: boolean @@ -169,7 +186,7 @@ export type IndexingEstimateResponse = { } export type KnowledgeConfig = { - data_source?: DataSource + data_source?: DataSource | null doc_form?: string doc_language?: string duplicate?: boolean @@ -179,8 +196,8 @@ export type KnowledgeConfig = { is_multimodal?: boolean name?: string | null original_document_id?: string | null - process_rule?: ProcessRule - retrieval_model?: RetrievalModel + process_rule?: ProcessRule | null + retrieval_model?: RetrievalModel | null summary_index_setting?: { [key: string]: unknown } | null @@ -213,6 +230,10 @@ export type IndexingEstimate = { total_segments: number } +export type OpaqueObjectResponse = { + [key: string]: unknown +} + export type RetrievalSettingResponse = { retrieval_method: Array } @@ -234,7 +255,7 @@ export type DatasetDetailWithPartialMembersResponse = { embedding_model_provider: string | null enable_api: boolean external_knowledge_info?: DatasetExternalKnowledgeInfoResponse - external_retrieval_model: DatasetExternalRetrievalModelResponse + external_retrieval_model: DatasetExternalRetrievalModelResponse | null icon_info?: DatasetIconInfoResponse id: string indexing_technique: string | null @@ -274,7 +295,7 @@ export type DatasetUpdatePayload = { partial_member_list?: Array<{ [key: string]: string }> | null - permission?: PermissionEnum + permission?: PermissionEnum | null retrieval_model?: { [key: string]: unknown } | null @@ -308,6 +329,8 @@ export type DocumentBatchDownloadZipPayload = { document_ids: Array } +export type BinaryFileResponse = Blob | File + export type GenerateSummaryPayload = { document_list: Array } @@ -448,13 +471,21 @@ export type ExternalHitTestingPayload = { query: string } +export type ExternalRetrievalTestResponse + = | { + [key: string]: unknown + } + | Array<{ + [key: string]: unknown + }> + export type HitTestingPayload = { attachment_ids?: Array | null external_retrieval_model?: { [key: string]: unknown } | null query: string - retrieval_model?: RetrievalModel + retrieval_model?: RetrievalModel | null } export type HitTestingResponse = { @@ -524,7 +555,7 @@ export type DatasetListItemResponse = { embedding_model_provider: string | null enable_api: boolean external_knowledge_info?: DatasetExternalKnowledgeInfoResponse - external_retrieval_model: DatasetExternalRetrievalModelResponse + external_retrieval_model: DatasetExternalRetrievalModelResponse | null icon_info?: DatasetIconInfoResponse id: string indexing_technique: string | null @@ -582,7 +613,7 @@ export type DatasetRetrievalModelResponse = { score_threshold_enabled: boolean search_method: string top_k: number - weights?: DatasetWeightedScoreResponse + weights?: DatasetWeightedScoreResponse | null } export type DatasetSummaryIndexSettingResponse = { @@ -648,6 +679,11 @@ export type Tag = { type: string } +export type ExternalKnowledgeDatasetBindingResponse = { + id: string + name: string +} + export type IndexingEstimatePreviewItemResponse = { child_chunks?: Array | null content: string @@ -665,19 +701,19 @@ export type DataSource = { export type ProcessRule = { mode: ProcessRuleMode - rules?: Rule + rules?: Rule | null } export type RetrievalModel = { - metadata_filtering_conditions?: MetadataFilteringCondition + metadata_filtering_conditions?: MetadataFilteringCondition | null reranking_enable: boolean reranking_mode?: string | null - reranking_model?: RerankingModel + reranking_model?: RerankingModel | null score_threshold?: number | null score_threshold_enabled: boolean search_method: RetrievalMethod top_k: number - weights?: WeightModel + weights?: WeightModel | null } export type DatasetResponse = { @@ -747,7 +783,7 @@ export type DocumentMetadataResponse = { id: string name: string type: string - value?: unknown + value?: string | number | number | boolean | null } export type SegmentResponse = { @@ -806,7 +842,7 @@ export type HitTestingRecord = { score: number | null segment: HitTestingSegment summary: string | null - tsne_position: unknown + tsne_position: unknown | null } export type DatasetMetadataListItemResponse = { @@ -861,9 +897,9 @@ export type DatasetWeightedScore = { export type InfoList = { data_source_type: 'notion_import' | 'upload_file' | 'website_crawl' - file_info_list?: FileInfo + file_info_list?: FileInfo | null notion_info_list?: Array | null - website_info_list?: WebsiteInfo + website_info_list?: WebsiteInfo | null } export type ProcessRuleMode = 'automatic' | 'custom' | 'hierarchical' @@ -871,8 +907,8 @@ export type ProcessRuleMode = 'automatic' | 'custom' | 'hierarchical' export type Rule = { parent_mode?: 'full-doc' | 'paragraph' | null pre_processing_rules?: Array | null - segmentation?: Segmentation - subchunk_segmentation?: Segmentation + segmentation?: Segmentation | null + subchunk_segmentation?: Segmentation | null } export type MetadataFilteringCondition = { @@ -892,15 +928,15 @@ export type RetrievalMethod | 'semantic_search' export type WeightModel = { - keyword_setting?: WeightKeywordSetting - vector_setting?: WeightVectorSetting + keyword_setting?: WeightKeywordSetting | null + vector_setting?: WeightVectorSetting | null weight_type?: 'customized' | 'keyword_first' | 'semantic_first' | null } export type MetadataDetail = { id: string name: string - value?: unknown + value?: string | number | number | null } export type SegmentAttachmentResponse = { @@ -957,7 +993,7 @@ export type HitTestingSegment = { export type DatasetQueryContentResponse = { content: string content_type: string - file_info?: DatasetQueryFileInfoResponse + file_info?: DatasetQueryFileInfoResponse | null } export type DatasetKeywordSettingResponse = { @@ -1029,7 +1065,7 @@ export type Condition = { | '≤' | '≥' name: string - value?: unknown + value?: string | Array | number | number | null } export type WeightKeywordSetting = { @@ -1044,7 +1080,7 @@ export type WeightVectorSetting = { export type HitTestingDocument = { data_source_type: string - doc_metadata: unknown + doc_metadata: unknown | null doc_type: string | null id: string name: string @@ -1060,7 +1096,7 @@ export type DatasetQueryFileInfoResponse = { } export type NotionPage = { - page_icon?: NotionIcon + page_icon?: NotionIcon | null page_id: string page_name: string type: string @@ -1100,13 +1136,9 @@ export type PostDatasetsData = { } export type PostDatasetsErrors = { - 400: { - [key: string]: unknown - } + 400: unknown } -export type PostDatasetsError = PostDatasetsErrors[keyof PostDatasetsErrors] - export type PostDatasetsResponses = { 201: DatasetDetailResponse } @@ -1149,13 +1181,9 @@ export type PostDatasetsApiKeysData = { } export type PostDatasetsApiKeysErrors = { - 400: { - [key: string]: unknown - } + 400: unknown } -export type PostDatasetsApiKeysError = PostDatasetsApiKeysErrors[keyof PostDatasetsApiKeysErrors] - export type PostDatasetsApiKeysResponses = { 200: ApiKeyItem } @@ -1173,9 +1201,7 @@ export type DeleteDatasetsApiKeysByApiKeyIdData = { } export type DeleteDatasetsApiKeysByApiKeyIdResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteDatasetsApiKeysByApiKeyIdResponse @@ -1221,16 +1247,10 @@ export type PostDatasetsExternalData = { } export type PostDatasetsExternalErrors = { - 400: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } + 400: unknown + 403: unknown } -export type PostDatasetsExternalError = PostDatasetsExternalErrors[keyof PostDatasetsExternalErrors] - export type PostDatasetsExternalResponses = { 201: DatasetDetail } @@ -1243,16 +1263,14 @@ export type GetDatasetsExternalKnowledgeApiData = { path?: never query?: { keyword?: string - limit?: string - page?: string + limit?: number + page?: number } url: '/datasets/external-knowledge-api' } export type GetDatasetsExternalKnowledgeApiResponses = { - 200: { - [key: string]: unknown - } + 200: ExternalKnowledgeApiListResponse } export type GetDatasetsExternalKnowledgeApiResponse @@ -1266,9 +1284,7 @@ export type PostDatasetsExternalKnowledgeApiData = { } export type PostDatasetsExternalKnowledgeApiResponses = { - 200: { - [key: string]: unknown - } + 201: ExternalKnowledgeApiResponse } export type PostDatasetsExternalKnowledgeApiResponse @@ -1284,9 +1300,7 @@ export type DeleteDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdData = { } export type DeleteDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponse @@ -1302,18 +1316,11 @@ export type GetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdData = { } export type GetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type GetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdError - = GetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdErrors[keyof GetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdErrors] - export type GetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponses = { - 200: { - [key: string]: unknown - } + 200: ExternalKnowledgeApiResponse } export type GetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponse @@ -1329,9 +1336,7 @@ export type PatchDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdData = { } export type PatchDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponses = { - 200: { - [key: string]: unknown - } + 200: ExternalKnowledgeApiResponse } export type PatchDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponse @@ -1375,13 +1380,9 @@ export type PostDatasetsInitData = { } export type PostDatasetsInitErrors = { - 400: { - [key: string]: unknown - } + 400: unknown } -export type PostDatasetsInitError = PostDatasetsInitErrors[keyof PostDatasetsInitErrors] - export type PostDatasetsInitResponses = { 201: DatasetAndDocumentResponse } @@ -1426,9 +1427,7 @@ export type GetDatasetsProcessRuleData = { } export type GetDatasetsProcessRuleResponses = { - 200: { - [key: string]: unknown - } + 200: OpaqueObjectResponse } export type GetDatasetsProcessRuleResponse @@ -1474,9 +1473,7 @@ export type DeleteDatasetsByDatasetIdData = { } export type DeleteDatasetsByDatasetIdResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteDatasetsByDatasetIdResponse @@ -1492,17 +1489,10 @@ export type GetDatasetsByDatasetIdData = { } export type GetDatasetsByDatasetIdErrors = { - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 403: unknown + 404: unknown } -export type GetDatasetsByDatasetIdError - = GetDatasetsByDatasetIdErrors[keyof GetDatasetsByDatasetIdErrors] - export type GetDatasetsByDatasetIdResponses = { 200: DatasetDetailWithPartialMembersResponse } @@ -1520,17 +1510,10 @@ export type PatchDatasetsByDatasetIdData = { } export type PatchDatasetsByDatasetIdErrors = { - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 403: unknown + 404: unknown } -export type PatchDatasetsByDatasetIdError - = PatchDatasetsByDatasetIdErrors[keyof PatchDatasetsByDatasetIdErrors] - export type PatchDatasetsByDatasetIdResponses = { 200: DatasetDetailWithPartialMembersResponse } @@ -1565,14 +1548,9 @@ export type GetDatasetsByDatasetIdAutoDisableLogsData = { } export type GetDatasetsByDatasetIdAutoDisableLogsErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type GetDatasetsByDatasetIdAutoDisableLogsError - = GetDatasetsByDatasetIdAutoDisableLogsErrors[keyof GetDatasetsByDatasetIdAutoDisableLogsErrors] - export type GetDatasetsByDatasetIdAutoDisableLogsResponses = { 200: AutoDisableLogsResponse } @@ -1591,9 +1569,7 @@ export type GetDatasetsByDatasetIdBatchByBatchIndexingEstimateData = { } export type GetDatasetsByDatasetIdBatchByBatchIndexingEstimateResponses = { - 200: { - [key: string]: unknown - } + 200: OpaqueObjectResponse } export type GetDatasetsByDatasetIdBatchByBatchIndexingEstimateResponse @@ -1626,9 +1602,7 @@ export type DeleteDatasetsByDatasetIdDocumentsData = { } export type DeleteDatasetsByDatasetIdDocumentsResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteDatasetsByDatasetIdDocumentsResponse @@ -1683,9 +1657,7 @@ export type PostDatasetsByDatasetIdDocumentsDownloadZipData = { } export type PostDatasetsByDatasetIdDocumentsDownloadZipResponses = { - 200: { - [key: string]: unknown - } + 200: BinaryFileResponse } export type PostDatasetsByDatasetIdDocumentsDownloadZipResponse @@ -1701,20 +1673,11 @@ export type PostDatasetsByDatasetIdDocumentsGenerateSummaryData = { } export type PostDatasetsByDatasetIdDocumentsGenerateSummaryErrors = { - 400: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 400: unknown + 403: unknown + 404: unknown } -export type PostDatasetsByDatasetIdDocumentsGenerateSummaryError - = PostDatasetsByDatasetIdDocumentsGenerateSummaryErrors[keyof PostDatasetsByDatasetIdDocumentsGenerateSummaryErrors] - export type PostDatasetsByDatasetIdDocumentsGenerateSummaryResponses = { 200: SimpleResultResponse } @@ -1732,9 +1695,7 @@ export type PostDatasetsByDatasetIdDocumentsMetadataData = { } export type PostDatasetsByDatasetIdDocumentsMetadataResponses = { - 204: { - [key: string]: never - } + 204: void } export type PostDatasetsByDatasetIdDocumentsMetadataResponse @@ -1768,9 +1729,7 @@ export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdData = { } export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdResponse @@ -1789,18 +1748,11 @@ export type GetDatasetsByDatasetIdDocumentsByDocumentIdData = { } export type GetDatasetsByDatasetIdDocumentsByDocumentIdErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type GetDatasetsByDatasetIdDocumentsByDocumentIdError - = GetDatasetsByDatasetIdDocumentsByDocumentIdErrors[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdErrors] - export type GetDatasetsByDatasetIdDocumentsByDocumentIdResponses = { - 200: { - [key: string]: unknown - } + 200: OpaqueObjectResponse } export type GetDatasetsByDatasetIdDocumentsByDocumentIdResponse @@ -1834,21 +1786,12 @@ export type GetDatasetsByDatasetIdDocumentsByDocumentIdIndexingEstimateData = { } export type GetDatasetsByDatasetIdDocumentsByDocumentIdIndexingEstimateErrors = { - 400: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 400: unknown + 404: unknown } -export type GetDatasetsByDatasetIdDocumentsByDocumentIdIndexingEstimateError - = GetDatasetsByDatasetIdDocumentsByDocumentIdIndexingEstimateErrors[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdIndexingEstimateErrors] - export type GetDatasetsByDatasetIdDocumentsByDocumentIdIndexingEstimateResponses = { - 200: { - [key: string]: unknown - } + 200: OpaqueObjectResponse } export type GetDatasetsByDatasetIdDocumentsByDocumentIdIndexingEstimateResponse @@ -1865,14 +1808,9 @@ export type GetDatasetsByDatasetIdDocumentsByDocumentIdIndexingStatusData = { } export type GetDatasetsByDatasetIdDocumentsByDocumentIdIndexingStatusErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type GetDatasetsByDatasetIdDocumentsByDocumentIdIndexingStatusError - = GetDatasetsByDatasetIdDocumentsByDocumentIdIndexingStatusErrors[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdIndexingStatusErrors] - export type GetDatasetsByDatasetIdDocumentsByDocumentIdIndexingStatusResponses = { 200: DocumentStatusResponse } @@ -1891,17 +1829,10 @@ export type PutDatasetsByDatasetIdDocumentsByDocumentIdMetadataData = { } export type PutDatasetsByDatasetIdDocumentsByDocumentIdMetadataErrors = { - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 403: unknown + 404: unknown } -export type PutDatasetsByDatasetIdDocumentsByDocumentIdMetadataError - = PutDatasetsByDatasetIdDocumentsByDocumentIdMetadataErrors[keyof PutDatasetsByDatasetIdDocumentsByDocumentIdMetadataErrors] - export type PutDatasetsByDatasetIdDocumentsByDocumentIdMetadataResponses = { 200: SimpleResultMessageResponse } @@ -1937,9 +1868,7 @@ export type GetDatasetsByDatasetIdDocumentsByDocumentIdPipelineExecutionLogData } export type GetDatasetsByDatasetIdDocumentsByDocumentIdPipelineExecutionLogResponses = { - 200: { - [key: string]: unknown - } + 200: OpaqueObjectResponse } export type GetDatasetsByDatasetIdDocumentsByDocumentIdPipelineExecutionLogResponse @@ -1956,9 +1885,7 @@ export type PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingPauseData = { } export type PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingPauseResponses = { - 204: { - [key: string]: never - } + 204: void } export type PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingPauseResponse @@ -1975,9 +1902,7 @@ export type PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingResumeData = } export type PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingResumeResponses = { - 204: { - [key: string]: never - } + 204: void } export type PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingResumeResponse @@ -1995,17 +1920,10 @@ export type PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingByActionData } export type PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingByActionErrors = { - 400: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 400: unknown + 404: unknown } -export type PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingByActionError - = PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingByActionErrors[keyof PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingByActionErrors] - export type PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingByActionResponses = { 200: SimpleResultResponse } @@ -2080,9 +1998,7 @@ export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsData = { } export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponse @@ -2158,9 +2074,7 @@ export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdDat } export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponse @@ -2257,9 +2171,7 @@ export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChi export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponse @@ -2297,18 +2209,11 @@ export type GetDatasetsByDatasetIdDocumentsByDocumentIdSummaryStatusData = { } export type GetDatasetsByDatasetIdDocumentsByDocumentIdSummaryStatusErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type GetDatasetsByDatasetIdDocumentsByDocumentIdSummaryStatusError - = GetDatasetsByDatasetIdDocumentsByDocumentIdSummaryStatusErrors[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdSummaryStatusErrors] - export type GetDatasetsByDatasetIdDocumentsByDocumentIdSummaryStatusResponses = { - 200: { - [key: string]: unknown - } + 200: OpaqueObjectResponse } export type GetDatasetsByDatasetIdDocumentsByDocumentIdSummaryStatusResponse @@ -2341,14 +2246,9 @@ export type GetDatasetsByDatasetIdErrorDocsData = { } export type GetDatasetsByDatasetIdErrorDocsErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type GetDatasetsByDatasetIdErrorDocsError - = GetDatasetsByDatasetIdErrorDocsErrors[keyof GetDatasetsByDatasetIdErrorDocsErrors] - export type GetDatasetsByDatasetIdErrorDocsResponses = { 200: ErrorDocsResponse } @@ -2366,21 +2266,12 @@ export type PostDatasetsByDatasetIdExternalHitTestingData = { } export type PostDatasetsByDatasetIdExternalHitTestingErrors = { - 400: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 400: unknown + 404: unknown } -export type PostDatasetsByDatasetIdExternalHitTestingError - = PostDatasetsByDatasetIdExternalHitTestingErrors[keyof PostDatasetsByDatasetIdExternalHitTestingErrors] - export type PostDatasetsByDatasetIdExternalHitTestingResponses = { - 200: { - [key: string]: unknown - } + 200: ExternalRetrievalTestResponse } export type PostDatasetsByDatasetIdExternalHitTestingResponse @@ -2396,17 +2287,10 @@ export type PostDatasetsByDatasetIdHitTestingData = { } export type PostDatasetsByDatasetIdHitTestingErrors = { - 400: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 400: unknown + 404: unknown } -export type PostDatasetsByDatasetIdHitTestingError - = PostDatasetsByDatasetIdHitTestingErrors[keyof PostDatasetsByDatasetIdHitTestingErrors] - export type PostDatasetsByDatasetIdHitTestingResponses = { 200: HitTestingResponse } @@ -2473,9 +2357,7 @@ export type PostDatasetsByDatasetIdMetadataBuiltInByActionData = { } export type PostDatasetsByDatasetIdMetadataBuiltInByActionResponses = { - 204: { - [key: string]: never - } + 204: void } export type PostDatasetsByDatasetIdMetadataBuiltInByActionResponse @@ -2492,9 +2374,7 @@ export type DeleteDatasetsByDatasetIdMetadataByMetadataIdData = { } export type DeleteDatasetsByDatasetIdMetadataByMetadataIdResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteDatasetsByDatasetIdMetadataByMetadataIdResponse @@ -2543,17 +2423,10 @@ export type GetDatasetsByDatasetIdPermissionPartUsersData = { } export type GetDatasetsByDatasetIdPermissionPartUsersErrors = { - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 403: unknown + 404: unknown } -export type GetDatasetsByDatasetIdPermissionPartUsersError - = GetDatasetsByDatasetIdPermissionPartUsersErrors[keyof GetDatasetsByDatasetIdPermissionPartUsersErrors] - export type GetDatasetsByDatasetIdPermissionPartUsersResponses = { 200: PartialMemberListResponse } @@ -2603,9 +2476,7 @@ export type PostDatasetsByDatasetIdRetryData = { } export type PostDatasetsByDatasetIdRetryResponses = { - 204: { - [key: string]: never - } + 204: void } export type PostDatasetsByDatasetIdRetryResponse @@ -2653,14 +2524,9 @@ export type PostDatasetsByResourceIdApiKeysData = { } export type PostDatasetsByResourceIdApiKeysErrors = { - 400: { - [key: string]: unknown - } + 400: unknown } -export type PostDatasetsByResourceIdApiKeysError - = PostDatasetsByResourceIdApiKeysErrors[keyof PostDatasetsByResourceIdApiKeysErrors] - export type PostDatasetsByResourceIdApiKeysResponses = { 201: ApiKeyItem } @@ -2679,9 +2545,7 @@ export type DeleteDatasetsByResourceIdApiKeysByApiKeyIdData = { } export type DeleteDatasetsByResourceIdApiKeysByApiKeyIdResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteDatasetsByResourceIdApiKeysByApiKeyIdResponse diff --git a/packages/contracts/generated/api/console/datasets/zod.gen.ts b/packages/contracts/generated/api/console/datasets/zod.gen.ts index 44b491d01a5..5a5f9282794 100644 --- a/packages/contracts/generated/api/console/datasets/zod.gen.ts +++ b/packages/contracts/generated/api/console/datasets/zod.gen.ts @@ -91,6 +91,11 @@ export const zNotionEstimatePayload = z.object({ process_rule: z.record(z.string(), z.unknown()), }) +/** + * OpaqueObjectResponse + */ +export const zOpaqueObjectResponse = z.record(z.string(), z.unknown()) + /** * RetrievalSettingResponse */ @@ -122,6 +127,11 @@ export const zDocumentBatchDownloadZipPayload = z.object({ document_ids: z.array(z.uuid()).min(1).max(100), }) +/** + * BinaryFileResponse + */ +export const zBinaryFileResponse = z.custom() + /** * GenerateSummaryPayload */ @@ -237,6 +247,14 @@ export const zExternalHitTestingPayload = z.object({ query: z.string(), }) +/** + * ExternalRetrievalTestResponse + */ +export const zExternalRetrievalTestResponse = z.union([ + z.record(z.string(), z.unknown()), + z.array(z.record(z.string(), z.unknown())), +]) + /** * MetadataArgs */ @@ -298,7 +316,7 @@ export const zDatasetCreatePayload = z.object({ external_knowledge_id: z.string().nullish(), indexing_technique: z.string().nullish(), name: z.string().min(1).max(40), - permission: zPermissionEnum.optional(), + permission: zPermissionEnum.nullish().default('only_me'), provider: z.string().optional().default('vendor'), }) @@ -317,7 +335,7 @@ export const zDatasetUpdatePayload = z.object({ is_multimodal: z.boolean().nullish().default(false), name: z.string().min(1).max(40).nullish(), partial_member_list: z.array(z.record(z.string(), z.string())).nullish(), - permission: zPermissionEnum.optional(), + permission: zPermissionEnum.nullish(), retrieval_model: z.record(z.string(), z.unknown()).nullish(), summary_index_setting: z.record(z.string(), z.unknown()).nullish(), }) @@ -421,6 +439,39 @@ export const zTag = z.object({ type: z.string(), }) +/** + * ExternalKnowledgeDatasetBindingResponse + */ +export const zExternalKnowledgeDatasetBindingResponse = z.object({ + id: z.string(), + name: z.string(), +}) + +/** + * ExternalKnowledgeApiResponse + */ +export const zExternalKnowledgeApiResponse = z.object({ + created_at: z.string(), + created_by: z.string(), + dataset_bindings: z.array(zExternalKnowledgeDatasetBindingResponse).optional(), + description: z.string(), + id: z.string(), + name: z.string(), + settings: z.record(z.string(), z.unknown()).nullish(), + tenant_id: z.string(), +}) + +/** + * ExternalKnowledgeApiListResponse + */ +export const zExternalKnowledgeApiListResponse = z.object({ + data: z.array(zExternalKnowledgeApiResponse), + has_more: z.boolean(), + limit: z.int(), + page: z.int(), + total: z.int(), +}) + /** * IndexingEstimatePreviewItemResponse */ @@ -509,7 +560,7 @@ export const zDocumentMetadataResponse = z.object({ id: z.string(), name: z.string(), type: z.string(), - value: z.unknown().optional(), + value: z.union([z.string(), z.int(), z.number(), z.boolean()]).nullish(), }) /** @@ -740,7 +791,7 @@ export const zRetrievalMethod = z.enum([ export const zMetadataDetail = z.object({ id: z.string(), name: z.string(), - value: z.unknown().optional(), + value: z.union([z.string(), z.int(), z.number()]).nullish(), }) /** @@ -883,7 +934,7 @@ export const zDatasetRetrievalModelResponse = z.object({ score_threshold_enabled: z.boolean(), search_method: z.string(), top_k: z.int(), - weights: zDatasetWeightedScoreResponse.optional(), + weights: zDatasetWeightedScoreResponse.nullish(), }) /** @@ -906,7 +957,7 @@ export const zDatasetDetailResponse = z.object({ embedding_model_provider: z.string().nullable(), enable_api: z.boolean(), external_knowledge_info: zDatasetExternalKnowledgeInfoResponse.optional(), - external_retrieval_model: zDatasetExternalRetrievalModelResponse, + external_retrieval_model: zDatasetExternalRetrievalModelResponse.nullable(), icon_info: zDatasetIconInfoResponse.optional(), id: z.string(), indexing_technique: z.string().nullable(), @@ -947,7 +998,7 @@ export const zDatasetDetailWithPartialMembersResponse = z.object({ embedding_model_provider: z.string().nullable(), enable_api: z.boolean(), external_knowledge_info: zDatasetExternalKnowledgeInfoResponse.optional(), - external_retrieval_model: zDatasetExternalRetrievalModelResponse, + external_retrieval_model: zDatasetExternalRetrievalModelResponse.nullable(), icon_info: zDatasetIconInfoResponse.optional(), id: z.string(), indexing_technique: z.string().nullable(), @@ -989,7 +1040,7 @@ export const zDatasetListItemResponse = z.object({ embedding_model_provider: z.string().nullable(), enable_api: z.boolean(), external_knowledge_info: zDatasetExternalKnowledgeInfoResponse.optional(), - external_retrieval_model: zDatasetExternalRetrievalModelResponse, + external_retrieval_model: zDatasetExternalRetrievalModelResponse.nullable(), icon_info: zDatasetIconInfoResponse.optional(), id: z.string(), indexing_technique: z.string().nullable(), @@ -1054,7 +1105,15 @@ export const zDatasetDetail = z.object({ author_name: z.string().optional(), built_in_field_enabled: z.boolean().optional(), chunk_structure: z.string().optional(), - created_at: z.record(z.string(), z.unknown()).optional(), + created_at: z.coerce + .bigint() + .min(BigInt('-9223372036854775808'), { + error: 'Invalid value: Expected int64 to be >= -9223372036854775808', + }) + .max(BigInt('9223372036854775807'), { + error: 'Invalid value: Expected int64 to be <= 9223372036854775807', + }) + .optional(), created_by: z.string().optional(), data_source_type: z.string().optional(), description: z.string().optional(), @@ -1082,7 +1141,15 @@ export const zDatasetDetail = z.object({ tags: z.array(zTag).optional(), total_available_documents: z.int().optional(), total_documents: z.int().optional(), - updated_at: z.record(z.string(), z.unknown()).optional(), + updated_at: z.coerce + .bigint() + .min(BigInt('-9223372036854775808'), { + error: 'Invalid value: Expected int64 to be >= -9223372036854775808', + }) + .max(BigInt('9223372036854775807'), { + error: 'Invalid value: Expected int64 to be <= 9223372036854775807', + }) + .optional(), updated_by: z.string().optional(), word_count: z.int().optional(), }) @@ -1127,8 +1194,8 @@ export const zSegmentation = z.object({ export const zRule = z.object({ parent_mode: z.enum(['full-doc', 'paragraph']).nullish(), pre_processing_rules: z.array(zPreProcessingRule).nullish(), - segmentation: zSegmentation.optional(), - subchunk_segmentation: zSegmentation.optional(), + segmentation: zSegmentation.nullish(), + subchunk_segmentation: zSegmentation.nullish(), }) /** @@ -1136,7 +1203,7 @@ export const zRule = z.object({ */ export const zProcessRule = z.object({ mode: zProcessRuleMode, - rules: zRule.optional(), + rules: zRule.nullish(), }) /** @@ -1166,7 +1233,7 @@ export const zCondition = z.object({ '≥', ]), name: z.string(), - value: z.unknown().optional(), + value: z.union([z.string(), z.array(z.string()), z.int(), z.number()]).nullish(), }) /** @@ -1199,8 +1266,8 @@ export const zWeightVectorSetting = z.object({ * WeightModel */ export const zWeightModel = z.object({ - keyword_setting: zWeightKeywordSetting.optional(), - vector_setting: zWeightVectorSetting.optional(), + keyword_setting: zWeightKeywordSetting.nullish(), + vector_setting: zWeightVectorSetting.nullish(), weight_type: z.enum(['customized', 'keyword_first', 'semantic_first']).nullish(), }) @@ -1208,15 +1275,15 @@ export const zWeightModel = z.object({ * RetrievalModel */ export const zRetrievalModel = z.object({ - metadata_filtering_conditions: zMetadataFilteringCondition.optional(), + metadata_filtering_conditions: zMetadataFilteringCondition.nullish(), reranking_enable: z.boolean(), reranking_mode: z.string().nullish(), - reranking_model: zRerankingModel.optional(), + reranking_model: zRerankingModel.nullish(), score_threshold: z.number().nullish(), score_threshold_enabled: z.boolean(), search_method: zRetrievalMethod, top_k: z.int(), - weights: zWeightModel.optional(), + weights: zWeightModel.nullish(), }) /** @@ -1226,7 +1293,7 @@ export const zHitTestingPayload = z.object({ attachment_ids: z.array(z.string()).nullish(), external_retrieval_model: z.record(z.string(), z.unknown()).nullish(), query: z.string().max(250), - retrieval_model: zRetrievalModel.optional(), + retrieval_model: zRetrievalModel.nullish(), }) /** @@ -1234,7 +1301,7 @@ export const zHitTestingPayload = z.object({ */ export const zHitTestingDocument = z.object({ data_source_type: z.string(), - doc_metadata: z.unknown(), + doc_metadata: z.unknown().nullable(), doc_type: z.string().nullable(), id: z.string(), name: z.string(), @@ -1278,7 +1345,7 @@ export const zHitTestingRecord = z.object({ score: z.number().nullable(), segment: zHitTestingSegment, summary: z.string().nullable(), - tsne_position: z.unknown(), + tsne_position: z.unknown().nullable(), }) /** @@ -1307,7 +1374,7 @@ export const zDatasetQueryFileInfoResponse = z.object({ export const zDatasetQueryContentResponse = z.object({ content: z.string(), content_type: z.string(), - file_info: zDatasetQueryFileInfoResponse.optional(), + file_info: zDatasetQueryFileInfoResponse.nullish(), }) /** @@ -1347,7 +1414,7 @@ export const zNotionIcon = z.object({ * NotionPage */ export const zNotionPage = z.object({ - page_icon: zNotionIcon.optional(), + page_icon: zNotionIcon.nullish(), page_id: z.string(), page_name: z.string(), type: z.string(), @@ -1367,9 +1434,9 @@ export const zNotionInfo = z.object({ */ export const zInfoList = z.object({ data_source_type: z.enum(['notion_import', 'upload_file', 'website_crawl']), - file_info_list: zFileInfo.optional(), + file_info_list: zFileInfo.nullish(), notion_info_list: z.array(zNotionInfo).nullish(), - website_info_list: zWebsiteInfo.optional(), + website_info_list: zWebsiteInfo.nullish(), }) /** @@ -1383,7 +1450,7 @@ export const zDataSource = z.object({ * KnowledgeConfig */ export const zKnowledgeConfig = z.object({ - data_source: zDataSource.optional(), + data_source: zDataSource.nullish(), doc_form: z.string().optional().default('text_model'), doc_language: z.string().optional().default('English'), duplicate: z.boolean().optional().default(true), @@ -1393,8 +1460,8 @@ export const zKnowledgeConfig = z.object({ is_multimodal: z.boolean().optional().default(false), name: z.string().nullish(), original_document_id: z.string().nullish(), - process_rule: zProcessRule.optional(), - retrieval_model: zRetrievalModel.optional(), + process_rule: zProcessRule.nullish(), + retrieval_model: zRetrievalModel.nullish(), summary_index_setting: z.record(z.string(), z.unknown()).nullish(), }) @@ -1441,7 +1508,7 @@ export const zDeleteDatasetsApiKeysByApiKeyIdPath = z.object({ /** * API key deleted successfully */ -export const zDeleteDatasetsApiKeysByApiKeyIdResponse = z.record(z.string(), z.never()) +export const zDeleteDatasetsApiKeysByApiKeyIdResponse = z.void() export const zGetDatasetsBatchImportStatusByJobIdPath = z.object({ job_id: z.string(), @@ -1472,21 +1539,21 @@ export const zPostDatasetsExternalResponse = zDatasetDetail export const zGetDatasetsExternalKnowledgeApiQuery = z.object({ keyword: z.string().optional(), - limit: z.string().optional(), - page: z.string().optional(), + limit: z.int().optional().default(20), + page: z.int().optional().default(1), }) /** * External API templates retrieved successfully */ -export const zGetDatasetsExternalKnowledgeApiResponse = z.record(z.string(), z.unknown()) +export const zGetDatasetsExternalKnowledgeApiResponse = zExternalKnowledgeApiListResponse export const zPostDatasetsExternalKnowledgeApiBody = zExternalKnowledgeApiPayload /** - * Success + * External API template created successfully */ -export const zPostDatasetsExternalKnowledgeApiResponse = z.record(z.string(), z.unknown()) +export const zPostDatasetsExternalKnowledgeApiResponse = zExternalKnowledgeApiResponse export const zDeleteDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdPath = z.object({ external_knowledge_api_id: z.string(), @@ -1495,10 +1562,7 @@ export const zDeleteDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdPath = z /** * External knowledge API deleted successfully */ -export const zDeleteDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponse = z.record( - z.string(), - z.never(), -) +export const zDeleteDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponse = z.void() export const zGetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdPath = z.object({ external_knowledge_api_id: z.string(), @@ -1507,10 +1571,8 @@ export const zGetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdPath = z.ob /** * External API template retrieved successfully */ -export const zGetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponse + = zExternalKnowledgeApiResponse export const zPatchDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdBody = zExternalKnowledgeApiPayload @@ -1520,12 +1582,10 @@ export const zPatchDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdPath = z. }) /** - * Success + * External API template updated successfully */ -export const zPatchDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponse = z.record( - z.string(), - z.unknown(), -) +export const zPatchDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponse + = zExternalKnowledgeApiResponse export const zGetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdUseCheckPath = z.object({ external_knowledge_api_id: z.string(), @@ -1570,7 +1630,7 @@ export const zGetDatasetsProcessRuleQuery = z.object({ /** * Process rules retrieved successfully */ -export const zGetDatasetsProcessRuleResponse = z.record(z.string(), z.unknown()) +export const zGetDatasetsProcessRuleResponse = zOpaqueObjectResponse /** * Retrieval settings retrieved successfully @@ -1593,7 +1653,7 @@ export const zDeleteDatasetsByDatasetIdPath = z.object({ /** * Dataset deleted successfully */ -export const zDeleteDatasetsByDatasetIdResponse = z.record(z.string(), z.never()) +export const zDeleteDatasetsByDatasetIdResponse = z.void() export const zGetDatasetsByDatasetIdPath = z.object({ dataset_id: z.string(), @@ -1640,12 +1700,9 @@ export const zGetDatasetsByDatasetIdBatchByBatchIndexingEstimatePath = z.object( }) /** - * Success + * Batch indexing estimate calculated successfully */ -export const zGetDatasetsByDatasetIdBatchByBatchIndexingEstimateResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetDatasetsByDatasetIdBatchByBatchIndexingEstimateResponse = zOpaqueObjectResponse export const zGetDatasetsByDatasetIdBatchByBatchIndexingStatusPath = z.object({ batch: z.string(), @@ -1664,7 +1721,7 @@ export const zDeleteDatasetsByDatasetIdDocumentsPath = z.object({ /** * Documents deleted successfully */ -export const zDeleteDatasetsByDatasetIdDocumentsResponse = z.record(z.string(), z.never()) +export const zDeleteDatasetsByDatasetIdDocumentsResponse = z.void() export const zGetDatasetsByDatasetIdDocumentsPath = z.object({ dataset_id: z.string(), @@ -1702,12 +1759,9 @@ export const zPostDatasetsByDatasetIdDocumentsDownloadZipPath = z.object({ }) /** - * Success + * ZIP archive generated successfully */ -export const zPostDatasetsByDatasetIdDocumentsDownloadZipResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostDatasetsByDatasetIdDocumentsDownloadZipResponse = zBinaryFileResponse export const zPostDatasetsByDatasetIdDocumentsGenerateSummaryBody = zGenerateSummaryPayload @@ -1729,7 +1783,7 @@ export const zPostDatasetsByDatasetIdDocumentsMetadataPath = z.object({ /** * Documents metadata updated successfully */ -export const zPostDatasetsByDatasetIdDocumentsMetadataResponse = z.record(z.string(), z.never()) +export const zPostDatasetsByDatasetIdDocumentsMetadataResponse = z.void() export const zPatchDatasetsByDatasetIdDocumentsStatusByActionBatchPath = z.object({ action: z.string(), @@ -1749,10 +1803,7 @@ export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdPath = z.object({ /** * Document deleted successfully */ -export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdResponse = z.record( - z.string(), - z.never(), -) +export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdResponse = z.void() export const zGetDatasetsByDatasetIdDocumentsByDocumentIdPath = z.object({ dataset_id: z.string(), @@ -1766,10 +1817,7 @@ export const zGetDatasetsByDatasetIdDocumentsByDocumentIdQuery = z.object({ /** * Document retrieved successfully */ -export const zGetDatasetsByDatasetIdDocumentsByDocumentIdResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetDatasetsByDatasetIdDocumentsByDocumentIdResponse = zOpaqueObjectResponse export const zGetDatasetsByDatasetIdDocumentsByDocumentIdDownloadPath = z.object({ dataset_id: z.string(), @@ -1789,10 +1837,8 @@ export const zGetDatasetsByDatasetIdDocumentsByDocumentIdIndexingEstimatePath = /** * Indexing estimate calculated successfully */ -export const zGetDatasetsByDatasetIdDocumentsByDocumentIdIndexingEstimateResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetDatasetsByDatasetIdDocumentsByDocumentIdIndexingEstimateResponse + = zOpaqueObjectResponse export const zGetDatasetsByDatasetIdDocumentsByDocumentIdIndexingStatusPath = z.object({ dataset_id: z.string(), @@ -1835,12 +1881,10 @@ export const zGetDatasetsByDatasetIdDocumentsByDocumentIdPipelineExecutionLogPat }) /** - * Success + * Document pipeline execution log retrieved successfully */ -export const zGetDatasetsByDatasetIdDocumentsByDocumentIdPipelineExecutionLogResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetDatasetsByDatasetIdDocumentsByDocumentIdPipelineExecutionLogResponse + = zOpaqueObjectResponse export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingPausePath = z.object({ dataset_id: z.string(), @@ -1850,10 +1894,7 @@ export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingPausePath = /** * Document paused successfully */ -export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingPauseResponse = z.record( - z.string(), - z.never(), -) +export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingPauseResponse = z.void() export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingResumePath = z.object({ dataset_id: z.string(), @@ -1863,10 +1904,7 @@ export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingResumePath /** * Document resumed successfully */ -export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingResumeResponse = z.record( - z.string(), - z.never(), -) +export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingResumeResponse = z.void() export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingByActionPath = z.object({ action: z.string(), @@ -1932,10 +1970,7 @@ export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsQuery = z.ob /** * Segments deleted successfully */ -export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponse = z.record( - z.string(), - z.never(), -) +export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponse = z.void() export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsPath = z.object({ dataset_id: z.string(), @@ -1991,10 +2026,7 @@ export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdP /** * Segment deleted successfully */ -export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponse = z.record( - z.string(), - z.never(), -) +export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponse = z.void() export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdBody = zSegmentUpdatePayload @@ -2075,7 +2107,7 @@ export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdC * Child chunk deleted successfully */ export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponse - = z.record(z.string(), z.never()) + = z.void() export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdBody = zChildChunkUpdatePayload @@ -2102,10 +2134,8 @@ export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSummaryStatusPath = z.o /** * Summary status retrieved successfully */ -export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSummaryStatusResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSummaryStatusResponse + = zOpaqueObjectResponse export const zGetDatasetsByDatasetIdDocumentsByDocumentIdWebsiteSyncPath = z.object({ dataset_id: z.string(), @@ -2135,7 +2165,7 @@ export const zPostDatasetsByDatasetIdExternalHitTestingPath = z.object({ /** * External hit testing completed successfully */ -export const zPostDatasetsByDatasetIdExternalHitTestingResponse = z.record(z.string(), z.unknown()) +export const zPostDatasetsByDatasetIdExternalHitTestingResponse = zExternalRetrievalTestResponse export const zPostDatasetsByDatasetIdHitTestingBody = zHitTestingPayload @@ -2185,10 +2215,7 @@ export const zPostDatasetsByDatasetIdMetadataBuiltInByActionPath = z.object({ /** * Action completed successfully */ -export const zPostDatasetsByDatasetIdMetadataBuiltInByActionResponse = z.record( - z.string(), - z.never(), -) +export const zPostDatasetsByDatasetIdMetadataBuiltInByActionResponse = z.void() export const zDeleteDatasetsByDatasetIdMetadataByMetadataIdPath = z.object({ dataset_id: z.string(), @@ -2198,10 +2225,7 @@ export const zDeleteDatasetsByDatasetIdMetadataByMetadataIdPath = z.object({ /** * Metadata deleted successfully */ -export const zDeleteDatasetsByDatasetIdMetadataByMetadataIdResponse = z.record( - z.string(), - z.never(), -) +export const zDeleteDatasetsByDatasetIdMetadataByMetadataIdResponse = z.void() export const zPatchDatasetsByDatasetIdMetadataByMetadataIdBody = zMetadataUpdatePayload @@ -2260,7 +2284,7 @@ export const zPostDatasetsByDatasetIdRetryPath = z.object({ /** * Documents retry started successfully */ -export const zPostDatasetsByDatasetIdRetryResponse = z.record(z.string(), z.never()) +export const zPostDatasetsByDatasetIdRetryResponse = z.void() export const zGetDatasetsByDatasetIdUseCheckPath = z.object({ dataset_id: z.string(), @@ -2297,4 +2321,4 @@ export const zDeleteDatasetsByResourceIdApiKeysByApiKeyIdPath = z.object({ /** * API key deleted successfully */ -export const zDeleteDatasetsByResourceIdApiKeysByApiKeyIdResponse = z.record(z.string(), z.never()) +export const zDeleteDatasetsByResourceIdApiKeysByApiKeyIdResponse = z.void() diff --git a/packages/contracts/generated/api/console/email-register/orpc.gen.ts b/packages/contracts/generated/api/console/email-register/orpc.gen.ts index b00fdb1c632..0fcc10b9898 100644 --- a/packages/contracts/generated/api/console/email-register/orpc.gen.ts +++ b/packages/contracts/generated/api/console/email-register/orpc.gen.ts @@ -1,73 +1,56 @@ // This file is auto-generated by @hey-api/openapi-ts import { oc } from '@orpc/contract' +import * as z from 'zod' import { + zPostEmailRegisterBody, zPostEmailRegisterResponse, + zPostEmailRegisterSendEmailBody, zPostEmailRegisterSendEmailResponse, + zPostEmailRegisterValidityBody, zPostEmailRegisterValidityResponse, } from './zod.gen' -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postEmailRegisterSendEmail', path: '/email-register/send-email', tags: ['console'], }) + .input(z.object({ body: zPostEmailRegisterSendEmailBody })) .output(zPostEmailRegisterSendEmailResponse) export const sendEmail = { post, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post2 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postEmailRegisterValidity', path: '/email-register/validity', tags: ['console'], }) + .input(z.object({ body: zPostEmailRegisterValidityBody })) .output(zPostEmailRegisterValidityResponse) export const validity = { post: post2, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post3 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postEmailRegister', path: '/email-register', tags: ['console'], }) + .input(z.object({ body: zPostEmailRegisterBody })) .output(zPostEmailRegisterResponse) export const emailRegister = { diff --git a/packages/contracts/generated/api/console/email-register/types.gen.ts b/packages/contracts/generated/api/console/email-register/types.gen.ts index cca4d754156..2a315d2c635 100644 --- a/packages/contracts/generated/api/console/email-register/types.gen.ts +++ b/packages/contracts/generated/api/console/email-register/types.gen.ts @@ -4,34 +4,62 @@ export type ClientOptions = { baseUrl: `${string}://${string}/console/api` | (string & {}) } +export type EmailRegisterResetPayload = { + language?: string | null + new_password: string + password_confirm: string + timezone?: string | null + token: string +} + +export type EmailRegisterResetResponse = { + data: EmailRegisterTokenPairResponse + result: string +} + +export type EmailRegisterSendPayload = { + email: string + language?: string | null +} + export type SimpleResultDataResponse = { data: string result: string } +export type EmailRegisterValidityPayload = { + code: string + email: string + token: string +} + export type VerificationTokenResponse = { email: string is_valid: boolean token: string } +export type EmailRegisterTokenPairResponse = { + access_token: string + csrf_token: string + refresh_token: string +} + export type PostEmailRegisterData = { - body?: never + body: EmailRegisterResetPayload path?: never query?: never url: '/email-register' } export type PostEmailRegisterResponses = { - 200: { - [key: string]: unknown - } + 200: EmailRegisterResetResponse } export type PostEmailRegisterResponse = PostEmailRegisterResponses[keyof PostEmailRegisterResponses] export type PostEmailRegisterSendEmailData = { - body?: never + body: EmailRegisterSendPayload path?: never query?: never url: '/email-register/send-email' @@ -45,7 +73,7 @@ export type PostEmailRegisterSendEmailResponse = PostEmailRegisterSendEmailResponses[keyof PostEmailRegisterSendEmailResponses] export type PostEmailRegisterValidityData = { - body?: never + body: EmailRegisterValidityPayload path?: never query?: never url: '/email-register/validity' diff --git a/packages/contracts/generated/api/console/email-register/zod.gen.ts b/packages/contracts/generated/api/console/email-register/zod.gen.ts index 490777db901..ae7ba7d5663 100644 --- a/packages/contracts/generated/api/console/email-register/zod.gen.ts +++ b/packages/contracts/generated/api/console/email-register/zod.gen.ts @@ -2,6 +2,25 @@ import * as z from 'zod' +/** + * EmailRegisterResetPayload + */ +export const zEmailRegisterResetPayload = z.object({ + language: z.string().nullish(), + new_password: z.string(), + password_confirm: z.string(), + timezone: z.string().nullish(), + token: z.string(), +}) + +/** + * EmailRegisterSendPayload + */ +export const zEmailRegisterSendPayload = z.object({ + email: z.string(), + language: z.string().nullish(), +}) + /** * SimpleResultDataResponse */ @@ -10,6 +29,15 @@ export const zSimpleResultDataResponse = z.object({ result: z.string(), }) +/** + * EmailRegisterValidityPayload + */ +export const zEmailRegisterValidityPayload = z.object({ + code: z.string(), + email: z.string(), + token: z.string(), +}) + /** * VerificationTokenResponse */ @@ -19,16 +47,39 @@ export const zVerificationTokenResponse = z.object({ token: z.string(), }) +/** + * EmailRegisterTokenPairResponse + */ +export const zEmailRegisterTokenPairResponse = z.object({ + access_token: z.string(), + csrf_token: z.string(), + refresh_token: z.string(), +}) + +/** + * EmailRegisterResetResponse + */ +export const zEmailRegisterResetResponse = z.object({ + data: zEmailRegisterTokenPairResponse, + result: z.string(), +}) + +export const zPostEmailRegisterBody = zEmailRegisterResetPayload + /** * Success */ -export const zPostEmailRegisterResponse = z.record(z.string(), z.unknown()) +export const zPostEmailRegisterResponse = zEmailRegisterResetResponse + +export const zPostEmailRegisterSendEmailBody = zEmailRegisterSendPayload /** * Success */ export const zPostEmailRegisterSendEmailResponse = zSimpleResultDataResponse +export const zPostEmailRegisterValidityBody = zEmailRegisterValidityPayload + /** * Success */ diff --git a/packages/contracts/generated/api/console/explore/orpc.gen.ts b/packages/contracts/generated/api/console/explore/orpc.gen.ts index 4933c1ec12d..23a7ef8bc42 100644 --- a/packages/contracts/generated/api/console/explore/orpc.gen.ts +++ b/packages/contracts/generated/api/console/explore/orpc.gen.ts @@ -6,21 +6,31 @@ import * as z from 'zod' import { zGetExploreAppsByAppIdPath, zGetExploreAppsByAppIdResponse, + zGetExploreAppsLearnDifyQuery, + zGetExploreAppsLearnDifyResponse, zGetExploreAppsQuery, zGetExploreAppsResponse, + zGetExploreBannersQuery, zGetExploreBannersResponse, } from './zod.gen' -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const get = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getExploreAppsLearnDify', + path: '/explore/apps/learn-dify', + tags: ['console'], + }) + .input(z.object({ query: zGetExploreAppsLearnDifyQuery.optional() })) + .output(zGetExploreAppsLearnDifyResponse) + +export const learnDify = { + get, +} + +export const get2 = oc + .route({ inputStructure: 'detailed', method: 'GET', operationId: 'getExploreAppsByAppId', @@ -31,10 +41,10 @@ export const get = oc .output(zGetExploreAppsByAppIdResponse) export const byAppId = { - get, + get: get2, } -export const get2 = oc +export const get3 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -46,22 +56,16 @@ export const get2 = oc .output(zGetExploreAppsResponse) export const apps = { - get: get2, + get: get3, + learnDify, byAppId, } /** * Get banner list - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get3 = oc +export const get4 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getExploreBanners', @@ -69,10 +73,11 @@ export const get3 = oc summary: 'Get banner list', tags: ['default'], }) + .input(z.object({ query: zGetExploreBannersQuery.optional() })) .output(zGetExploreBannersResponse) export const banners = { - get: get3, + get: get4, } export const explore = { diff --git a/packages/contracts/generated/api/console/explore/types.gen.ts b/packages/contracts/generated/api/console/explore/types.gen.ts index 329c1f7722c..e5980e3c54d 100644 --- a/packages/contracts/generated/api/console/explore/types.gen.ts +++ b/packages/contracts/generated/api/console/explore/types.gen.ts @@ -9,8 +9,18 @@ export type RecommendedAppListResponse = { recommended_apps: Array } +export type LearnDifyAppListResponse = { + recommended_apps: Array +} + +export type RecommendedAppDetailResponse = { + [key: string]: unknown +} + +export type BannerListResponse = Array + export type RecommendedAppResponse = { - app?: RecommendedAppInfoResponse + app?: RecommendedAppInfoResponse | null app_id: string can_trial?: boolean | null categories?: Array @@ -22,6 +32,15 @@ export type RecommendedAppResponse = { privacy_policy?: string | null } +export type BannerResponse = { + content: unknown + created_at?: string | null + id: string + link?: string | null + sort: number + status: string +} + export type RecommendedAppInfoResponse = { icon?: string | null icon_background?: string | null @@ -46,6 +65,22 @@ export type GetExploreAppsResponses = { export type GetExploreAppsResponse = GetExploreAppsResponses[keyof GetExploreAppsResponses] +export type GetExploreAppsLearnDifyData = { + body?: never + path?: never + query?: { + language?: string + } + url: '/explore/apps/learn-dify' +} + +export type GetExploreAppsLearnDifyResponses = { + 200: LearnDifyAppListResponse +} + +export type GetExploreAppsLearnDifyResponse + = GetExploreAppsLearnDifyResponses[keyof GetExploreAppsLearnDifyResponses] + export type GetExploreAppsByAppIdData = { body?: never path: { @@ -56,9 +91,7 @@ export type GetExploreAppsByAppIdData = { } export type GetExploreAppsByAppIdResponses = { - 200: { - [key: string]: unknown - } + 200: RecommendedAppDetailResponse } export type GetExploreAppsByAppIdResponse @@ -67,14 +100,14 @@ export type GetExploreAppsByAppIdResponse export type GetExploreBannersData = { body?: never path?: never - query?: never + query?: { + language?: string + } url: '/explore/banners' } export type GetExploreBannersResponses = { - 200: { - [key: string]: unknown - } + 200: BannerListResponse } export type GetExploreBannersResponse = GetExploreBannersResponses[keyof GetExploreBannersResponses] diff --git a/packages/contracts/generated/api/console/explore/zod.gen.ts b/packages/contracts/generated/api/console/explore/zod.gen.ts index c65c47be918..9346796a86b 100644 --- a/packages/contracts/generated/api/console/explore/zod.gen.ts +++ b/packages/contracts/generated/api/console/explore/zod.gen.ts @@ -2,6 +2,28 @@ import * as z from 'zod' +/** + * RecommendedAppDetailResponse + */ +export const zRecommendedAppDetailResponse = z.record(z.string(), z.unknown()) + +/** + * BannerResponse + */ +export const zBannerResponse = z.object({ + content: z.unknown(), + created_at: z.string().nullish(), + id: z.string(), + link: z.string().nullish(), + sort: z.int(), + status: z.string(), +}) + +/** + * BannerListResponse + */ +export const zBannerListResponse = z.array(zBannerResponse) + /** * RecommendedAppInfoResponse */ @@ -18,7 +40,7 @@ export const zRecommendedAppInfoResponse = z.object({ * RecommendedAppResponse */ export const zRecommendedAppResponse = z.object({ - app: zRecommendedAppInfoResponse.optional(), + app: zRecommendedAppInfoResponse.nullish(), app_id: z.string(), can_trial: z.boolean().nullish(), categories: z.array(z.string()).optional(), @@ -38,6 +60,13 @@ export const zRecommendedAppListResponse = z.object({ recommended_apps: z.array(zRecommendedAppResponse), }) +/** + * LearnDifyAppListResponse + */ +export const zLearnDifyAppListResponse = z.object({ + recommended_apps: z.array(zRecommendedAppResponse), +}) + export const zGetExploreAppsQuery = z.object({ language: z.string().optional(), }) @@ -47,6 +76,15 @@ export const zGetExploreAppsQuery = z.object({ */ export const zGetExploreAppsResponse = zRecommendedAppListResponse +export const zGetExploreAppsLearnDifyQuery = z.object({ + language: z.string().optional(), +}) + +/** + * Success + */ +export const zGetExploreAppsLearnDifyResponse = zLearnDifyAppListResponse + export const zGetExploreAppsByAppIdPath = z.object({ app_id: z.string(), }) @@ -54,9 +92,13 @@ export const zGetExploreAppsByAppIdPath = z.object({ /** * Success */ -export const zGetExploreAppsByAppIdResponse = z.record(z.string(), z.unknown()) +export const zGetExploreAppsByAppIdResponse = zRecommendedAppDetailResponse + +export const zGetExploreBannersQuery = z.object({ + language: z.string().optional().default('en-US'), +}) /** * Success */ -export const zGetExploreBannersResponse = z.record(z.string(), z.unknown()) +export const zGetExploreBannersResponse = zBannerListResponse diff --git a/packages/contracts/generated/api/console/features/types.gen.ts b/packages/contracts/generated/api/console/features/types.gen.ts index 68b2dc0d9eb..da4dac47c24 100644 --- a/packages/contracts/generated/api/console/features/types.gen.ts +++ b/packages/contracts/generated/api/console/features/types.gen.ts @@ -22,7 +22,7 @@ export type FeatureModel = { model_load_balancing_enabled: boolean next_credit_reset_date: number trigger_event: Quota - vector_space: LimitationModel + vector_space: LimitationModel | null webapp_copyright_enabled: boolean workspace_members: LicenseLimitationModel } diff --git a/packages/contracts/generated/api/console/features/zod.gen.ts b/packages/contracts/generated/api/console/features/zod.gen.ts index 0e26f296b60..a5a66a25782 100644 --- a/packages/contracts/generated/api/console/features/zod.gen.ts +++ b/packages/contracts/generated/api/console/features/zod.gen.ts @@ -60,33 +60,48 @@ export const zSubscriptionModel = z.object({ */ export const zBillingModel = z.object({ enabled: z.boolean().default(false), - subscription: zSubscriptionModel, + subscription: zSubscriptionModel.default({ interval: '', plan: 'sandbox' }), }) /** * FeatureModel */ export const zFeatureModel = z.object({ - annotation_quota_limit: zLimitationModel, - api_rate_limit: zQuota, - apps: zLimitationModel, - billing: zBillingModel, + annotation_quota_limit: zLimitationModel.default({ limit: 10, size: 0 }), + api_rate_limit: zQuota.default({ + limit: 5000, + reset_date: 0, + usage: 0, + }), + apps: zLimitationModel.default({ limit: 10, size: 0 }), + billing: zBillingModel.default({ + enabled: false, + subscription: { interval: '', plan: 'sandbox' }, + }), can_replace_logo: z.boolean().default(false), dataset_operator_enabled: z.boolean().default(false), docs_processing: z.string().default('standard'), - documents_upload_quota: zLimitationModel, - education: zEducationModel, + documents_upload_quota: zLimitationModel.default({ limit: 50, size: 0 }), + education: zEducationModel.default({ activated: false, enabled: false }), human_input_email_delivery_enabled: z.boolean().default(false), is_allow_transfer_workspace: z.boolean().default(true), - knowledge_pipeline: zKnowledgePipeline, + knowledge_pipeline: zKnowledgePipeline.default({ publish_enabled: false }), knowledge_rate_limit: z.int().default(10), - members: zLimitationModel, + members: zLimitationModel.default({ limit: 1, size: 0 }), model_load_balancing_enabled: z.boolean().default(false), next_credit_reset_date: z.int().default(0), - trigger_event: zQuota, - vector_space: zLimitationModel, + trigger_event: zQuota.default({ + limit: 3000, + reset_date: 0, + usage: 0, + }), + vector_space: zLimitationModel.nullable().default({ limit: 5, size: 0 }), webapp_copyright_enabled: z.boolean().default(false), - workspace_members: zLicenseLimitationModel, + workspace_members: zLicenseLimitationModel.default({ + enabled: false, + limit: 0, + size: 0, + }), }) /** diff --git a/packages/contracts/generated/api/console/forgot-password/types.gen.ts b/packages/contracts/generated/api/console/forgot-password/types.gen.ts index b58165c8eb1..c551d799e5e 100644 --- a/packages/contracts/generated/api/console/forgot-password/types.gen.ts +++ b/packages/contracts/generated/api/console/forgot-password/types.gen.ts @@ -45,13 +45,9 @@ export type PostForgotPasswordData = { } export type PostForgotPasswordErrors = { - 400: { - [key: string]: unknown - } + 400: unknown } -export type PostForgotPasswordError = PostForgotPasswordErrors[keyof PostForgotPasswordErrors] - export type PostForgotPasswordResponses = { 200: ForgotPasswordEmailResponse } @@ -67,14 +63,9 @@ export type PostForgotPasswordResetsData = { } export type PostForgotPasswordResetsErrors = { - 400: { - [key: string]: unknown - } + 400: unknown } -export type PostForgotPasswordResetsError - = PostForgotPasswordResetsErrors[keyof PostForgotPasswordResetsErrors] - export type PostForgotPasswordResetsResponses = { 200: ForgotPasswordResetResponse } @@ -90,14 +81,9 @@ export type PostForgotPasswordValidityData = { } export type PostForgotPasswordValidityErrors = { - 400: { - [key: string]: unknown - } + 400: unknown } -export type PostForgotPasswordValidityError - = PostForgotPasswordValidityErrors[keyof PostForgotPasswordValidityErrors] - export type PostForgotPasswordValidityResponses = { 200: ForgotPasswordCheckResponse } diff --git a/packages/contracts/generated/api/console/form/orpc.gen.ts b/packages/contracts/generated/api/console/form/orpc.gen.ts index d28f1b4bb40..5cb831538f8 100644 --- a/packages/contracts/generated/api/console/form/orpc.gen.ts +++ b/packages/contracts/generated/api/console/form/orpc.gen.ts @@ -15,16 +15,10 @@ import { * Get human input form definition by form token * * GET /console/api/form/human_input/ - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get = oc .route({ - deprecated: true, - description: - 'GET /console/api/form/human_input/\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'GET /console/api/form/human_input/', inputStructure: 'detailed', method: 'GET', operationId: 'getFormHumanInputByFormToken', @@ -47,16 +41,11 @@ export const get = oc * }, * "action": "Approve" * } - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post = oc .route({ - deprecated: true, description: - 'POST /console/api/form/human_input/\n\nRequest body:\n{\n "inputs": {\n "content": "User input content"\n },\n "action": "Approve"\n}\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + 'POST /console/api/form/human_input/\n\nRequest body:\n{\n "inputs": {\n "content": "User input content"\n },\n "action": "Approve"\n}', inputStructure: 'detailed', method: 'POST', operationId: 'postFormHumanInputByFormToken', diff --git a/packages/contracts/generated/api/console/form/types.gen.ts b/packages/contracts/generated/api/console/form/types.gen.ts index fb908f1c705..9565a18219c 100644 --- a/packages/contracts/generated/api/console/form/types.gen.ts +++ b/packages/contracts/generated/api/console/form/types.gen.ts @@ -4,6 +4,10 @@ export type ClientOptions = { baseUrl: `${string}://${string}/console/api` | (string & {}) } +export type ConsoleHumanInputFormDefinitionResponse = { + [key: string]: unknown +} + export type HumanInputFormSubmitPayload = { action: string form_inputs: { @@ -14,6 +18,10 @@ export type HumanInputFormSubmitPayload = { } } +export type ConsoleHumanInputFormSubmitResponse = { + [key: string]: unknown +} + export type GetFormHumanInputByFormTokenData = { body?: never path: { @@ -24,9 +32,7 @@ export type GetFormHumanInputByFormTokenData = { } export type GetFormHumanInputByFormTokenResponses = { - 200: { - [key: string]: unknown - } + 200: ConsoleHumanInputFormDefinitionResponse } export type GetFormHumanInputByFormTokenResponse @@ -42,9 +48,7 @@ export type PostFormHumanInputByFormTokenData = { } export type PostFormHumanInputByFormTokenResponses = { - 200: { - [key: string]: unknown - } + 200: ConsoleHumanInputFormSubmitResponse } export type PostFormHumanInputByFormTokenResponse diff --git a/packages/contracts/generated/api/console/form/zod.gen.ts b/packages/contracts/generated/api/console/form/zod.gen.ts index 8d74f49963c..54029facb7c 100644 --- a/packages/contracts/generated/api/console/form/zod.gen.ts +++ b/packages/contracts/generated/api/console/form/zod.gen.ts @@ -2,6 +2,11 @@ import * as z from 'zod' +/** + * ConsoleHumanInputFormDefinitionResponse + */ +export const zConsoleHumanInputFormDefinitionResponse = z.record(z.string(), z.unknown()) + /** * HumanInputFormSubmitPayload */ @@ -11,6 +16,11 @@ export const zHumanInputFormSubmitPayload = z.object({ inputs: z.record(z.string(), z.unknown()), }) +/** + * ConsoleHumanInputFormSubmitResponse + */ +export const zConsoleHumanInputFormSubmitResponse = z.record(z.string(), z.unknown()) + export const zGetFormHumanInputByFormTokenPath = z.object({ form_token: z.string(), }) @@ -18,7 +28,7 @@ export const zGetFormHumanInputByFormTokenPath = z.object({ /** * Success */ -export const zGetFormHumanInputByFormTokenResponse = z.record(z.string(), z.unknown()) +export const zGetFormHumanInputByFormTokenResponse = zConsoleHumanInputFormDefinitionResponse export const zPostFormHumanInputByFormTokenBody = zHumanInputFormSubmitPayload @@ -29,4 +39,4 @@ export const zPostFormHumanInputByFormTokenPath = z.object({ /** * Success */ -export const zPostFormHumanInputByFormTokenResponse = z.record(z.string(), z.unknown()) +export const zPostFormHumanInputByFormTokenResponse = zConsoleHumanInputFormSubmitResponse diff --git a/packages/contracts/generated/api/console/info/types.gen.ts b/packages/contracts/generated/api/console/info/types.gen.ts index 88ee9fd59d2..ceeaeeadaae 100644 --- a/packages/contracts/generated/api/console/info/types.gen.ts +++ b/packages/contracts/generated/api/console/info/types.gen.ts @@ -6,7 +6,7 @@ export type ClientOptions = { export type TenantInfoResponse = { created_at?: number | null - custom_config?: WorkspaceCustomConfigResponse + custom_config?: WorkspaceCustomConfigResponse | null id: string in_trial?: boolean | null name?: string | null diff --git a/packages/contracts/generated/api/console/info/zod.gen.ts b/packages/contracts/generated/api/console/info/zod.gen.ts index f903e9307d9..b3c4d966cec 100644 --- a/packages/contracts/generated/api/console/info/zod.gen.ts +++ b/packages/contracts/generated/api/console/info/zod.gen.ts @@ -15,7 +15,7 @@ export const zWorkspaceCustomConfigResponse = z.object({ */ export const zTenantInfoResponse = z.object({ created_at: z.int().nullish(), - custom_config: zWorkspaceCustomConfigResponse.optional(), + custom_config: zWorkspaceCustomConfigResponse.nullish(), id: z.string(), in_trial: z.boolean().nullish(), name: z.string().nullish(), diff --git a/packages/contracts/generated/api/console/installed-apps/orpc.gen.ts b/packages/contracts/generated/api/console/installed-apps/orpc.gen.ts index 414e72dcb21..026f3bdf513 100644 --- a/packages/contracts/generated/api/console/installed-apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/installed-apps/orpc.gen.ts @@ -28,13 +28,16 @@ import { zGetInstalledAppsByInstalledAppIdSavedMessagesPath, zGetInstalledAppsByInstalledAppIdSavedMessagesQuery, zGetInstalledAppsByInstalledAppIdSavedMessagesResponse, + zGetInstalledAppsQuery, zGetInstalledAppsResponse, + zPatchInstalledAppsByInstalledAppIdBody, zPatchInstalledAppsByInstalledAppIdConversationsByCIdPinPath, zPatchInstalledAppsByInstalledAppIdConversationsByCIdPinResponse, zPatchInstalledAppsByInstalledAppIdConversationsByCIdUnpinPath, zPatchInstalledAppsByInstalledAppIdConversationsByCIdUnpinResponse, zPatchInstalledAppsByInstalledAppIdPath, zPatchInstalledAppsByInstalledAppIdResponse, + zPostInstalledAppsBody, zPostInstalledAppsByInstalledAppIdAudioToTextPath, zPostInstalledAppsByInstalledAppIdAudioToTextResponse, zPostInstalledAppsByInstalledAppIdChatMessagesBody, @@ -67,16 +70,8 @@ import { zPostInstalledAppsResponse, } from './zod.gen' -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postInstalledAppsByInstalledAppIdAudioToText', @@ -109,16 +104,8 @@ export const byTaskId = { stop, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post3 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postInstalledAppsByInstalledAppIdChatMessages', @@ -157,16 +144,8 @@ export const byTaskId2 = { stop: stop2, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post5 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postInstalledAppsByInstalledAppIdCompletionMessages', @@ -186,16 +165,8 @@ export const completionMessages = { byTaskId: byTaskId2, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post6 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postInstalledAppsByInstalledAppIdConversationsByCIdName', @@ -263,16 +234,8 @@ export const byCId = { unpin, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const get = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getInstalledAppsByInstalledAppIdConversations', @@ -312,16 +275,8 @@ export const feedbacks = { post: post7, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const get2 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getInstalledAppsByInstalledAppIdMessagesByMessageIdMoreLikeThis', @@ -365,16 +320,8 @@ export const byMessageId = { suggestedQuestions, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const get4 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getInstalledAppsByInstalledAppIdMessages', @@ -396,16 +343,9 @@ export const messages = { /** * Get app meta - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get5 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getInstalledAppsByInstalledAppIdMeta', @@ -422,16 +362,9 @@ export const meta = { /** * Retrieve app parameters - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get6 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getInstalledAppsByInstalledAppIdParameters', @@ -462,16 +395,8 @@ export const byMessageId2 = { delete: delete2, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const get7 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getInstalledAppsByInstalledAppIdSavedMessages', @@ -508,16 +433,8 @@ export const savedMessages = { byMessageId: byMessageId2, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post9 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postInstalledAppsByInstalledAppIdTextToAudio', @@ -538,16 +455,9 @@ export const textToAudio = { /** * Run workflow - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post10 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postInstalledAppsByInstalledAppIdWorkflowsRun', @@ -611,23 +521,20 @@ export const delete3 = oc .input(z.object({ params: zDeleteInstalledAppsByInstalledAppIdPath })) .output(zDeleteInstalledAppsByInstalledAppIdResponse) -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const patch3 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchInstalledAppsByInstalledAppId', path: '/installed-apps/{installed_app_id}', tags: ['console'], }) - .input(z.object({ params: zPatchInstalledAppsByInstalledAppIdPath })) + .input( + z.object({ + body: zPatchInstalledAppsByInstalledAppIdBody, + params: zPatchInstalledAppsByInstalledAppIdPath, + }), + ) .output(zPatchInstalledAppsByInstalledAppIdResponse) export const byInstalledAppId = { @@ -653,24 +560,18 @@ export const get8 = oc path: '/installed-apps', tags: ['console'], }) + .input(z.object({ query: zGetInstalledAppsQuery.optional() })) .output(zGetInstalledAppsResponse) -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post12 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postInstalledApps', path: '/installed-apps', tags: ['console'], }) + .input(z.object({ body: zPostInstalledAppsBody })) .output(zPostInstalledAppsResponse) export const installedApps = { diff --git a/packages/contracts/generated/api/console/installed-apps/types.gen.ts b/packages/contracts/generated/api/console/installed-apps/types.gen.ts index b1b08934c84..6111cf7e104 100644 --- a/packages/contracts/generated/api/console/installed-apps/types.gen.ts +++ b/packages/contracts/generated/api/console/installed-apps/types.gen.ts @@ -8,15 +8,27 @@ export type InstalledAppListResponse = { installed_apps: Array } +export type InstalledAppCreatePayload = { + app_id: string +} + export type SimpleMessageResponse = { message: string } +export type InstalledAppUpdatePayload = { + is_pinned?: boolean | null +} + export type SimpleResultMessageResponse = { message: string result: string } +export type AudioTranscriptResponse = { + text: string +} + export type ChatMessagePayload = { conversation_id?: string | null files?: Array | null @@ -32,6 +44,8 @@ export type ChatMessagePayload = { retriever_from?: string } +export type GeneratedAppResponse = JsonValue + export type SimpleResultResponse = { result: string } @@ -48,15 +62,39 @@ export type CompletionMessageExplorePayload = { retriever_from?: string } +export type ConversationInfiniteScrollPagination = { + data: Array + has_more: boolean + limit: number +} + export type ConversationRenamePayload = { auto_generate?: boolean name?: string | null } +export type SimpleConversation = { + created_at?: number | null + id: string + inputs: { + [key: string]: JsonValue + } + introduction?: string | null + name: string + status: string + updated_at?: number | null +} + export type ResultResponse = { result: string } +export type MessageInfiniteScrollPagination = { + data: Array + has_more: boolean + limit: number +} + export type MessageFeedbackPayload = { content?: string | null message_id: string @@ -67,6 +105,33 @@ export type SuggestedQuestionsResponse = { data: Array } +export type ExploreAppMetaResponse = { + tool_icons?: { + [key: string]: unknown + } +} + +export type Parameters = { + annotation_reply: JsonObject + file_upload: JsonObject + more_like_this: JsonObject + opening_statement?: string | null + retriever_resource: JsonObject + sensitive_word_avoidance: JsonObject + speech_to_text: JsonObject + suggested_questions: Array + suggested_questions_after_answer: JsonObject + system_parameters: SystemParameters + text_to_speech: JsonObject + user_input_form: Array +} + +export type SavedMessageInfiniteScrollPagination = { + data: Array + has_more: boolean + limit: number +} + export type SavedMessageCreatePayload = { message_id: string } @@ -78,6 +143,8 @@ export type TextToAudioPayload = { voice?: string | null } +export type AudioBinaryResponse = Blob | File + export type WorkflowRunPayload = { files?: Array<{ [key: string]: unknown @@ -97,6 +164,60 @@ export type InstalledAppResponse = { uninstallable: boolean } +export type JsonValue + = | string + | number + | number + | boolean + | { + [key: string]: unknown + } + | Array + | null + +export type MessageListItem = { + agent_thoughts: Array + answer: string + conversation_id: string + created_at?: number | null + error?: string | null + extra_contents: Array + feedback?: SimpleFeedback | null + id: string + inputs: { + [key: string]: JsonValueType + } + message_files: Array + parent_message_id?: string | null + query: string + retriever_resources: Array + status: string +} + +export type JsonObject = { + [key: string]: unknown +} + +export type SystemParameters = { + audio_file_size_limit: number + file_size_limit: number + image_file_size_limit: number + video_file_size_limit: number + workflow_file_upload_limit: number +} + +export type SavedMessageItem = { + answer: string + created_at?: number | null + feedback?: SimpleFeedback | null + id: string + inputs: { + [key: string]: JsonValueType + } + message_files: Array + query: string +} + export type InstalledAppInfoResponse = { icon?: string | null icon_background?: string | null @@ -107,10 +228,172 @@ export type InstalledAppInfoResponse = { use_icon_as_answer_icon?: boolean | null } +export type AgentThought = { + chain_id?: string | null + created_at?: number | null + files: Array + id: string + message_chain_id?: string | null + message_id: string + observation?: string | null + position: number + thought?: string | null + tool?: string | null + tool_input?: string | null + tool_labels: JsonValue +} + +export type HumanInputContent = { + form_definition?: HumanInputFormDefinition | null + form_submission_data?: HumanInputFormSubmissionData | null + submitted: boolean + type?: ExecutionContentType + workflow_run_id: string +} + +export type SimpleFeedback = { + rating?: string | null +} + +export type JsonValueType = unknown + +export type MessageFile = { + belongs_to?: string | null + filename: string + id: string + mime_type?: string | null + size?: number | null + transfer_method: string + type: string + upload_file_id?: string | null + url?: string | null +} + +export type RetrieverResource = { + content?: string | null + created_at?: number | null + data_source_type?: string | null + dataset_id?: string | null + dataset_name?: string | null + document_id?: string | null + document_name?: string | null + hit_count?: number | null + id?: string + index_node_hash?: string | null + message_id?: string + position: number + score?: number | null + segment_id?: string | null + segment_position?: number | null + summary?: string | null + word_count?: number | null +} + +export type HumanInputFormDefinition = { + actions?: Array + display_in_ui?: boolean + expiration_time: number + form_content: string + form_id: string + form_token?: string | null + inputs?: Array + node_id: string + node_title: string + resolved_default_values?: { + [key: string]: unknown + } +} + +export type HumanInputFormSubmissionData = { + action_id: string + action_text: string + node_id: string + node_title: string + rendered_content: string + submitted_data?: { + [key: string]: JsonValue2 + } | null +} + +export type ExecutionContentType = 'human_input' + +export type UserActionConfig = { + button_style?: ButtonStyle + id: string + title: string +} + +export type FormInputConfig + = | ({ + type: 'paragraph' + } & ParagraphInputConfig) + | ({ + type: 'select' + } & SelectInputConfig) + | ({ + type: 'file' + } & FileInputConfig) + | ({ + type: 'file-list' + } & FileListInputConfig) + +export type JsonValue2 = unknown + +export type ButtonStyle = 'accent' | 'default' | 'ghost' | 'primary' + +export type ParagraphInputConfig = { + default?: StringSource | null + output_variable_name: string + type?: 'paragraph' +} + +export type SelectInputConfig = { + option_source: StringListSource + output_variable_name: string + type?: 'select' +} + +export type FileInputConfig = { + allowed_file_extensions?: Array + allowed_file_types?: Array + allowed_file_upload_methods?: Array + output_variable_name: string + type?: 'file' +} + +export type FileListInputConfig = { + allowed_file_extensions?: Array + allowed_file_types?: Array + allowed_file_upload_methods?: Array + number_limits?: number + output_variable_name: string + type?: 'file-list' +} + +export type StringSource = { + selector?: Array + type: ValueSourceType + value?: string +} + +export type StringListSource = { + selector?: Array + type: ValueSourceType + value?: Array +} + +export type FileType = 'audio' | 'custom' | 'document' | 'image' | 'video' + +export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url' | 'tool_file' + +export type ValueSourceType = 'constant' | 'variable' + export type GetInstalledAppsData = { body?: never path?: never - query?: never + query?: { + app_id?: string + } url: '/installed-apps' } @@ -121,7 +404,7 @@ export type GetInstalledAppsResponses = { export type GetInstalledAppsResponse = GetInstalledAppsResponses[keyof GetInstalledAppsResponses] export type PostInstalledAppsData = { - body?: never + body: InstalledAppCreatePayload path?: never query?: never url: '/installed-apps' @@ -143,16 +426,14 @@ export type DeleteInstalledAppsByInstalledAppIdData = { } export type DeleteInstalledAppsByInstalledAppIdResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteInstalledAppsByInstalledAppIdResponse = DeleteInstalledAppsByInstalledAppIdResponses[keyof DeleteInstalledAppsByInstalledAppIdResponses] export type PatchInstalledAppsByInstalledAppIdData = { - body?: never + body: InstalledAppUpdatePayload path: { installed_app_id: string } @@ -177,9 +458,7 @@ export type PostInstalledAppsByInstalledAppIdAudioToTextData = { } export type PostInstalledAppsByInstalledAppIdAudioToTextResponses = { - 200: { - [key: string]: unknown - } + 200: AudioTranscriptResponse } export type PostInstalledAppsByInstalledAppIdAudioToTextResponse @@ -195,9 +474,7 @@ export type PostInstalledAppsByInstalledAppIdChatMessagesData = { } export type PostInstalledAppsByInstalledAppIdChatMessagesResponses = { - 200: { - [key: string]: unknown - } + 200: GeneratedAppResponse } export type PostInstalledAppsByInstalledAppIdChatMessagesResponse @@ -230,9 +507,7 @@ export type PostInstalledAppsByInstalledAppIdCompletionMessagesData = { } export type PostInstalledAppsByInstalledAppIdCompletionMessagesResponses = { - 200: { - [key: string]: unknown - } + 200: GeneratedAppResponse } export type PostInstalledAppsByInstalledAppIdCompletionMessagesResponse @@ -261,17 +536,15 @@ export type GetInstalledAppsByInstalledAppIdConversationsData = { installed_app_id: string } query?: { - last_id?: string | null + last_id?: string limit?: number - pinned?: boolean | null + pinned?: boolean } url: '/installed-apps/{installed_app_id}/conversations' } export type GetInstalledAppsByInstalledAppIdConversationsResponses = { - 200: { - [key: string]: unknown - } + 200: ConversationInfiniteScrollPagination } export type GetInstalledAppsByInstalledAppIdConversationsResponse @@ -288,9 +561,7 @@ export type DeleteInstalledAppsByInstalledAppIdConversationsByCIdData = { } export type DeleteInstalledAppsByInstalledAppIdConversationsByCIdResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteInstalledAppsByInstalledAppIdConversationsByCIdResponse @@ -307,9 +578,7 @@ export type PostInstalledAppsByInstalledAppIdConversationsByCIdNameData = { } export type PostInstalledAppsByInstalledAppIdConversationsByCIdNameResponses = { - 200: { - [key: string]: unknown - } + 200: SimpleConversation } export type PostInstalledAppsByInstalledAppIdConversationsByCIdNameResponse @@ -356,16 +625,14 @@ export type GetInstalledAppsByInstalledAppIdMessagesData = { } query: { conversation_id: string - first_id?: string | null + first_id?: string limit?: number } url: '/installed-apps/{installed_app_id}/messages' } export type GetInstalledAppsByInstalledAppIdMessagesResponses = { - 200: { - [key: string]: unknown - } + 200: MessageInfiniteScrollPagination } export type GetInstalledAppsByInstalledAppIdMessagesResponse @@ -401,9 +668,7 @@ export type GetInstalledAppsByInstalledAppIdMessagesByMessageIdMoreLikeThisData } export type GetInstalledAppsByInstalledAppIdMessagesByMessageIdMoreLikeThisResponses = { - 200: { - [key: string]: unknown - } + 200: GeneratedAppResponse } export type GetInstalledAppsByInstalledAppIdMessagesByMessageIdMoreLikeThisResponse @@ -436,9 +701,7 @@ export type GetInstalledAppsByInstalledAppIdMetaData = { } export type GetInstalledAppsByInstalledAppIdMetaResponses = { - 200: { - [key: string]: unknown - } + 200: ExploreAppMetaResponse } export type GetInstalledAppsByInstalledAppIdMetaResponse @@ -454,9 +717,7 @@ export type GetInstalledAppsByInstalledAppIdParametersData = { } export type GetInstalledAppsByInstalledAppIdParametersResponses = { - 200: { - [key: string]: unknown - } + 200: Parameters } export type GetInstalledAppsByInstalledAppIdParametersResponse @@ -468,16 +729,14 @@ export type GetInstalledAppsByInstalledAppIdSavedMessagesData = { installed_app_id: string } query?: { - last_id?: string | null + last_id?: string limit?: number } url: '/installed-apps/{installed_app_id}/saved-messages' } export type GetInstalledAppsByInstalledAppIdSavedMessagesResponses = { - 200: { - [key: string]: unknown - } + 200: SavedMessageInfiniteScrollPagination } export type GetInstalledAppsByInstalledAppIdSavedMessagesResponse @@ -510,9 +769,7 @@ export type DeleteInstalledAppsByInstalledAppIdSavedMessagesByMessageIdData = { } export type DeleteInstalledAppsByInstalledAppIdSavedMessagesByMessageIdResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteInstalledAppsByInstalledAppIdSavedMessagesByMessageIdResponse @@ -528,9 +785,7 @@ export type PostInstalledAppsByInstalledAppIdTextToAudioData = { } export type PostInstalledAppsByInstalledAppIdTextToAudioResponses = { - 200: { - [key: string]: unknown - } + 200: AudioBinaryResponse } export type PostInstalledAppsByInstalledAppIdTextToAudioResponse @@ -546,9 +801,7 @@ export type PostInstalledAppsByInstalledAppIdWorkflowsRunData = { } export type PostInstalledAppsByInstalledAppIdWorkflowsRunResponses = { - 200: { - [key: string]: unknown - } + 200: GeneratedAppResponse } export type PostInstalledAppsByInstalledAppIdWorkflowsRunResponse diff --git a/packages/contracts/generated/api/console/installed-apps/zod.gen.ts b/packages/contracts/generated/api/console/installed-apps/zod.gen.ts index 6fd4856c933..055b53936bf 100644 --- a/packages/contracts/generated/api/console/installed-apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/installed-apps/zod.gen.ts @@ -2,6 +2,13 @@ import * as z from 'zod' +/** + * InstalledAppCreatePayload + */ +export const zInstalledAppCreatePayload = z.object({ + app_id: z.string(), +}) + /** * SimpleMessageResponse */ @@ -9,6 +16,13 @@ export const zSimpleMessageResponse = z.object({ message: z.string(), }) +/** + * InstalledAppUpdatePayload + */ +export const zInstalledAppUpdatePayload = z.object({ + is_pinned: z.boolean().nullish(), +}) + /** * SimpleResultMessageResponse */ @@ -17,6 +31,13 @@ export const zSimpleResultMessageResponse = z.object({ result: z.string(), }) +/** + * AudioTranscriptResponse + */ +export const zAudioTranscriptResponse = z.object({ + text: z.string(), +}) + /** * ChatMessagePayload */ @@ -80,6 +101,13 @@ export const zSuggestedQuestionsResponse = z.object({ data: z.array(z.string()), }) +/** + * ExploreAppMetaResponse + */ +export const zExploreAppMetaResponse = z.object({ + tool_icons: z.record(z.string(), z.unknown()).optional(), +}) + /** * SavedMessageCreatePayload */ @@ -97,6 +125,11 @@ export const zTextToAudioPayload = z.object({ voice: z.string().nullish(), }) +/** + * AudioBinaryResponse + */ +export const zAudioBinaryResponse = z.custom() + /** * WorkflowRunPayload */ @@ -105,6 +138,75 @@ export const zWorkflowRunPayload = z.object({ inputs: z.record(z.string(), z.unknown()), }) +export const zJsonValue = z + .union([ + z.string(), + z.int(), + z.number(), + z.boolean(), + z.record(z.string(), z.unknown()), + z.array(z.unknown()), + ]) + .nullable() + +/** + * GeneratedAppResponse + */ +export const zGeneratedAppResponse = zJsonValue + +/** + * SimpleConversation + */ +export const zSimpleConversation = z.object({ + created_at: z.int().nullish(), + id: z.string(), + inputs: z.record(z.string(), zJsonValue), + introduction: z.string().nullish(), + name: z.string(), + status: z.string(), + updated_at: z.int().nullish(), +}) + +/** + * ConversationInfiniteScrollPagination + */ +export const zConversationInfiniteScrollPagination = z.object({ + data: z.array(zSimpleConversation), + has_more: z.boolean(), + limit: z.int(), +}) + +export const zJsonObject = z.record(z.string(), z.unknown()) + +/** + * SystemParameters + */ +export const zSystemParameters = z.object({ + audio_file_size_limit: z.int(), + file_size_limit: z.int(), + image_file_size_limit: z.int(), + video_file_size_limit: z.int(), + workflow_file_upload_limit: z.int(), +}) + +/** + * Parameters + */ +export const zParameters = z.object({ + annotation_reply: zJsonObject, + file_upload: zJsonObject, + more_like_this: zJsonObject, + opening_statement: z.string().nullish(), + retriever_resource: zJsonObject, + sensitive_word_avoidance: zJsonObject, + speech_to_text: zJsonObject, + suggested_questions: z.array(z.string()), + suggested_questions_after_answer: zJsonObject, + system_parameters: zSystemParameters, + text_to_speech: zJsonObject, + user_input_form: z.array(zJsonObject), +}) + /** * InstalledAppInfoResponse */ @@ -138,11 +240,290 @@ export const zInstalledAppListResponse = z.object({ installed_apps: z.array(zInstalledAppResponse), }) +/** + * AgentThought + */ +export const zAgentThought = z.object({ + chain_id: z.string().nullish(), + 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(), + thought: z.string().nullish(), + tool: z.string().nullish(), + tool_input: z.string().nullish(), + tool_labels: zJsonValue, +}) + +/** + * SimpleFeedback + */ +export const zSimpleFeedback = z.object({ + rating: z.string().nullish(), +}) + +export const zJsonValueType = z.unknown() + +/** + * MessageFile + */ +export const zMessageFile = z.object({ + belongs_to: z.string().nullish(), + filename: z.string(), + id: z.string(), + mime_type: z.string().nullish(), + size: z.int().nullish(), + transfer_method: z.string(), + type: z.string(), + upload_file_id: z.string().nullish(), + url: z.string().nullish(), +}) + +/** + * SavedMessageItem + */ +export const zSavedMessageItem = z.object({ + answer: z.string(), + created_at: z.int().nullish(), + feedback: zSimpleFeedback.nullish(), + id: z.string(), + inputs: z.record(z.string(), zJsonValueType), + message_files: z.array(zMessageFile), + query: z.string(), +}) + +/** + * SavedMessageInfiniteScrollPagination + */ +export const zSavedMessageInfiniteScrollPagination = z.object({ + data: z.array(zSavedMessageItem), + has_more: z.boolean(), + limit: z.int(), +}) + +/** + * RetrieverResource + */ +export const zRetrieverResource = z.object({ + content: z.string().nullish(), + created_at: z.int().nullish(), + data_source_type: z.string().nullish(), + dataset_id: z.string().nullish(), + dataset_name: z.string().nullish(), + document_id: z.string().nullish(), + document_name: z.string().nullish(), + hit_count: z.int().nullish(), + id: z.string().optional(), + index_node_hash: z.string().nullish(), + message_id: z.string().optional(), + position: z.int(), + score: z.number().nullish(), + segment_id: z.string().nullish(), + segment_position: z.int().nullish(), + summary: z.string().nullish(), + word_count: z.int().nullish(), +}) + +/** + * ExecutionContentType + */ +export const zExecutionContentType = z.enum(['human_input']) + +export const zJsonValue2 = z.unknown() + +/** + * HumanInputFormSubmissionData + */ +export const zHumanInputFormSubmissionData = z.object({ + action_id: z.string(), + action_text: z.string(), + node_id: z.string(), + node_title: z.string(), + rendered_content: z.string(), + submitted_data: z.record(z.string(), zJsonValue2).nullish(), +}) + +/** + * ButtonStyle + * + * Button styles for user actions. + */ +export const zButtonStyle = z.enum(['accent', 'default', 'ghost', 'primary']) + +/** + * UserActionConfig + * + * User action configuration. + */ +export const zUserActionConfig = z.object({ + button_style: zButtonStyle.optional().default('default'), + id: z.string().max(20), + title: z.string().max(100), +}) + +/** + * FileType + */ +export const zFileType = z.enum(['audio', 'custom', 'document', 'image', 'video']) + +/** + * FileTransferMethod + */ +export const zFileTransferMethod = z.enum([ + 'datasource_file', + 'local_file', + 'remote_url', + 'tool_file', +]) + +/** + * FileInputConfig + */ +export const zFileInputConfig = z.object({ + allowed_file_extensions: z.array(z.string()).optional(), + allowed_file_types: z.array(zFileType).optional(), + allowed_file_upload_methods: z.array(zFileTransferMethod).optional(), + output_variable_name: z.string(), + type: z.literal('file').optional().default('file'), +}) + +/** + * FileListInputConfig + */ +export const zFileListInputConfig = z.object({ + allowed_file_extensions: z.array(z.string()).optional(), + allowed_file_types: z.array(zFileType).optional(), + allowed_file_upload_methods: z.array(zFileTransferMethod).optional(), + number_limits: z.int().gte(0).optional().default(0), + output_variable_name: z.string(), + type: z.literal('file-list').optional().default('file-list'), +}) + +/** + * ValueSourceType + * + * ValueSourceType records whether the value comes from a static setting + * in form definiton, or a variable while the workflow is running. + */ +export const zValueSourceType = z.enum(['constant', 'variable']) + +/** + * StringSource + * + * Default configuration for form inputs. + */ +export const zStringSource = z.object({ + selector: z.array(z.string()).optional(), + type: zValueSourceType, + value: z.string().optional().default(''), +}) + +/** + * ParagraphInputConfig + * + * Form input definition. + */ +export const zParagraphInputConfig = z.object({ + default: zStringSource.nullish(), + output_variable_name: z.string(), + type: z.literal('paragraph').optional().default('paragraph'), +}) + +/** + * StringListSource + */ +export const zStringListSource = z.object({ + selector: z.array(z.string()).optional(), + type: zValueSourceType, + value: z.array(z.string()).optional(), +}) + +/** + * SelectInputConfig + */ +export const zSelectInputConfig = z.object({ + option_source: zStringListSource, + output_variable_name: z.string(), + type: z.literal('select').optional().default('select'), +}) + +export const zFormInputConfig = z.discriminatedUnion('type', [ + zParagraphInputConfig.extend({ type: z.literal('paragraph') }), + zSelectInputConfig.extend({ type: z.literal('select') }), + zFileInputConfig.extend({ type: z.literal('file') }), + zFileListInputConfig.extend({ type: z.literal('file-list') }), +]) + +/** + * HumanInputFormDefinition + */ +export const zHumanInputFormDefinition = z.object({ + actions: z.array(zUserActionConfig).optional(), + display_in_ui: z.boolean().optional().default(false), + expiration_time: z.int(), + form_content: z.string(), + form_id: z.string(), + form_token: z.string().nullish(), + inputs: z.array(zFormInputConfig).optional(), + node_id: z.string(), + node_title: z.string(), + resolved_default_values: z.record(z.string(), z.unknown()).optional(), +}) + +/** + * HumanInputContent + */ +export const zHumanInputContent = z.object({ + form_definition: zHumanInputFormDefinition.nullish(), + form_submission_data: zHumanInputFormSubmissionData.nullish(), + submitted: z.boolean(), + type: zExecutionContentType.optional().default('human_input'), + workflow_run_id: z.string(), +}) + +/** + * MessageListItem + */ +export const zMessageListItem = z.object({ + agent_thoughts: z.array(zAgentThought), + answer: z.string(), + conversation_id: z.string(), + created_at: z.int().nullish(), + error: z.string().nullish(), + extra_contents: z.array(zHumanInputContent), + feedback: zSimpleFeedback.nullish(), + id: z.string(), + inputs: z.record(z.string(), zJsonValueType), + message_files: z.array(zMessageFile), + parent_message_id: z.string().nullish(), + query: z.string(), + retriever_resources: z.array(zRetrieverResource), + status: z.string(), +}) + +/** + * MessageInfiniteScrollPagination + */ +export const zMessageInfiniteScrollPagination = z.object({ + data: z.array(zMessageListItem), + has_more: z.boolean(), + limit: z.int(), +}) + +export const zGetInstalledAppsQuery = z.object({ + app_id: z.string().optional(), +}) + /** * Success */ export const zGetInstalledAppsResponse = zInstalledAppListResponse +export const zPostInstalledAppsBody = zInstalledAppCreatePayload + /** * Success */ @@ -155,7 +536,9 @@ export const zDeleteInstalledAppsByInstalledAppIdPath = z.object({ /** * App uninstalled successfully */ -export const zDeleteInstalledAppsByInstalledAppIdResponse = z.record(z.string(), z.never()) +export const zDeleteInstalledAppsByInstalledAppIdResponse = z.void() + +export const zPatchInstalledAppsByInstalledAppIdBody = zInstalledAppUpdatePayload export const zPatchInstalledAppsByInstalledAppIdPath = z.object({ installed_app_id: z.string(), @@ -173,10 +556,7 @@ export const zPostInstalledAppsByInstalledAppIdAudioToTextPath = z.object({ /** * Success */ -export const zPostInstalledAppsByInstalledAppIdAudioToTextResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostInstalledAppsByInstalledAppIdAudioToTextResponse = zAudioTranscriptResponse export const zPostInstalledAppsByInstalledAppIdChatMessagesBody = zChatMessagePayload @@ -187,10 +567,7 @@ export const zPostInstalledAppsByInstalledAppIdChatMessagesPath = z.object({ /** * Success */ -export const zPostInstalledAppsByInstalledAppIdChatMessagesResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostInstalledAppsByInstalledAppIdChatMessagesResponse = zGeneratedAppResponse export const zPostInstalledAppsByInstalledAppIdChatMessagesByTaskIdStopPath = z.object({ installed_app_id: z.string(), @@ -213,10 +590,7 @@ export const zPostInstalledAppsByInstalledAppIdCompletionMessagesPath = z.object /** * Success */ -export const zPostInstalledAppsByInstalledAppIdCompletionMessagesResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostInstalledAppsByInstalledAppIdCompletionMessagesResponse = zGeneratedAppResponse export const zPostInstalledAppsByInstalledAppIdCompletionMessagesByTaskIdStopPath = z.object({ installed_app_id: z.string(), @@ -234,18 +608,16 @@ export const zGetInstalledAppsByInstalledAppIdConversationsPath = z.object({ }) export const zGetInstalledAppsByInstalledAppIdConversationsQuery = z.object({ - last_id: z.string().nullish(), + last_id: z.string().optional(), limit: z.int().gte(1).lte(100).optional().default(20), - pinned: z.boolean().nullish(), + pinned: z.boolean().optional(), }) /** * Success */ -export const zGetInstalledAppsByInstalledAppIdConversationsResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetInstalledAppsByInstalledAppIdConversationsResponse + = zConversationInfiniteScrollPagination export const zDeleteInstalledAppsByInstalledAppIdConversationsByCIdPath = z.object({ c_id: z.string(), @@ -255,10 +627,7 @@ export const zDeleteInstalledAppsByInstalledAppIdConversationsByCIdPath = z.obje /** * Conversation deleted successfully */ -export const zDeleteInstalledAppsByInstalledAppIdConversationsByCIdResponse = z.record( - z.string(), - z.never(), -) +export const zDeleteInstalledAppsByInstalledAppIdConversationsByCIdResponse = z.void() export const zPostInstalledAppsByInstalledAppIdConversationsByCIdNameBody = zConversationRenamePayload @@ -269,12 +638,9 @@ export const zPostInstalledAppsByInstalledAppIdConversationsByCIdNamePath = z.ob }) /** - * Success + * Conversation renamed successfully */ -export const zPostInstalledAppsByInstalledAppIdConversationsByCIdNameResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostInstalledAppsByInstalledAppIdConversationsByCIdNameResponse = zSimpleConversation export const zPatchInstalledAppsByInstalledAppIdConversationsByCIdPinPath = z.object({ c_id: z.string(), @@ -302,14 +668,14 @@ export const zGetInstalledAppsByInstalledAppIdMessagesPath = z.object({ export const zGetInstalledAppsByInstalledAppIdMessagesQuery = z.object({ conversation_id: z.string(), - first_id: z.string().nullish(), + first_id: z.string().optional(), limit: z.int().gte(1).lte(100).optional().default(20), }) /** * Success */ -export const zGetInstalledAppsByInstalledAppIdMessagesResponse = z.record(z.string(), z.unknown()) +export const zGetInstalledAppsByInstalledAppIdMessagesResponse = zMessageInfiniteScrollPagination export const zPostInstalledAppsByInstalledAppIdMessagesByMessageIdFeedbacksBody = zMessageFeedbackPayload @@ -337,10 +703,8 @@ export const zGetInstalledAppsByInstalledAppIdMessagesByMessageIdMoreLikeThisQue /** * Success */ -export const zGetInstalledAppsByInstalledAppIdMessagesByMessageIdMoreLikeThisResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetInstalledAppsByInstalledAppIdMessagesByMessageIdMoreLikeThisResponse + = zGeneratedAppResponse export const zGetInstalledAppsByInstalledAppIdMessagesByMessageIdSuggestedQuestionsPath = z.object({ installed_app_id: z.string(), @@ -360,7 +724,7 @@ export const zGetInstalledAppsByInstalledAppIdMetaPath = z.object({ /** * Success */ -export const zGetInstalledAppsByInstalledAppIdMetaResponse = z.record(z.string(), z.unknown()) +export const zGetInstalledAppsByInstalledAppIdMetaResponse = zExploreAppMetaResponse export const zGetInstalledAppsByInstalledAppIdParametersPath = z.object({ installed_app_id: z.string(), @@ -369,24 +733,22 @@ export const zGetInstalledAppsByInstalledAppIdParametersPath = z.object({ /** * Success */ -export const zGetInstalledAppsByInstalledAppIdParametersResponse = z.record(z.string(), z.unknown()) +export const zGetInstalledAppsByInstalledAppIdParametersResponse = zParameters export const zGetInstalledAppsByInstalledAppIdSavedMessagesPath = z.object({ installed_app_id: z.string(), }) export const zGetInstalledAppsByInstalledAppIdSavedMessagesQuery = z.object({ - last_id: z.string().nullish(), + last_id: z.string().optional(), limit: z.int().gte(1).lte(100).optional().default(20), }) /** * Success */ -export const zGetInstalledAppsByInstalledAppIdSavedMessagesResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetInstalledAppsByInstalledAppIdSavedMessagesResponse + = zSavedMessageInfiniteScrollPagination export const zPostInstalledAppsByInstalledAppIdSavedMessagesBody = zSavedMessageCreatePayload @@ -407,10 +769,7 @@ export const zDeleteInstalledAppsByInstalledAppIdSavedMessagesByMessageIdPath = /** * Saved message deleted successfully */ -export const zDeleteInstalledAppsByInstalledAppIdSavedMessagesByMessageIdResponse = z.record( - z.string(), - z.never(), -) +export const zDeleteInstalledAppsByInstalledAppIdSavedMessagesByMessageIdResponse = z.void() export const zPostInstalledAppsByInstalledAppIdTextToAudioBody = zTextToAudioPayload @@ -421,10 +780,7 @@ export const zPostInstalledAppsByInstalledAppIdTextToAudioPath = z.object({ /** * Success */ -export const zPostInstalledAppsByInstalledAppIdTextToAudioResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostInstalledAppsByInstalledAppIdTextToAudioResponse = zAudioBinaryResponse export const zPostInstalledAppsByInstalledAppIdWorkflowsRunBody = zWorkflowRunPayload @@ -435,10 +791,7 @@ export const zPostInstalledAppsByInstalledAppIdWorkflowsRunPath = z.object({ /** * Success */ -export const zPostInstalledAppsByInstalledAppIdWorkflowsRunResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostInstalledAppsByInstalledAppIdWorkflowsRunResponse = zGeneratedAppResponse export const zPostInstalledAppsByInstalledAppIdWorkflowsTasksByTaskIdStopPath = z.object({ installed_app_id: z.string(), diff --git a/packages/contracts/generated/api/console/instruction-generate/orpc.gen.ts b/packages/contracts/generated/api/console/instruction-generate/orpc.gen.ts index f91220f369b..3aff6a9a3b6 100644 --- a/packages/contracts/generated/api/console/instruction-generate/orpc.gen.ts +++ b/packages/contracts/generated/api/console/instruction-generate/orpc.gen.ts @@ -12,16 +12,10 @@ import { /** * Get instruction generation template - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post = oc .route({ - deprecated: true, - description: - 'Get instruction generation template\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get instruction generation template', inputStructure: 'detailed', method: 'POST', operationId: 'postInstructionGenerateTemplate', @@ -37,16 +31,10 @@ export const template = { /** * Generate instruction for workflow nodes or general use - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post2 = oc .route({ - deprecated: true, - description: - 'Generate instruction for workflow nodes or general use\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Generate instruction for workflow nodes or general use', inputStructure: 'detailed', method: 'POST', operationId: 'postInstructionGenerate', diff --git a/packages/contracts/generated/api/console/instruction-generate/types.gen.ts b/packages/contracts/generated/api/console/instruction-generate/types.gen.ts index c17d0c451b5..82a9bee0864 100644 --- a/packages/contracts/generated/api/console/instruction-generate/types.gen.ts +++ b/packages/contracts/generated/api/console/instruction-generate/types.gen.ts @@ -14,10 +14,16 @@ export type InstructionGeneratePayload = { node_id?: string } +export type GeneratorResponse = unknown + export type InstructionTemplatePayload = { type: string } +export type SimpleDataResponse = { + data: string +} + export type ModelConfig = { completion_params?: { [key: string]: unknown @@ -37,21 +43,12 @@ export type PostInstructionGenerateData = { } export type PostInstructionGenerateErrors = { - 400: { - [key: string]: unknown - } - 402: { - [key: string]: unknown - } + 400: unknown + 402: unknown } -export type PostInstructionGenerateError - = PostInstructionGenerateErrors[keyof PostInstructionGenerateErrors] - export type PostInstructionGenerateResponses = { - 200: { - [key: string]: unknown - } + 200: GeneratorResponse } export type PostInstructionGenerateResponse @@ -65,18 +62,11 @@ export type PostInstructionGenerateTemplateData = { } export type PostInstructionGenerateTemplateErrors = { - 400: { - [key: string]: unknown - } + 400: unknown } -export type PostInstructionGenerateTemplateError - = PostInstructionGenerateTemplateErrors[keyof PostInstructionGenerateTemplateErrors] - export type PostInstructionGenerateTemplateResponses = { - 200: { - [key: string]: unknown - } + 200: SimpleDataResponse } export type PostInstructionGenerateTemplateResponse diff --git a/packages/contracts/generated/api/console/instruction-generate/zod.gen.ts b/packages/contracts/generated/api/console/instruction-generate/zod.gen.ts index b24c9b178f1..2d89050e2a0 100644 --- a/packages/contracts/generated/api/console/instruction-generate/zod.gen.ts +++ b/packages/contracts/generated/api/console/instruction-generate/zod.gen.ts @@ -2,6 +2,11 @@ import * as z from 'zod' +/** + * GeneratorResponse + */ +export const zGeneratorResponse = z.unknown() + /** * InstructionTemplatePayload */ @@ -9,6 +14,13 @@ export const zInstructionTemplatePayload = z.object({ type: z.string(), }) +/** + * SimpleDataResponse + */ +export const zSimpleDataResponse = z.object({ + data: z.string(), +}) + /** * LLMMode * @@ -44,11 +56,11 @@ export const zPostInstructionGenerateBody = zInstructionGeneratePayload /** * Instruction generated successfully */ -export const zPostInstructionGenerateResponse = z.record(z.string(), z.unknown()) +export const zPostInstructionGenerateResponse = zGeneratorResponse export const zPostInstructionGenerateTemplateBody = zInstructionTemplatePayload /** * Template retrieved successfully */ -export const zPostInstructionGenerateTemplateResponse = z.record(z.string(), z.unknown()) +export const zPostInstructionGenerateTemplateResponse = zSimpleDataResponse diff --git a/packages/contracts/generated/api/console/mcp/orpc.gen.ts b/packages/contracts/generated/api/console/mcp/orpc.gen.ts deleted file mode 100644 index 6508e34a427..00000000000 --- a/packages/contracts/generated/api/console/mcp/orpc.gen.ts +++ /dev/null @@ -1,39 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import { oc } from '@orpc/contract' - -import { zGetMcpOauthCallbackResponse } from './zod.gen' - -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get = oc - .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getMcpOauthCallback', - path: '/mcp/oauth/callback', - tags: ['console'], - }) - .output(zGetMcpOauthCallbackResponse) - -export const callback = { - get, -} - -export const oauth = { - callback, -} - -export const mcp = { - oauth, -} - -export const contract = { - mcp, -} diff --git a/packages/contracts/generated/api/console/mcp/types.gen.ts b/packages/contracts/generated/api/console/mcp/types.gen.ts deleted file mode 100644 index 4e96a663934..00000000000 --- a/packages/contracts/generated/api/console/mcp/types.gen.ts +++ /dev/null @@ -1,21 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -export type ClientOptions = { - baseUrl: `${string}://${string}/console/api` | (string & {}) -} - -export type GetMcpOauthCallbackData = { - body?: never - path?: never - query?: never - url: '/mcp/oauth/callback' -} - -export type GetMcpOauthCallbackResponses = { - 200: { - [key: string]: unknown - } -} - -export type GetMcpOauthCallbackResponse - = GetMcpOauthCallbackResponses[keyof GetMcpOauthCallbackResponses] diff --git a/packages/contracts/generated/api/console/mcp/zod.gen.ts b/packages/contracts/generated/api/console/mcp/zod.gen.ts deleted file mode 100644 index ade0c01f7ac..00000000000 --- a/packages/contracts/generated/api/console/mcp/zod.gen.ts +++ /dev/null @@ -1,8 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import * as z from 'zod' - -/** - * Success - */ -export const zGetMcpOauthCallbackResponse = z.record(z.string(), z.unknown()) diff --git a/packages/contracts/generated/api/console/notification/orpc.gen.ts b/packages/contracts/generated/api/console/notification/orpc.gen.ts index c7d55612afc..6fcfa31376f 100644 --- a/packages/contracts/generated/api/console/notification/orpc.gen.ts +++ b/packages/contracts/generated/api/console/notification/orpc.gen.ts @@ -1,27 +1,27 @@ // This file is auto-generated by @hey-api/openapi-ts import { oc } from '@orpc/contract' +import * as z from 'zod' -import { zGetNotificationResponse, zPostNotificationDismissResponse } from './zod.gen' +import { + zGetNotificationResponse, + zPostNotificationDismissBody, + zPostNotificationDismissResponse, +} from './zod.gen' /** * Mark a notification as dismissed for the current user. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post = oc .route({ - deprecated: true, - description: - 'Mark a notification as dismissed for the current user.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Mark a notification as dismissed for the current user.', inputStructure: 'detailed', method: 'POST', operationId: 'postNotificationDismiss', path: '/notification/dismiss', tags: ['console'], }) + .input(z.object({ body: zPostNotificationDismissBody })) .output(zPostNotificationDismissResponse) export const dismiss = { @@ -30,16 +30,11 @@ export const dismiss = { /** * 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. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get = oc .route({ - deprecated: true, description: - '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.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + '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.', inputStructure: 'detailed', method: 'GET', operationId: 'getNotification', diff --git a/packages/contracts/generated/api/console/notification/types.gen.ts b/packages/contracts/generated/api/console/notification/types.gen.ts index eb068725f38..07376b7a66c 100644 --- a/packages/contracts/generated/api/console/notification/types.gen.ts +++ b/packages/contracts/generated/api/console/notification/types.gen.ts @@ -4,10 +4,29 @@ export type ClientOptions = { baseUrl: `${string}://${string}/console/api` | (string & {}) } +export type NotificationResponse = { + notifications: Array + should_show: boolean +} + +export type DismissNotificationPayload = { + notification_id: string +} + export type SimpleResultResponse = { result: string } +export type NotificationItemResponse = { + body: string + frequency?: string | null + lang: string + notification_id?: string | null + subtitle: string + title: string + title_pic_url: string +} + export type GetNotificationData = { body?: never path?: never @@ -16,37 +35,26 @@ export type GetNotificationData = { } export type GetNotificationErrors = { - 401: { - [key: string]: unknown - } + 401: unknown } -export type GetNotificationError = GetNotificationErrors[keyof GetNotificationErrors] - export type GetNotificationResponses = { - 200: { - [key: string]: unknown - } + 200: NotificationResponse } export type GetNotificationResponse = GetNotificationResponses[keyof GetNotificationResponses] export type PostNotificationDismissData = { - body?: never + body: DismissNotificationPayload path?: never query?: never url: '/notification/dismiss' } export type PostNotificationDismissErrors = { - 401: { - [key: string]: unknown - } + 401: unknown } -export type PostNotificationDismissError - = PostNotificationDismissErrors[keyof PostNotificationDismissErrors] - export type PostNotificationDismissResponses = { 200: SimpleResultResponse } diff --git a/packages/contracts/generated/api/console/notification/zod.gen.ts b/packages/contracts/generated/api/console/notification/zod.gen.ts index dcf4aaa56b8..fb39eada2d2 100644 --- a/packages/contracts/generated/api/console/notification/zod.gen.ts +++ b/packages/contracts/generated/api/console/notification/zod.gen.ts @@ -2,6 +2,13 @@ import * as z from 'zod' +/** + * DismissNotificationPayload + */ +export const zDismissNotificationPayload = z.object({ + notification_id: z.string(), +}) + /** * SimpleResultResponse */ @@ -9,10 +16,33 @@ export const zSimpleResultResponse = z.object({ result: z.string(), }) +/** + * NotificationItemResponse + */ +export const zNotificationItemResponse = z.object({ + body: z.string(), + frequency: z.string().nullish(), + lang: z.string(), + notification_id: z.string().nullish(), + subtitle: z.string(), + title: z.string(), + title_pic_url: z.string(), +}) + +/** + * NotificationResponse + */ +export const zNotificationResponse = z.object({ + notifications: z.array(zNotificationItemResponse), + should_show: z.boolean(), +}) + /** * Success — inspect should_show to decide whether to render the modal */ -export const zGetNotificationResponse = z.record(z.string(), z.unknown()) +export const zGetNotificationResponse = zNotificationResponse + +export const zPostNotificationDismissBody = zDismissNotificationPayload /** * Success diff --git a/packages/contracts/generated/api/console/notion/types.gen.ts b/packages/contracts/generated/api/console/notion/types.gen.ts index ac0431b2d31..92b26116c9e 100644 --- a/packages/contracts/generated/api/console/notion/types.gen.ts +++ b/packages/contracts/generated/api/console/notion/types.gen.ts @@ -21,7 +21,7 @@ export type NotionIntegrateWorkspaceResponse = { export type NotionIntegratePageResponse = { is_bound: boolean - page_icon: DataSourceIntegrateIconResponse + page_icon: DataSourceIntegrateIconResponse | null page_id: string page_name: string parent_id: string | null diff --git a/packages/contracts/generated/api/console/notion/zod.gen.ts b/packages/contracts/generated/api/console/notion/zod.gen.ts index 6e371766b9b..633ae90be35 100644 --- a/packages/contracts/generated/api/console/notion/zod.gen.ts +++ b/packages/contracts/generated/api/console/notion/zod.gen.ts @@ -23,7 +23,7 @@ export const zDataSourceIntegrateIconResponse = z.object({ */ export const zNotionIntegratePageResponse = z.object({ is_bound: z.boolean(), - page_icon: zDataSourceIntegrateIconResponse, + page_icon: zDataSourceIntegrateIconResponse.nullable(), page_id: z.string(), page_name: z.string(), parent_id: z.string().nullable(), diff --git a/packages/contracts/generated/api/console/oauth/orpc.gen.ts b/packages/contracts/generated/api/console/oauth/orpc.gen.ts index 5ef03ccb969..ab1bd8552b9 100644 --- a/packages/contracts/generated/api/console/oauth/orpc.gen.ts +++ b/packages/contracts/generated/api/console/oauth/orpc.gen.ts @@ -4,9 +4,6 @@ import { oc } from '@orpc/contract' import * as z from 'zod' import { - zGetOauthAuthorizeByProviderPath, - zGetOauthAuthorizeByProviderQuery, - zGetOauthAuthorizeByProviderResponse, zGetOauthDataSourceBindingByProviderPath, zGetOauthDataSourceBindingByProviderQuery, zGetOauthDataSourceBindingByProviderResponse, @@ -14,66 +11,25 @@ import { zGetOauthDataSourceByProviderByBindingIdSyncResponse, zGetOauthDataSourceByProviderPath, zGetOauthDataSourceByProviderResponse, - zGetOauthDataSourceCallbackByProviderPath, - zGetOauthDataSourceCallbackByProviderQuery, - zGetOauthDataSourceCallbackByProviderResponse, - zGetOauthLoginByProviderPath, - zGetOauthLoginByProviderQuery, - zGetOauthLoginByProviderResponse, - zGetOauthPluginByProviderIdDatasourceCallbackPath, - zGetOauthPluginByProviderIdDatasourceCallbackResponse, zGetOauthPluginByProviderIdDatasourceGetAuthorizationUrlPath, + zGetOauthPluginByProviderIdDatasourceGetAuthorizationUrlQuery, zGetOauthPluginByProviderIdDatasourceGetAuthorizationUrlResponse, zGetOauthPluginByProviderToolAuthorizationUrlPath, zGetOauthPluginByProviderToolAuthorizationUrlResponse, - zGetOauthPluginByProviderToolCallbackPath, - zGetOauthPluginByProviderToolCallbackResponse, - zGetOauthPluginByProviderTriggerCallbackPath, - zGetOauthPluginByProviderTriggerCallbackResponse, + zPostOauthProviderAccountBody, zPostOauthProviderAccountResponse, + zPostOauthProviderAuthorizeBody, zPostOauthProviderAuthorizeResponse, + zPostOauthProviderBody, zPostOauthProviderResponse, + zPostOauthProviderTokenBody, zPostOauthProviderTokenResponse, } from './zod.gen' -/** - * Handle OAuth callback and complete login process - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get = oc - .route({ - deprecated: true, - description: - 'Handle OAuth callback and complete login process\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getOauthAuthorizeByProvider', - path: '/oauth/authorize/{provider}', - tags: ['console'], - }) - .input( - z.object({ - params: zGetOauthAuthorizeByProviderPath, - query: zGetOauthAuthorizeByProviderQuery.optional(), - }), - ) - .output(zGetOauthAuthorizeByProviderResponse) - -export const byProvider = { - get, -} - -export const authorize = { - byProvider, -} - /** * Bind OAuth data source with authorization code */ -export const get2 = oc +export const get = oc .route({ description: 'Bind OAuth data source with authorization code', inputStructure: 'detailed', @@ -85,57 +41,23 @@ export const get2 = oc .input( z.object({ params: zGetOauthDataSourceBindingByProviderPath, - query: zGetOauthDataSourceBindingByProviderQuery.optional(), + query: zGetOauthDataSourceBindingByProviderQuery, }), ) .output(zGetOauthDataSourceBindingByProviderResponse) -export const byProvider2 = { - get: get2, +export const byProvider = { + get, } export const binding = { - byProvider: byProvider2, -} - -/** - * Handle OAuth callback from data source provider - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get3 = oc - .route({ - deprecated: true, - description: - 'Handle OAuth callback from data source provider\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getOauthDataSourceCallbackByProvider', - path: '/oauth/data-source/callback/{provider}', - tags: ['console'], - }) - .input( - z.object({ - params: zGetOauthDataSourceCallbackByProviderPath, - query: zGetOauthDataSourceCallbackByProviderQuery.optional(), - }), - ) - .output(zGetOauthDataSourceCallbackByProviderResponse) - -export const byProvider3 = { - get: get3, -} - -export const callback = { - byProvider: byProvider3, + byProvider, } /** * Sync data from OAuth data source */ -export const get4 = oc +export const get2 = oc .route({ description: 'Sync data from OAuth data source', inputStructure: 'detailed', @@ -148,7 +70,7 @@ export const get4 = oc .output(zGetOauthDataSourceByProviderByBindingIdSyncResponse) export const sync = { - get: get4, + get: get2, } export const byBindingId = { @@ -158,7 +80,7 @@ export const byBindingId = { /** * Get OAuth authorization URL for data source provider */ -export const get5 = oc +export const get3 = oc .route({ description: 'Get OAuth authorization URL for data source provider', inputStructure: 'detailed', @@ -170,99 +92,37 @@ export const get5 = oc .input(z.object({ params: zGetOauthDataSourceByProviderPath })) .output(zGetOauthDataSourceByProviderResponse) -export const byProvider4 = { - get: get5, +export const byProvider2 = { + get: get3, byBindingId, } export const dataSource = { binding, - callback, - byProvider: byProvider4, + byProvider: byProvider2, } -/** - * Initiate OAuth login process - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get6 = oc +export const get4 = oc .route({ - deprecated: true, - description: - 'Initiate OAuth login process\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getOauthLoginByProvider', - path: '/oauth/login/{provider}', - tags: ['console'], - }) - .input( - z.object({ - params: zGetOauthLoginByProviderPath, - query: zGetOauthLoginByProviderQuery.optional(), - }), - ) - .output(zGetOauthLoginByProviderResponse) - -export const byProvider5 = { - get: get6, -} - -export const login = { - byProvider: byProvider5, -} - -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get7 = oc - .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getOauthPluginByProviderIdDatasourceCallback', - path: '/oauth/plugin/{provider_id}/datasource/callback', - tags: ['console'], - }) - .input(z.object({ params: zGetOauthPluginByProviderIdDatasourceCallbackPath })) - .output(zGetOauthPluginByProviderIdDatasourceCallbackResponse) - -export const callback2 = { - get: get7, -} - -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get8 = oc - .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getOauthPluginByProviderIdDatasourceGetAuthorizationUrl', path: '/oauth/plugin/{provider_id}/datasource/get-authorization-url', tags: ['console'], }) - .input(z.object({ params: zGetOauthPluginByProviderIdDatasourceGetAuthorizationUrlPath })) + .input( + z.object({ + params: zGetOauthPluginByProviderIdDatasourceGetAuthorizationUrlPath, + query: zGetOauthPluginByProviderIdDatasourceGetAuthorizationUrlQuery.optional(), + }), + ) .output(zGetOauthPluginByProviderIdDatasourceGetAuthorizationUrlResponse) export const getAuthorizationUrl = { - get: get8, + get: get4, } export const datasource = { - callback: callback2, getAuthorizationUrl, } @@ -270,16 +130,8 @@ export const byProviderId = { datasource, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get9 = oc +export const get5 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getOauthPluginByProviderToolAuthorizationUrl', @@ -290,172 +142,87 @@ export const get9 = oc .output(zGetOauthPluginByProviderToolAuthorizationUrlResponse) export const authorizationUrl = { - get: get9, -} - -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get10 = oc - .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getOauthPluginByProviderToolCallback', - path: '/oauth/plugin/{provider}/tool/callback', - tags: ['console'], - }) - .input(z.object({ params: zGetOauthPluginByProviderToolCallbackPath })) - .output(zGetOauthPluginByProviderToolCallbackResponse) - -export const callback3 = { - get: get10, + get: get5, } export const tool = { authorizationUrl, - callback: callback3, } -/** - * Handle OAuth callback for trigger provider - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get11 = oc - .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getOauthPluginByProviderTriggerCallback', - path: '/oauth/plugin/{provider}/trigger/callback', - summary: 'Handle OAuth callback for trigger provider', - tags: ['console'], - }) - .input(z.object({ params: zGetOauthPluginByProviderTriggerCallbackPath })) - .output(zGetOauthPluginByProviderTriggerCallbackResponse) - -export const callback4 = { - get: get11, -} - -export const trigger = { - callback: callback4, -} - -export const byProvider6 = { +export const byProvider3 = { tool, - trigger, } export const plugin = { byProviderId, - byProvider: byProvider6, + byProvider: byProvider3, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postOauthProviderAccount', path: '/oauth/provider/account', tags: ['console'], }) + .input(z.object({ body: zPostOauthProviderAccountBody })) .output(zPostOauthProviderAccountResponse) export const account = { post, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post2 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postOauthProviderAuthorize', path: '/oauth/provider/authorize', tags: ['console'], }) + .input(z.object({ body: zPostOauthProviderAuthorizeBody })) .output(zPostOauthProviderAuthorizeResponse) -export const authorize2 = { +export const authorize = { post: post2, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post3 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postOauthProviderToken', path: '/oauth/provider/token', tags: ['console'], }) + .input(z.object({ body: zPostOauthProviderTokenBody })) .output(zPostOauthProviderTokenResponse) export const token = { post: post3, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post4 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postOauthProvider', path: '/oauth/provider', tags: ['console'], }) + .input(z.object({ body: zPostOauthProviderBody })) .output(zPostOauthProviderResponse) export const provider = { post: post4, account, - authorize: authorize2, + authorize, token, } export const oauth = { - authorize, dataSource, - login, plugin, provider, } diff --git a/packages/contracts/generated/api/console/oauth/types.gen.ts b/packages/contracts/generated/api/console/oauth/types.gen.ts index 15bad82f5e0..b6d27affa3d 100644 --- a/packages/contracts/generated/api/console/oauth/types.gen.ts +++ b/packages/contracts/generated/api/console/oauth/types.gen.ts @@ -16,56 +16,70 @@ export type OAuthDataSourceSyncResponse = { result: string } -export type GetOauthAuthorizeByProviderData = { - body?: never - path: { - provider: string - } - query?: { - code?: string - state?: string - } - url: '/oauth/authorize/{provider}' +export type PluginOAuthAuthorizationUrlResponse = { + authorization_url: string } -export type GetOauthAuthorizeByProviderErrors = { - 400: { +export type OAuthProviderRequest = { + client_id: string + redirect_uri: string +} + +export type OAuthProviderAppResponse = { + app_icon: string + app_label: { [key: string]: unknown } + scope: string } -export type GetOauthAuthorizeByProviderError - = GetOauthAuthorizeByProviderErrors[keyof GetOauthAuthorizeByProviderErrors] - -export type GetOauthAuthorizeByProviderResponses = { - 200: { - [key: string]: unknown - } +export type OAuthClientPayload = { + client_id: string } -export type GetOauthAuthorizeByProviderResponse - = GetOauthAuthorizeByProviderResponses[keyof GetOauthAuthorizeByProviderResponses] +export type OAuthProviderAccountResponse = { + avatar?: string | null + email: string + interface_language: string + name: string + timezone: string +} + +export type OAuthProviderAuthorizeResponse = { + code: string +} + +export type OAuthTokenRequest = { + client_id: string + client_secret?: string | null + code?: string | null + grant_type: string + redirect_uri?: string | null + refresh_token?: string | null +} + +export type OAuthProviderTokenResponse = { + access_token: string + expires_in: number + refresh_token: string + token_type: string +} export type GetOauthDataSourceBindingByProviderData = { body?: never path: { provider: string } - query?: { - code?: string + query: { + code: string } url: '/oauth/data-source/binding/{provider}' } export type GetOauthDataSourceBindingByProviderErrors = { - 400: { - [key: string]: unknown - } + 400: unknown } -export type GetOauthDataSourceBindingByProviderError - = GetOauthDataSourceBindingByProviderErrors[keyof GetOauthDataSourceBindingByProviderErrors] - export type GetOauthDataSourceBindingByProviderResponses = { 200: OAuthDataSourceBindingResponse } @@ -73,36 +87,6 @@ export type GetOauthDataSourceBindingByProviderResponses = { export type GetOauthDataSourceBindingByProviderResponse = GetOauthDataSourceBindingByProviderResponses[keyof GetOauthDataSourceBindingByProviderResponses] -export type GetOauthDataSourceCallbackByProviderData = { - body?: never - path: { - provider: string - } - query?: { - code?: string - error?: string - } - url: '/oauth/data-source/callback/{provider}' -} - -export type GetOauthDataSourceCallbackByProviderErrors = { - 400: { - [key: string]: unknown - } -} - -export type GetOauthDataSourceCallbackByProviderError - = GetOauthDataSourceCallbackByProviderErrors[keyof GetOauthDataSourceCallbackByProviderErrors] - -export type GetOauthDataSourceCallbackByProviderResponses = { - 200: { - [key: string]: unknown - } -} - -export type GetOauthDataSourceCallbackByProviderResponse - = GetOauthDataSourceCallbackByProviderResponses[keyof GetOauthDataSourceCallbackByProviderResponses] - export type GetOauthDataSourceByProviderData = { body?: never path: { @@ -113,17 +97,10 @@ export type GetOauthDataSourceByProviderData = { } export type GetOauthDataSourceByProviderErrors = { - 400: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } + 400: unknown + 403: unknown } -export type GetOauthDataSourceByProviderError - = GetOauthDataSourceByProviderErrors[keyof GetOauthDataSourceByProviderErrors] - export type GetOauthDataSourceByProviderResponses = { 200: OAuthDataSourceResponse } @@ -142,14 +119,9 @@ export type GetOauthDataSourceByProviderByBindingIdSyncData = { } export type GetOauthDataSourceByProviderByBindingIdSyncErrors = { - 400: { - [key: string]: unknown - } + 400: unknown } -export type GetOauthDataSourceByProviderByBindingIdSyncError - = GetOauthDataSourceByProviderByBindingIdSyncErrors[keyof GetOauthDataSourceByProviderByBindingIdSyncErrors] - export type GetOauthDataSourceByProviderByBindingIdSyncResponses = { 200: OAuthDataSourceSyncResponse } @@ -157,66 +129,19 @@ export type GetOauthDataSourceByProviderByBindingIdSyncResponses = { export type GetOauthDataSourceByProviderByBindingIdSyncResponse = GetOauthDataSourceByProviderByBindingIdSyncResponses[keyof GetOauthDataSourceByProviderByBindingIdSyncResponses] -export type GetOauthLoginByProviderData = { - body?: never - path: { - provider: string - } - query?: { - invite_token?: string - } - url: '/oauth/login/{provider}' -} - -export type GetOauthLoginByProviderErrors = { - 400: { - [key: string]: unknown - } -} - -export type GetOauthLoginByProviderError - = GetOauthLoginByProviderErrors[keyof GetOauthLoginByProviderErrors] - -export type GetOauthLoginByProviderResponses = { - 200: { - [key: string]: unknown - } -} - -export type GetOauthLoginByProviderResponse - = GetOauthLoginByProviderResponses[keyof GetOauthLoginByProviderResponses] - -export type GetOauthPluginByProviderIdDatasourceCallbackData = { - body?: never - path: { - provider_id: string - } - query?: never - url: '/oauth/plugin/{provider_id}/datasource/callback' -} - -export type GetOauthPluginByProviderIdDatasourceCallbackResponses = { - 200: { - [key: string]: unknown - } -} - -export type GetOauthPluginByProviderIdDatasourceCallbackResponse - = GetOauthPluginByProviderIdDatasourceCallbackResponses[keyof GetOauthPluginByProviderIdDatasourceCallbackResponses] - export type GetOauthPluginByProviderIdDatasourceGetAuthorizationUrlData = { body?: never path: { provider_id: string } - query?: never + query?: { + credential_id?: string + } url: '/oauth/plugin/{provider_id}/datasource/get-authorization-url' } export type GetOauthPluginByProviderIdDatasourceGetAuthorizationUrlResponses = { - 200: { - [key: string]: unknown - } + 200: PluginOAuthAuthorizationUrlResponse } export type GetOauthPluginByProviderIdDatasourceGetAuthorizationUrlResponse @@ -232,108 +157,62 @@ export type GetOauthPluginByProviderToolAuthorizationUrlData = { } export type GetOauthPluginByProviderToolAuthorizationUrlResponses = { - 200: { - [key: string]: unknown - } + 200: PluginOAuthAuthorizationUrlResponse } export type GetOauthPluginByProviderToolAuthorizationUrlResponse = GetOauthPluginByProviderToolAuthorizationUrlResponses[keyof GetOauthPluginByProviderToolAuthorizationUrlResponses] -export type GetOauthPluginByProviderToolCallbackData = { - body?: never - path: { - provider: string - } - query?: never - url: '/oauth/plugin/{provider}/tool/callback' -} - -export type GetOauthPluginByProviderToolCallbackResponses = { - 200: { - [key: string]: unknown - } -} - -export type GetOauthPluginByProviderToolCallbackResponse - = GetOauthPluginByProviderToolCallbackResponses[keyof GetOauthPluginByProviderToolCallbackResponses] - -export type GetOauthPluginByProviderTriggerCallbackData = { - body?: never - path: { - provider: string - } - query?: never - url: '/oauth/plugin/{provider}/trigger/callback' -} - -export type GetOauthPluginByProviderTriggerCallbackResponses = { - 200: { - [key: string]: unknown - } -} - -export type GetOauthPluginByProviderTriggerCallbackResponse - = GetOauthPluginByProviderTriggerCallbackResponses[keyof GetOauthPluginByProviderTriggerCallbackResponses] - export type PostOauthProviderData = { - body?: never + body: OAuthProviderRequest path?: never query?: never url: '/oauth/provider' } export type PostOauthProviderResponses = { - 200: { - [key: string]: unknown - } + 200: OAuthProviderAppResponse } export type PostOauthProviderResponse = PostOauthProviderResponses[keyof PostOauthProviderResponses] export type PostOauthProviderAccountData = { - body?: never + body: OAuthClientPayload path?: never query?: never url: '/oauth/provider/account' } export type PostOauthProviderAccountResponses = { - 200: { - [key: string]: unknown - } + 200: OAuthProviderAccountResponse } export type PostOauthProviderAccountResponse = PostOauthProviderAccountResponses[keyof PostOauthProviderAccountResponses] export type PostOauthProviderAuthorizeData = { - body?: never + body: OAuthClientPayload path?: never query?: never url: '/oauth/provider/authorize' } export type PostOauthProviderAuthorizeResponses = { - 200: { - [key: string]: unknown - } + 200: OAuthProviderAuthorizeResponse } export type PostOauthProviderAuthorizeResponse = PostOauthProviderAuthorizeResponses[keyof PostOauthProviderAuthorizeResponses] export type PostOauthProviderTokenData = { - body?: never + body: OAuthTokenRequest path?: never query?: never url: '/oauth/provider/token' } export type PostOauthProviderTokenResponses = { - 200: { - [key: string]: unknown - } + 200: OAuthProviderTokenResponse } export type PostOauthProviderTokenResponse diff --git a/packages/contracts/generated/api/console/oauth/zod.gen.ts b/packages/contracts/generated/api/console/oauth/zod.gen.ts index a96b7e33821..569b35ec4d0 100644 --- a/packages/contracts/generated/api/console/oauth/zod.gen.ts +++ b/packages/contracts/generated/api/console/oauth/zod.gen.ts @@ -23,26 +23,83 @@ export const zOAuthDataSourceSyncResponse = z.object({ result: z.string(), }) -export const zGetOauthAuthorizeByProviderPath = z.object({ - provider: z.string(), -}) - -export const zGetOauthAuthorizeByProviderQuery = z.object({ - code: z.string().optional(), - state: z.string().optional(), +/** + * PluginOAuthAuthorizationUrlResponse + */ +export const zPluginOAuthAuthorizationUrlResponse = z.object({ + authorization_url: z.string(), }) /** - * Success + * OAuthProviderRequest */ -export const zGetOauthAuthorizeByProviderResponse = z.record(z.string(), z.unknown()) +export const zOAuthProviderRequest = z.object({ + client_id: z.string(), + redirect_uri: z.string(), +}) + +/** + * OAuthProviderAppResponse + */ +export const zOAuthProviderAppResponse = z.object({ + app_icon: z.string(), + app_label: z.record(z.string(), z.unknown()), + scope: z.string(), +}) + +/** + * OAuthClientPayload + */ +export const zOAuthClientPayload = z.object({ + client_id: z.string(), +}) + +/** + * OAuthProviderAccountResponse + */ +export const zOAuthProviderAccountResponse = z.object({ + avatar: z.string().nullish(), + email: z.string(), + interface_language: z.string(), + name: z.string(), + timezone: z.string(), +}) + +/** + * OAuthProviderAuthorizeResponse + */ +export const zOAuthProviderAuthorizeResponse = z.object({ + code: z.string(), +}) + +/** + * OAuthTokenRequest + */ +export const zOAuthTokenRequest = z.object({ + client_id: z.string(), + client_secret: z.string().nullish(), + code: z.string().nullish(), + grant_type: z.string(), + redirect_uri: z.string().nullish(), + refresh_token: z.string().nullish(), +}) + +/** + * OAuthProviderTokenResponse + */ +export const zOAuthProviderTokenResponse = z.object({ + access_token: z.string(), + expires_in: z.int(), + refresh_token: z.string(), + token_type: z.string(), +}) export const zGetOauthDataSourceBindingByProviderPath = z.object({ provider: z.string(), }) export const zGetOauthDataSourceBindingByProviderQuery = z.object({ - code: z.string().optional(), + code: z.string(), }) /** @@ -50,20 +107,6 @@ export const zGetOauthDataSourceBindingByProviderQuery = z.object({ */ export const zGetOauthDataSourceBindingByProviderResponse = zOAuthDataSourceBindingResponse -export const zGetOauthDataSourceCallbackByProviderPath = z.object({ - provider: z.string(), -}) - -export const zGetOauthDataSourceCallbackByProviderQuery = z.object({ - code: z.string().optional(), - error: z.string().optional(), -}) - -/** - * Success - */ -export const zGetOauthDataSourceCallbackByProviderResponse = z.record(z.string(), z.unknown()) - export const zGetOauthDataSourceByProviderPath = z.object({ provider: z.string(), }) @@ -83,89 +126,54 @@ export const zGetOauthDataSourceByProviderByBindingIdSyncPath = z.object({ */ export const zGetOauthDataSourceByProviderByBindingIdSyncResponse = zOAuthDataSourceSyncResponse -export const zGetOauthLoginByProviderPath = z.object({ - provider: z.string(), -}) - -export const zGetOauthLoginByProviderQuery = z.object({ - invite_token: z.string().optional(), -}) - -/** - * Success - */ -export const zGetOauthLoginByProviderResponse = z.record(z.string(), z.unknown()) - -export const zGetOauthPluginByProviderIdDatasourceCallbackPath = z.object({ - provider_id: z.string(), -}) - -/** - * Success - */ -export const zGetOauthPluginByProviderIdDatasourceCallbackResponse = z.record( - z.string(), - z.unknown(), -) - export const zGetOauthPluginByProviderIdDatasourceGetAuthorizationUrlPath = z.object({ provider_id: z.string(), }) +export const zGetOauthPluginByProviderIdDatasourceGetAuthorizationUrlQuery = z.object({ + credential_id: z.string().optional(), +}) + /** - * Success + * Authorization URL retrieved successfully */ -export const zGetOauthPluginByProviderIdDatasourceGetAuthorizationUrlResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetOauthPluginByProviderIdDatasourceGetAuthorizationUrlResponse + = zPluginOAuthAuthorizationUrlResponse export const zGetOauthPluginByProviderToolAuthorizationUrlPath = z.object({ provider: z.string(), }) /** - * Success + * Authorization URL retrieved successfully */ -export const zGetOauthPluginByProviderToolAuthorizationUrlResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetOauthPluginByProviderToolAuthorizationUrlResponse + = zPluginOAuthAuthorizationUrlResponse -export const zGetOauthPluginByProviderToolCallbackPath = z.object({ - provider: z.string(), -}) +export const zPostOauthProviderBody = zOAuthProviderRequest /** * Success */ -export const zGetOauthPluginByProviderToolCallbackResponse = z.record(z.string(), z.unknown()) +export const zPostOauthProviderResponse = zOAuthProviderAppResponse -export const zGetOauthPluginByProviderTriggerCallbackPath = z.object({ - provider: z.string(), -}) +export const zPostOauthProviderAccountBody = zOAuthClientPayload /** * Success */ -export const zGetOauthPluginByProviderTriggerCallbackResponse = z.record(z.string(), z.unknown()) +export const zPostOauthProviderAccountResponse = zOAuthProviderAccountResponse + +export const zPostOauthProviderAuthorizeBody = zOAuthClientPayload /** * Success */ -export const zPostOauthProviderResponse = z.record(z.string(), z.unknown()) +export const zPostOauthProviderAuthorizeResponse = zOAuthProviderAuthorizeResponse + +export const zPostOauthProviderTokenBody = zOAuthTokenRequest /** * Success */ -export const zPostOauthProviderAccountResponse = z.record(z.string(), z.unknown()) - -/** - * Success - */ -export const zPostOauthProviderAuthorizeResponse = z.record(z.string(), z.unknown()) - -/** - * Success - */ -export const zPostOauthProviderTokenResponse = z.record(z.string(), z.unknown()) +export const zPostOauthProviderTokenResponse = zOAuthProviderTokenResponse diff --git a/packages/contracts/generated/api/console/orpc.gen.ts b/packages/contracts/generated/api/console/orpc.gen.ts index ef5f95f6371..1d25989f247 100644 --- a/packages/contracts/generated/api/console/orpc.gen.ts +++ b/packages/contracts/generated/api/console/orpc.gen.ts @@ -2,7 +2,7 @@ import { account } from './account/orpc.gen' import { activate } from './activate/orpc.gen' -import { agents } from './agents/orpc.gen' +import { agent } from './agent/orpc.gen' import { allWorkspaces } from './all-workspaces/orpc.gen' import { apiBasedExtension } from './api-based-extension/orpc.gen' import { apiKeyAuth } from './api-key-auth/orpc.gen' @@ -27,7 +27,6 @@ import { installedApps } from './installed-apps/orpc.gen' import { instructionGenerate } from './instruction-generate/orpc.gen' import { login } from './login/orpc.gen' import { logout } from './logout/orpc.gen' -import { mcp } from './mcp/orpc.gen' import { notification } from './notification/orpc.gen' import { notion } from './notion/orpc.gen' import { oauth } from './oauth/orpc.gen' @@ -54,7 +53,7 @@ import { workspaces } from './workspaces/orpc.gen' export const contract = { account, activate, - agents, + agent, allWorkspaces, apiBasedExtension, apiKeyAuth, @@ -79,7 +78,6 @@ export const contract = { instructionGenerate, login, logout, - mcp, notification, notion, oauth, diff --git a/packages/contracts/generated/api/console/rag/orpc.gen.ts b/packages/contracts/generated/api/console/rag/orpc.gen.ts index a4d22839fe0..ea52e39d5f6 100644 --- a/packages/contracts/generated/api/console/rag/orpc.gen.ts +++ b/packages/contracts/generated/api/console/rag/orpc.gen.ts @@ -22,8 +22,10 @@ import { zGetRagPipelinesByPipelineIdWorkflowRunsByRunIdPath, zGetRagPipelinesByPipelineIdWorkflowRunsByRunIdResponse, zGetRagPipelinesByPipelineIdWorkflowRunsPath, + zGetRagPipelinesByPipelineIdWorkflowRunsQuery, zGetRagPipelinesByPipelineIdWorkflowRunsResponse, zGetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypePath, + zGetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeQuery, zGetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponse, zGetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsPath, zGetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsResponse, @@ -35,8 +37,10 @@ import { zGetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesResponse, zGetRagPipelinesByPipelineIdWorkflowsDraftPath, zGetRagPipelinesByPipelineIdWorkflowsDraftPreProcessingParametersPath, + zGetRagPipelinesByPipelineIdWorkflowsDraftPreProcessingParametersQuery, zGetRagPipelinesByPipelineIdWorkflowsDraftPreProcessingParametersResponse, zGetRagPipelinesByPipelineIdWorkflowsDraftProcessingParametersPath, + zGetRagPipelinesByPipelineIdWorkflowsDraftProcessingParametersQuery, zGetRagPipelinesByPipelineIdWorkflowsDraftProcessingParametersResponse, zGetRagPipelinesByPipelineIdWorkflowsDraftResponse, zGetRagPipelinesByPipelineIdWorkflowsDraftSystemVariablesPath, @@ -44,18 +48,23 @@ import { zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdPath, zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponse, zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesPath, + zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesQuery, zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesResponse, zGetRagPipelinesByPipelineIdWorkflowsPath, zGetRagPipelinesByPipelineIdWorkflowsPublishedPreProcessingParametersPath, + zGetRagPipelinesByPipelineIdWorkflowsPublishedPreProcessingParametersQuery, zGetRagPipelinesByPipelineIdWorkflowsPublishedPreProcessingParametersResponse, zGetRagPipelinesByPipelineIdWorkflowsPublishedProcessingParametersPath, + zGetRagPipelinesByPipelineIdWorkflowsPublishedProcessingParametersQuery, zGetRagPipelinesByPipelineIdWorkflowsPublishedProcessingParametersResponse, zGetRagPipelinesByPipelineIdWorkflowsPublishPath, zGetRagPipelinesByPipelineIdWorkflowsPublishResponse, + zGetRagPipelinesByPipelineIdWorkflowsQuery, zGetRagPipelinesByPipelineIdWorkflowsResponse, zGetRagPipelinesDatasourcePluginsResponse, zGetRagPipelinesImportsByPipelineIdCheckDependenciesPath, zGetRagPipelinesImportsByPipelineIdCheckDependenciesResponse, + zGetRagPipelinesRecommendedPluginsQuery, zGetRagPipelinesRecommendedPluginsResponse, zGetRagPipelineTemplatesByTemplateIdPath, zGetRagPipelineTemplatesByTemplateIdQuery, @@ -65,6 +74,7 @@ import { zPatchRagPipelineCustomizedTemplatesByTemplateIdBody, zPatchRagPipelineCustomizedTemplatesByTemplateIdPath, zPatchRagPipelineCustomizedTemplatesByTemplateIdResponse, + zPatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdBody, zPatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdPath, zPatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdResponse, zPatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdBody, @@ -82,6 +92,7 @@ import { zPostRagPipelinesByPipelineIdWorkflowRunsTasksByTaskIdStopResponse, zPostRagPipelinesByPipelineIdWorkflowsByWorkflowIdRestorePath, zPostRagPipelinesByPipelineIdWorkflowsByWorkflowIdRestoreResponse, + zPostRagPipelinesByPipelineIdWorkflowsDraftBody, zPostRagPipelinesByPipelineIdWorkflowsDraftDatasourceNodesByNodeIdRunBody, zPostRagPipelinesByPipelineIdWorkflowsDraftDatasourceNodesByNodeIdRunPath, zPostRagPipelinesByPipelineIdWorkflowsDraftDatasourceNodesByNodeIdRunResponse, @@ -135,16 +146,8 @@ export const delete_ = oc .input(z.object({ params: zDeleteRagPipelineCustomizedTemplatesByTemplateIdPath })) .output(zDeleteRagPipelineCustomizedTemplatesByTemplateIdResponse) -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const patch = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchRagPipelineCustomizedTemplatesByTemplateId', @@ -216,16 +219,8 @@ export const emptyDataset = { post: post3, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const get = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelineTemplatesByTemplateId', @@ -244,16 +239,8 @@ export const byTemplateId2 = { get, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const get2 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelineTemplates', @@ -275,16 +262,8 @@ export const pipeline = { templates: templates2, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const get3 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelinesDatasourcePlugins', @@ -352,38 +331,23 @@ export const imports = { byPipelineId, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const get5 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelinesRecommendedPlugins', path: '/rag/pipelines/recommended-plugins', tags: ['console'], }) + .input(z.object({ query: zGetRagPipelinesRecommendedPluginsQuery.optional() })) .output(zGetRagPipelinesRecommendedPluginsResponse) export const recommendedPlugins = { get: get5, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post6 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postRagPipelinesTransformDatasetsByDatasetId', @@ -405,16 +369,8 @@ export const transform = { datasets, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post7 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postRagPipelinesByPipelineIdCustomizedPublish', @@ -536,7 +492,12 @@ export const get9 = oc summary: 'Get workflow run list', tags: ['console'], }) - .input(z.object({ params: zGetRagPipelinesByPipelineIdWorkflowRunsPath })) + .input( + z.object({ + params: zGetRagPipelinesByPipelineIdWorkflowRunsPath, + query: zGetRagPipelinesByPipelineIdWorkflowRunsQuery.optional(), + }), + ) .output(zGetRagPipelinesByPipelineIdWorkflowRunsResponse) export const workflowRuns = { @@ -547,16 +508,9 @@ export const workflowRuns = { /** * Get default block config - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get10 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsByBlockType', @@ -567,6 +521,8 @@ export const get10 = oc .input( z.object({ params: zGetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypePath, + query: + zGetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeQuery.optional(), }), ) .output(zGetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponse) @@ -577,16 +533,9 @@ export const byBlockType = { /** * Get default block config - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get11 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigs', @@ -604,16 +553,9 @@ export const defaultWorkflowBlockConfigs = { /** * Run rag pipeline datasource - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post9 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postRagPipelinesByPipelineIdWorkflowsDraftDatasourceNodesByNodeIdRun', @@ -643,16 +585,9 @@ export const nodes = { /** * Set datasource variables - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post10 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postRagPipelinesByPipelineIdWorkflowsDraftDatasourceVariablesInspect', @@ -679,16 +614,9 @@ export const datasource = { /** * Get draft workflow - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get12 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelinesByPipelineIdWorkflowsDraftEnvironmentVariables', @@ -705,16 +633,9 @@ export const environmentVariables = { /** * Run draft workflow iteration node - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post11 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postRagPipelinesByPipelineIdWorkflowsDraftIterationNodesByNodeIdRun', @@ -748,16 +669,9 @@ export const iteration = { /** * Run draft workflow loop node - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post12 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postRagPipelinesByPipelineIdWorkflowsDraftLoopNodesByNodeIdRun', @@ -806,16 +720,9 @@ export const lastRun = { /** * Run draft workflow node - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post13 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdRun', @@ -835,20 +742,13 @@ export const run4 = { post: post13, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const delete2 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariables', path: '/rag/pipelines/{pipeline_id}/workflows/draft/nodes/{node_id}/variables', + successStatus: 204, tags: ['console'], }) .input( @@ -856,16 +756,8 @@ export const delete2 = oc ) .output(zDeleteRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesResponse) -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const get14 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariables', @@ -892,16 +784,9 @@ export const nodes4 = { /** * Get first step parameters of rag pipeline - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get15 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelinesByPipelineIdWorkflowsDraftPreProcessingParameters', @@ -910,7 +795,10 @@ export const get15 = oc tags: ['console'], }) .input( - z.object({ params: zGetRagPipelinesByPipelineIdWorkflowsDraftPreProcessingParametersPath }), + z.object({ + params: zGetRagPipelinesByPipelineIdWorkflowsDraftPreProcessingParametersPath, + query: zGetRagPipelinesByPipelineIdWorkflowsDraftPreProcessingParametersQuery, + }), ) .output(zGetRagPipelinesByPipelineIdWorkflowsDraftPreProcessingParametersResponse) @@ -924,16 +812,9 @@ export const preProcessing = { /** * Get second step parameters of rag pipeline - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get16 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelinesByPipelineIdWorkflowsDraftProcessingParameters', @@ -941,7 +822,12 @@ export const get16 = oc summary: 'Get second step parameters of rag pipeline', tags: ['console'], }) - .input(z.object({ params: zGetRagPipelinesByPipelineIdWorkflowsDraftProcessingParametersPath })) + .input( + z.object({ + params: zGetRagPipelinesByPipelineIdWorkflowsDraftProcessingParametersPath, + query: zGetRagPipelinesByPipelineIdWorkflowsDraftProcessingParametersQuery, + }), + ) .output(zGetRagPipelinesByPipelineIdWorkflowsDraftProcessingParametersResponse) export const parameters2 = { @@ -954,16 +840,9 @@ export const processing = { /** * Run draft workflow - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post14 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postRagPipelinesByPipelineIdWorkflowsDraftRun', @@ -983,16 +862,8 @@ export const run5 = { post: post14, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const get17 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelinesByPipelineIdWorkflowsDraftSystemVariables', @@ -1006,16 +877,8 @@ export const systemVariables = { get: get17, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const put = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PUT', operationId: 'putRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdReset', @@ -1031,20 +894,13 @@ export const reset = { put, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const delete3 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableId', path: '/rag/pipelines/{pipeline_id}/workflows/draft/variables/{variable_id}', + successStatus: 204, tags: ['console'], }) .input( @@ -1052,16 +908,8 @@ export const delete3 = oc ) .output(zDeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponse) -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const get18 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableId', @@ -1071,16 +919,8 @@ export const get18 = oc .input(z.object({ params: zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdPath })) .output(zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponse) -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const patch2 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableId', @@ -1102,20 +942,13 @@ export const byVariableId = { reset, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const delete4 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteRagPipelinesByPipelineIdWorkflowsDraftVariables', path: '/rag/pipelines/{pipeline_id}/workflows/draft/variables', + successStatus: 204, tags: ['console'], }) .input(z.object({ params: zDeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesPath })) @@ -1123,16 +956,9 @@ export const delete4 = oc /** * Get draft workflow - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get19 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelinesByPipelineIdWorkflowsDraftVariables', @@ -1140,7 +966,12 @@ export const get19 = oc summary: 'Get draft workflow', tags: ['console'], }) - .input(z.object({ params: zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesPath })) + .input( + z.object({ + params: zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesPath, + query: zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesQuery.optional(), + }), + ) .output(zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesResponse) export const variables2 = { @@ -1151,16 +982,9 @@ export const variables2 = { /** * Get draft rag pipeline's workflow - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get20 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelinesByPipelineIdWorkflowsDraft', @@ -1173,16 +997,9 @@ export const get20 = oc /** * Sync draft workflow - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post15 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postRagPipelinesByPipelineIdWorkflowsDraft', @@ -1190,7 +1007,12 @@ export const post15 = oc summary: 'Sync draft workflow', tags: ['console'], }) - .input(z.object({ params: zPostRagPipelinesByPipelineIdWorkflowsDraftPath })) + .input( + z.object({ + body: zPostRagPipelinesByPipelineIdWorkflowsDraftBody, + params: zPostRagPipelinesByPipelineIdWorkflowsDraftPath, + }), + ) .output(zPostRagPipelinesByPipelineIdWorkflowsDraftResponse) export const draft = { @@ -1210,16 +1032,9 @@ export const draft = { /** * Get published pipeline - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get21 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelinesByPipelineIdWorkflowsPublish', @@ -1252,16 +1067,9 @@ export const publish2 = { /** * Run datasource content preview - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post17 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdPreview', @@ -1283,16 +1091,9 @@ export const preview = { /** * Run rag pipeline datasource - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post18 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdRun', @@ -1327,16 +1128,9 @@ export const datasource2 = { /** * Get first step parameters of rag pipeline - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get22 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelinesByPipelineIdWorkflowsPublishedPreProcessingParameters', @@ -1345,7 +1139,10 @@ export const get22 = oc tags: ['console'], }) .input( - z.object({ params: zGetRagPipelinesByPipelineIdWorkflowsPublishedPreProcessingParametersPath }), + z.object({ + params: zGetRagPipelinesByPipelineIdWorkflowsPublishedPreProcessingParametersPath, + query: zGetRagPipelinesByPipelineIdWorkflowsPublishedPreProcessingParametersQuery, + }), ) .output(zGetRagPipelinesByPipelineIdWorkflowsPublishedPreProcessingParametersResponse) @@ -1359,16 +1156,9 @@ export const preProcessing2 = { /** * Get second step parameters of rag pipeline - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get23 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelinesByPipelineIdWorkflowsPublishedProcessingParameters', @@ -1377,7 +1167,10 @@ export const get23 = oc tags: ['console'], }) .input( - z.object({ params: zGetRagPipelinesByPipelineIdWorkflowsPublishedProcessingParametersPath }), + z.object({ + params: zGetRagPipelinesByPipelineIdWorkflowsPublishedProcessingParametersPath, + query: zGetRagPipelinesByPipelineIdWorkflowsPublishedProcessingParametersQuery, + }), ) .output(zGetRagPipelinesByPipelineIdWorkflowsPublishedProcessingParametersResponse) @@ -1391,16 +1184,9 @@ export const processing2 = { /** * Run published workflow - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post19 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postRagPipelinesByPipelineIdWorkflowsPublishedRun', @@ -1460,16 +1246,9 @@ export const delete5 = oc /** * Update workflow attributes - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const patch3 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchRagPipelinesByPipelineIdWorkflowsByWorkflowId', @@ -1477,7 +1256,12 @@ export const patch3 = oc summary: 'Update workflow attributes', tags: ['console'], }) - .input(z.object({ params: zPatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdPath })) + .input( + z.object({ + body: zPatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdBody, + params: zPatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdPath, + }), + ) .output(zPatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdResponse) export const byWorkflowId = { @@ -1488,16 +1272,9 @@ export const byWorkflowId = { /** * Get published workflows - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get24 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelinesByPipelineIdWorkflows', @@ -1505,7 +1282,12 @@ export const get24 = oc summary: 'Get published workflows', tags: ['console'], }) - .input(z.object({ params: zGetRagPipelinesByPipelineIdWorkflowsPath })) + .input( + z.object({ + params: zGetRagPipelinesByPipelineIdWorkflowsPath, + query: zGetRagPipelinesByPipelineIdWorkflowsQuery.optional(), + }), + ) .output(zGetRagPipelinesByPipelineIdWorkflowsResponse) export const workflows = { diff --git a/packages/contracts/generated/api/console/rag/types.gen.ts b/packages/contracts/generated/api/console/rag/types.gen.ts index ad46a4b2765..49572f971b7 100644 --- a/packages/contracts/generated/api/console/rag/types.gen.ts +++ b/packages/contracts/generated/api/console/rag/types.gen.ts @@ -47,7 +47,7 @@ export type DatasetDetailResponse = { embedding_model_provider: string | null enable_api: boolean external_knowledge_info?: DatasetExternalKnowledgeInfoResponse - external_retrieval_model: DatasetExternalRetrievalModelResponse + external_retrieval_model: DatasetExternalRetrievalModelResponse | null icon_info?: DatasetIconInfoResponse id: string indexing_technique: string | null @@ -87,6 +87,8 @@ export type PipelineTemplateDetailResponse = { name: string } +export type RagPipelineOpaqueResponse = unknown + export type RagPipelineImportPayload = { description?: string | null icon?: string | null @@ -115,8 +117,8 @@ export type SimpleResultResponse = { export type WorkflowRunDetailResponse = { created_at?: number | null - created_by_account?: SimpleAccount - created_by_end_user?: SimpleEndUser + created_by_account?: SimpleAccount | null + created_by_end_user?: SimpleEndUser | null created_by_role?: string | null elapsed_time?: number | null error?: string | null @@ -143,10 +145,18 @@ 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 + created_by?: SimpleAccount | null environment_variables: Array features: { [key: string]: unknown @@ -161,10 +171,29 @@ export type WorkflowResponse = { rag_pipeline_variables: Array tool_published: boolean updated_at: number - updated_by?: SimpleAccount + updated_by?: SimpleAccount | null version: string } +export type DraftWorkflowSyncPayload = { + conversation_variables?: Array<{ + [key: string]: unknown + }> | null + environment_variables?: Array<{ + [key: string]: unknown + }> | null + features?: { + [key: string]: unknown + } | null + graph: { + [key: string]: unknown + } + hash?: string | null + rag_pipeline_variables?: Array<{ + [key: string]: unknown + }> | null +} + export type RagPipelineWorkflowSyncResponse = { hash: string result: string @@ -190,8 +219,8 @@ export type DatasourceVariablesPayload = { export type WorkflowRunNodeExecutionResponse = { created_at?: number | null - created_by_account?: SimpleAccount - created_by_end_user?: SimpleEndUser + created_by_account?: SimpleAccount | null + created_by_end_user?: SimpleEndUser | null created_by_role?: string | null elapsed_time?: number | null error?: string | null @@ -213,6 +242,10 @@ export type WorkflowRunNodeExecutionResponse = { title?: string | null } +export type EnvironmentVariableListResponse = { + items: Array +} + export type NodeRunPayload = { inputs?: { [key: string]: unknown @@ -225,6 +258,14 @@ export type NodeRunRequiredPayload = { } } +export type WorkflowDraftVariableList = { + items?: Array +} + +export type RagPipelineStepParametersResponse = { + variables: unknown +} + export type DraftWorkflowRunPayload = { datasource_info_list: Array<{ [key: string]: unknown @@ -236,9 +277,39 @@ export type DraftWorkflowRunPayload = { start_node_id: string } +export type WorkflowDraftVariableListWithoutValue = { + items?: Array + total?: number +} + +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 WorkflowDraftVariablePatchPayload = { name?: string | null - value?: unknown + value?: unknown | null } export type RagPipelineWorkflowPublishResponse = { @@ -254,6 +325,8 @@ export type Parser = { } } +export type DataSourceContentPreviewResponse = unknown + export type PublishedWorkflowRunPayload = { datasource_info_list: Array<{ [key: string]: unknown @@ -268,6 +341,11 @@ export type PublishedWorkflowRunPayload = { start_node_id: string } +export type WorkflowUpdatePayload = { + marked_comment?: string | null + marked_name?: string | null +} + export type ImportStatus = 'completed' | 'completed-with-warnings' | 'failed' | 'pending' export type DatasetDocMetadataResponse = { @@ -304,7 +382,7 @@ export type DatasetRetrievalModelResponse = { score_threshold_enabled: boolean search_method: string top_k: number - weights?: DatasetWeightedScoreResponse + weights?: DatasetWeightedScoreResponse | null } export type DatasetSummaryIndexSettingResponse = { @@ -336,12 +414,12 @@ export type PipelineTemplateItemResponse = { export type PluginDependency = { current_identifier?: string | null type: Type - value: unknown + value: Github | Marketplace | Package } export type WorkflowRunForListResponse = { created_at?: number | null - created_by_account?: SimpleAccount + created_by_account?: SimpleAccount | null elapsed_time?: number | null exceptions_count?: number | null finished_at?: number | null @@ -370,9 +448,7 @@ export type WorkflowConversationVariableResponse = { description: string id: string name: string - value: { - [key: string]: unknown - } + value: unknown value_type: string } @@ -380,9 +456,7 @@ export type WorkflowEnvironmentVariableResponse = { description: string id: string name: string - value: { - [key: string]: unknown - } + value: unknown value_type: string } @@ -391,9 +465,7 @@ export type PipelineVariableResponse = { allowed_file_types?: Array | null allowed_file_upload_methods?: Array | null belong_to_node_id: string - default_value?: { - [key: string]: unknown - } + default_value?: unknown label: string max_length?: number | null options?: Array | null @@ -405,6 +477,31 @@ export type PipelineVariableResponse = { variable: string } +export type EnvironmentVariableItemResponse = { + description?: string | null + editable: boolean + edited: boolean + id: string + name: string + selector: Array + type: string + value: unknown + value_type: string + visible: boolean +} + +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 DatasetRerankingModelResponse = { reranking_model_name?: string | null reranking_provider_name?: string | null @@ -455,9 +552,7 @@ export type DeleteRagPipelineCustomizedTemplatesByTemplateIdData = { } export type DeleteRagPipelineCustomizedTemplatesByTemplateIdResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteRagPipelineCustomizedTemplatesByTemplateIdResponse @@ -473,9 +568,7 @@ export type PatchRagPipelineCustomizedTemplatesByTemplateIdData = { } export type PatchRagPipelineCustomizedTemplatesByTemplateIdResponses = { - 204: { - [key: string]: never - } + 204: void } export type PatchRagPipelineCustomizedTemplatesByTemplateIdResponse @@ -568,9 +661,7 @@ export type GetRagPipelinesDatasourcePluginsData = { } export type GetRagPipelinesDatasourcePluginsResponses = { - 200: { - [key: string]: unknown - } + 200: RagPipelineOpaqueResponse } export type GetRagPipelinesDatasourcePluginsResponse @@ -640,14 +731,14 @@ export type GetRagPipelinesImportsByPipelineIdCheckDependenciesResponse export type GetRagPipelinesRecommendedPluginsData = { body?: never path?: never - query?: never + query?: { + type?: string + } url: '/rag/pipelines/recommended-plugins' } export type GetRagPipelinesRecommendedPluginsResponses = { - 200: { - [key: string]: unknown - } + 200: RagPipelineOpaqueResponse } export type GetRagPipelinesRecommendedPluginsResponse @@ -663,9 +754,7 @@ export type PostRagPipelinesTransformDatasetsByDatasetIdData = { } export type PostRagPipelinesTransformDatasetsByDatasetIdResponses = { - 200: { - [key: string]: unknown - } + 200: RagPipelineOpaqueResponse } export type PostRagPipelinesTransformDatasetsByDatasetIdResponse @@ -681,9 +770,7 @@ export type PostRagPipelinesByPipelineIdCustomizedPublishData = { } export type PostRagPipelinesByPipelineIdCustomizedPublishResponses = { - 204: { - [key: string]: never - } + 204: void } export type PostRagPipelinesByPipelineIdCustomizedPublishResponse @@ -712,7 +799,10 @@ export type GetRagPipelinesByPipelineIdWorkflowRunsData = { path: { pipeline_id: string } - query?: never + query?: { + last_id?: string + limit?: number + } url: '/rag/pipelines/{pipeline_id}/workflow-runs' } @@ -779,19 +869,19 @@ export type GetRagPipelinesByPipelineIdWorkflowsData = { path: { pipeline_id: string } - query?: never + query?: { + limit?: number + named_only?: boolean + page?: number + user_id?: string + } url: '/rag/pipelines/{pipeline_id}/workflows' } export type GetRagPipelinesByPipelineIdWorkflowsErrors = { - 403: { - [key: string]: unknown - } + 403: unknown } -export type GetRagPipelinesByPipelineIdWorkflowsError - = GetRagPipelinesByPipelineIdWorkflowsErrors[keyof GetRagPipelinesByPipelineIdWorkflowsErrors] - export type GetRagPipelinesByPipelineIdWorkflowsResponses = { 200: WorkflowPaginationResponse } @@ -809,9 +899,7 @@ export type GetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsData } export type GetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsResponses = { - 200: { - [key: string]: unknown - } + 200: DefaultBlockConfigsResponse } export type GetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsResponse @@ -823,14 +911,14 @@ export type GetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsByBlo block_type: string pipeline_id: string } - query?: never + query?: { + q?: string + } url: '/rag/pipelines/{pipeline_id}/workflows/default-workflow-block-configs/{block_type}' } export type GetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponses = { - 200: { - [key: string]: unknown - } + 200: DefaultBlockConfigResponse } export type GetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponse @@ -846,14 +934,9 @@ export type GetRagPipelinesByPipelineIdWorkflowsDraftData = { } export type GetRagPipelinesByPipelineIdWorkflowsDraftErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type GetRagPipelinesByPipelineIdWorkflowsDraftError - = GetRagPipelinesByPipelineIdWorkflowsDraftErrors[keyof GetRagPipelinesByPipelineIdWorkflowsDraftErrors] - export type GetRagPipelinesByPipelineIdWorkflowsDraftResponses = { 200: WorkflowResponse } @@ -862,7 +945,7 @@ export type GetRagPipelinesByPipelineIdWorkflowsDraftResponse = GetRagPipelinesByPipelineIdWorkflowsDraftResponses[keyof GetRagPipelinesByPipelineIdWorkflowsDraftResponses] export type PostRagPipelinesByPipelineIdWorkflowsDraftData = { - body?: never + body: DraftWorkflowSyncPayload path: { pipeline_id: string } @@ -888,9 +971,7 @@ export type PostRagPipelinesByPipelineIdWorkflowsDraftDatasourceNodesByNodeIdRun } export type PostRagPipelinesByPipelineIdWorkflowsDraftDatasourceNodesByNodeIdRunResponses = { - 200: { - [key: string]: unknown - } + 200: RagPipelineOpaqueResponse } export type PostRagPipelinesByPipelineIdWorkflowsDraftDatasourceNodesByNodeIdRunResponse @@ -922,9 +1003,7 @@ export type GetRagPipelinesByPipelineIdWorkflowsDraftEnvironmentVariablesData = } export type GetRagPipelinesByPipelineIdWorkflowsDraftEnvironmentVariablesResponses = { - 200: { - [key: string]: unknown - } + 200: EnvironmentVariableListResponse } export type GetRagPipelinesByPipelineIdWorkflowsDraftEnvironmentVariablesResponse @@ -941,9 +1020,7 @@ export type PostRagPipelinesByPipelineIdWorkflowsDraftIterationNodesByNodeIdRunD } export type PostRagPipelinesByPipelineIdWorkflowsDraftIterationNodesByNodeIdRunResponses = { - 200: { - [key: string]: unknown - } + 200: RagPipelineOpaqueResponse } export type PostRagPipelinesByPipelineIdWorkflowsDraftIterationNodesByNodeIdRunResponse @@ -960,9 +1037,7 @@ export type PostRagPipelinesByPipelineIdWorkflowsDraftLoopNodesByNodeIdRunData = } export type PostRagPipelinesByPipelineIdWorkflowsDraftLoopNodesByNodeIdRunResponses = { - 200: { - [key: string]: unknown - } + 200: RagPipelineOpaqueResponse } export type PostRagPipelinesByPipelineIdWorkflowsDraftLoopNodesByNodeIdRunResponse @@ -1013,9 +1088,7 @@ export type DeleteRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesDa } export type DeleteRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesResponses = { - 200: { - [key: string]: unknown - } + 204: void } export type DeleteRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesResponse @@ -1032,9 +1105,7 @@ export type GetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesData } export type GetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesResponses = { - 200: { - [key: string]: unknown - } + 200: WorkflowDraftVariableList } export type GetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesResponse @@ -1045,14 +1116,14 @@ export type GetRagPipelinesByPipelineIdWorkflowsDraftPreProcessingParametersData path: { pipeline_id: string } - query?: never + query: { + node_id: string + } url: '/rag/pipelines/{pipeline_id}/workflows/draft/pre-processing/parameters' } export type GetRagPipelinesByPipelineIdWorkflowsDraftPreProcessingParametersResponses = { - 200: { - [key: string]: unknown - } + 200: RagPipelineStepParametersResponse } export type GetRagPipelinesByPipelineIdWorkflowsDraftPreProcessingParametersResponse @@ -1063,14 +1134,14 @@ export type GetRagPipelinesByPipelineIdWorkflowsDraftProcessingParametersData = path: { pipeline_id: string } - query?: never + query: { + node_id: string + } url: '/rag/pipelines/{pipeline_id}/workflows/draft/processing/parameters' } export type GetRagPipelinesByPipelineIdWorkflowsDraftProcessingParametersResponses = { - 200: { - [key: string]: unknown - } + 200: RagPipelineStepParametersResponse } export type GetRagPipelinesByPipelineIdWorkflowsDraftProcessingParametersResponse @@ -1086,9 +1157,7 @@ export type PostRagPipelinesByPipelineIdWorkflowsDraftRunData = { } export type PostRagPipelinesByPipelineIdWorkflowsDraftRunResponses = { - 200: { - [key: string]: unknown - } + 200: RagPipelineOpaqueResponse } export type PostRagPipelinesByPipelineIdWorkflowsDraftRunResponse @@ -1104,9 +1173,7 @@ export type GetRagPipelinesByPipelineIdWorkflowsDraftSystemVariablesData = { } export type GetRagPipelinesByPipelineIdWorkflowsDraftSystemVariablesResponses = { - 200: { - [key: string]: unknown - } + 200: WorkflowDraftVariableList } export type GetRagPipelinesByPipelineIdWorkflowsDraftSystemVariablesResponse @@ -1122,9 +1189,7 @@ export type DeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesData = { } export type DeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesResponses = { - 200: { - [key: string]: unknown - } + 204: void } export type DeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesResponse @@ -1135,14 +1200,15 @@ export type GetRagPipelinesByPipelineIdWorkflowsDraftVariablesData = { path: { pipeline_id: string } - query?: never + query?: { + limit?: number + page?: number + } url: '/rag/pipelines/{pipeline_id}/workflows/draft/variables' } export type GetRagPipelinesByPipelineIdWorkflowsDraftVariablesResponses = { - 200: { - [key: string]: unknown - } + 200: WorkflowDraftVariableListWithoutValue } export type GetRagPipelinesByPipelineIdWorkflowsDraftVariablesResponse @@ -1159,9 +1225,7 @@ export type DeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdDat } export type DeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponses = { - 200: { - [key: string]: unknown - } + 204: void } export type DeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponse @@ -1178,9 +1242,7 @@ export type GetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdData = } export type GetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponses = { - 200: { - [key: string]: unknown - } + 200: WorkflowDraftVariable } export type GetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponse @@ -1197,9 +1259,7 @@ export type PatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdData } export type PatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponses = { - 200: { - [key: string]: unknown - } + 200: WorkflowDraftVariable } export type PatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponse @@ -1216,9 +1276,8 @@ export type PutRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResetD } export type PutRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResetResponses = { - 200: { - [key: string]: unknown - } + 200: WorkflowDraftVariable + 204: void } export type PutRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResetResponse @@ -1268,9 +1327,7 @@ export type PostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeI export type PostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdPreviewResponses = { - 200: { - [key: string]: unknown - } + 200: DataSourceContentPreviewResponse } export type PostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdPreviewResponse @@ -1287,9 +1344,7 @@ export type PostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeI } export type PostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdRunResponses = { - 200: { - [key: string]: unknown - } + 200: RagPipelineOpaqueResponse } export type PostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdRunResponse @@ -1300,14 +1355,14 @@ export type GetRagPipelinesByPipelineIdWorkflowsPublishedPreProcessingParameters path: { pipeline_id: string } - query?: never + query: { + node_id: string + } url: '/rag/pipelines/{pipeline_id}/workflows/published/pre-processing/parameters' } export type GetRagPipelinesByPipelineIdWorkflowsPublishedPreProcessingParametersResponses = { - 200: { - [key: string]: unknown - } + 200: RagPipelineStepParametersResponse } export type GetRagPipelinesByPipelineIdWorkflowsPublishedPreProcessingParametersResponse @@ -1318,14 +1373,14 @@ export type GetRagPipelinesByPipelineIdWorkflowsPublishedProcessingParametersDat path: { pipeline_id: string } - query?: never + query: { + node_id: string + } url: '/rag/pipelines/{pipeline_id}/workflows/published/processing/parameters' } export type GetRagPipelinesByPipelineIdWorkflowsPublishedProcessingParametersResponses = { - 200: { - [key: string]: unknown - } + 200: RagPipelineStepParametersResponse } export type GetRagPipelinesByPipelineIdWorkflowsPublishedProcessingParametersResponse @@ -1341,9 +1396,7 @@ export type PostRagPipelinesByPipelineIdWorkflowsPublishedRunData = { } export type PostRagPipelinesByPipelineIdWorkflowsPublishedRunResponses = { - 200: { - [key: string]: unknown - } + 200: RagPipelineOpaqueResponse } export type PostRagPipelinesByPipelineIdWorkflowsPublishedRunResponse @@ -1360,16 +1413,14 @@ export type DeleteRagPipelinesByPipelineIdWorkflowsByWorkflowIdData = { } export type DeleteRagPipelinesByPipelineIdWorkflowsByWorkflowIdResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteRagPipelinesByPipelineIdWorkflowsByWorkflowIdResponse = DeleteRagPipelinesByPipelineIdWorkflowsByWorkflowIdResponses[keyof DeleteRagPipelinesByPipelineIdWorkflowsByWorkflowIdResponses] export type PatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdData = { - body?: never + body: WorkflowUpdatePayload path: { pipeline_id: string workflow_id: string @@ -1379,20 +1430,11 @@ export type PatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdData = { } export type PatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdErrors = { - 400: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 400: unknown + 403: unknown + 404: unknown } -export type PatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdError - = PatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdErrors[keyof PatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdErrors] - export type PatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdResponses = { 200: WorkflowResponse } diff --git a/packages/contracts/generated/api/console/rag/zod.gen.ts b/packages/contracts/generated/api/console/rag/zod.gen.ts index 9c0c586bc6b..31120fab148 100644 --- a/packages/contracts/generated/api/console/rag/zod.gen.ts +++ b/packages/contracts/generated/api/console/rag/zod.gen.ts @@ -39,6 +39,11 @@ export const zPipelineTemplateDetailResponse = z.object({ name: z.string(), }) +/** + * RagPipelineOpaqueResponse + */ +export const zRagPipelineOpaqueResponse = z.unknown() + /** * RagPipelineImportPayload */ @@ -61,6 +66,28 @@ export const zSimpleResultResponse = z.object({ result: z.string(), }) +/** + * DefaultBlockConfigsResponse + */ +export const zDefaultBlockConfigsResponse = z.array(z.record(z.string(), z.unknown())) + +/** + * DefaultBlockConfigResponse + */ +export const zDefaultBlockConfigResponse = z.record(z.string(), z.unknown()) + +/** + * DraftWorkflowSyncPayload + */ +export const zDraftWorkflowSyncPayload = z.object({ + conversation_variables: z.array(z.record(z.string(), z.unknown())).nullish(), + environment_variables: z.array(z.record(z.string(), z.unknown())).nullish(), + features: z.record(z.string(), z.unknown()).nullish(), + graph: z.record(z.string(), z.unknown()), + hash: z.string().nullish(), + rag_pipeline_variables: z.array(z.record(z.string(), z.unknown())).nullish(), +}) + /** * RagPipelineWorkflowSyncResponse */ @@ -103,6 +130,13 @@ export const zNodeRunRequiredPayload = z.object({ inputs: z.record(z.string(), z.unknown()), }) +/** + * RagPipelineStepParametersResponse + */ +export const zRagPipelineStepParametersResponse = z.object({ + variables: z.unknown(), +}) + /** * DraftWorkflowRunPayload */ @@ -113,12 +147,39 @@ export const zDraftWorkflowRunPayload = z.object({ start_node_id: z.string(), }) +export const zWorkflowDraftVariable = z.object({ + description: z.string().optional(), + edited: z.boolean().optional(), + full_content: z.record(z.string(), z.unknown()).optional(), + id: z.string().optional(), + is_truncated: z.boolean().optional(), + name: z.string().optional(), + selector: z.array(z.string()).optional(), + type: z.string().optional(), + value: z + .union([ + z.string(), + z.int(), + z.number(), + z.boolean(), + z.record(z.string(), z.unknown()), + z.array(z.unknown()), + ]) + .nullish(), + value_type: z.string().optional(), + visible: z.boolean().optional(), +}) + +export const zWorkflowDraftVariableList = z.object({ + items: z.array(zWorkflowDraftVariable).optional(), +}) + /** * WorkflowDraftVariablePatchPayload */ export const zWorkflowDraftVariablePatchPayload = z.object({ name: z.string().nullish(), - value: z.unknown().optional(), + value: z.unknown().nullish(), }) /** @@ -138,6 +199,11 @@ export const zParser = z.object({ inputs: z.record(z.string(), z.unknown()), }) +/** + * DataSourceContentPreviewResponse + */ +export const zDataSourceContentPreviewResponse = z.unknown() + /** * PublishedWorkflowRunPayload */ @@ -151,6 +217,14 @@ export const zPublishedWorkflowRunPayload = z.object({ start_node_id: z.string(), }) +/** + * WorkflowUpdatePayload + */ +export const zWorkflowUpdatePayload = z.object({ + marked_comment: z.string().max(100).nullish(), + marked_name: z.string().max(20).nullish(), +}) + /** * ImportStatus */ @@ -261,7 +335,7 @@ export const zSimpleAccount = z.object({ */ export const zWorkflowRunForListResponse = z.object({ created_at: z.int().nullish(), - created_by_account: zSimpleAccount.optional(), + created_by_account: zSimpleAccount.nullish(), elapsed_time: z.number().nullish(), exceptions_count: z.int().nullish(), finished_at: z.int().nullish(), @@ -297,8 +371,8 @@ export const zSimpleEndUser = z.object({ */ export const zWorkflowRunDetailResponse = z.object({ created_at: z.int().nullish(), - created_by_account: zSimpleAccount.optional(), - created_by_end_user: zSimpleEndUser.optional(), + created_by_account: zSimpleAccount.nullish(), + created_by_end_user: zSimpleEndUser.nullish(), created_by_role: z.string().nullish(), elapsed_time: z.number().nullish(), error: z.string().nullish(), @@ -319,8 +393,8 @@ export const zWorkflowRunDetailResponse = z.object({ */ export const zWorkflowRunNodeExecutionResponse = z.object({ created_at: z.int().nullish(), - created_by_account: zSimpleAccount.optional(), - created_by_end_user: zSimpleEndUser.optional(), + created_by_account: zSimpleAccount.nullish(), + created_by_end_user: zSimpleEndUser.nullish(), created_by_role: z.string().nullish(), elapsed_time: z.number().nullish(), error: z.string().nullish(), @@ -356,7 +430,7 @@ export const zWorkflowConversationVariableResponse = z.object({ description: z.string(), id: z.string(), name: z.string(), - value: z.record(z.string(), z.unknown()), + value: z.unknown(), value_type: z.string(), }) @@ -367,7 +441,7 @@ export const zWorkflowEnvironmentVariableResponse = z.object({ description: z.string(), id: z.string(), name: z.string(), - value: z.record(z.string(), z.unknown()), + value: z.unknown(), value_type: z.string(), }) @@ -379,7 +453,7 @@ export const zPipelineVariableResponse = z.object({ allowed_file_types: z.array(z.string()).nullish(), allowed_file_upload_methods: z.array(z.string()).nullish(), belong_to_node_id: z.string(), - default_value: z.record(z.string(), z.unknown()).optional(), + default_value: z.unknown().optional(), label: z.string(), max_length: z.int().nullish(), options: z.array(z.string()).nullish(), @@ -397,7 +471,7 @@ export const zPipelineVariableResponse = z.object({ export const zWorkflowResponse = z.object({ conversation_variables: z.array(zWorkflowConversationVariableResponse), created_at: z.int(), - created_by: zSimpleAccount.optional(), + created_by: zSimpleAccount.nullish(), environment_variables: z.array(zWorkflowEnvironmentVariableResponse), features: z.record(z.string(), z.unknown()), graph: z.record(z.string(), z.unknown()), @@ -408,7 +482,7 @@ export const zWorkflowResponse = z.object({ rag_pipeline_variables: z.array(zPipelineVariableResponse), tool_published: z.boolean(), updated_at: z.int(), - updated_by: zSimpleAccount.optional(), + updated_by: zSimpleAccount.nullish(), version: z.string(), }) @@ -422,6 +496,46 @@ export const zWorkflowPaginationResponse = z.object({ page: z.int(), }) +/** + * EnvironmentVariableItemResponse + */ +export const zEnvironmentVariableItemResponse = z.object({ + description: z.string().nullish(), + editable: z.boolean(), + edited: z.boolean(), + id: z.string(), + name: z.string(), + selector: z.array(z.string()), + type: z.string(), + value: z.unknown(), + value_type: z.string(), + visible: z.boolean(), +}) + +/** + * EnvironmentVariableListResponse + */ +export const zEnvironmentVariableListResponse = z.object({ + items: z.array(zEnvironmentVariableItemResponse), +}) + +export const zWorkflowDraftVariableWithoutValue = z.object({ + description: z.string().optional(), + edited: z.boolean().optional(), + id: z.string().optional(), + is_truncated: z.boolean().optional(), + name: z.string().optional(), + selector: z.array(z.string()).optional(), + type: z.string().optional(), + value_type: z.string().optional(), + visible: z.boolean().optional(), +}) + +export const zWorkflowDraftVariableListWithoutValue = z.object({ + items: z.array(zWorkflowDraftVariableWithoutValue).optional(), + total: z.int().optional(), +}) + /** * DatasetRerankingModelResponse */ @@ -435,22 +549,6 @@ export const zDatasetRerankingModelResponse = z.object({ */ export const zType = z.enum(['github', 'marketplace', 'package']) -/** - * PluginDependency - */ -export const zPluginDependency = z.object({ - current_identifier: z.string().nullish(), - type: zType, - value: z.unknown(), -}) - -/** - * RagPipelineImportCheckDependenciesResponse - */ -export const zRagPipelineImportCheckDependenciesResponse = z.object({ - leaked_dependencies: z.array(zPluginDependency).optional(), -}) - /** * Github */ @@ -477,6 +575,22 @@ export const zPackage = z.object({ version: z.string().nullish(), }) +/** + * PluginDependency + */ +export const zPluginDependency = z.object({ + current_identifier: z.string().nullish(), + type: zType, + value: z.union([zGithub, zMarketplace, zPackage]), +}) + +/** + * RagPipelineImportCheckDependenciesResponse + */ +export const zRagPipelineImportCheckDependenciesResponse = z.object({ + leaked_dependencies: z.array(zPluginDependency).optional(), +}) + /** * DatasetKeywordSettingResponse */ @@ -513,7 +627,7 @@ export const zDatasetRetrievalModelResponse = z.object({ score_threshold_enabled: z.boolean(), search_method: z.string(), top_k: z.int(), - weights: zDatasetWeightedScoreResponse.optional(), + weights: zDatasetWeightedScoreResponse.nullish(), }) /** @@ -536,7 +650,7 @@ export const zDatasetDetailResponse = z.object({ embedding_model_provider: z.string().nullable(), enable_api: z.boolean(), external_knowledge_info: zDatasetExternalKnowledgeInfoResponse.optional(), - external_retrieval_model: zDatasetExternalRetrievalModelResponse, + external_retrieval_model: zDatasetExternalRetrievalModelResponse.nullable(), icon_info: zDatasetIconInfoResponse.optional(), id: z.string(), indexing_technique: z.string().nullable(), @@ -564,10 +678,7 @@ export const zDeleteRagPipelineCustomizedTemplatesByTemplateIdPath = z.object({ /** * Pipeline template deleted */ -export const zDeleteRagPipelineCustomizedTemplatesByTemplateIdResponse = z.record( - z.string(), - z.never(), -) +export const zDeleteRagPipelineCustomizedTemplatesByTemplateIdResponse = z.void() export const zPatchRagPipelineCustomizedTemplatesByTemplateIdBody = zCustomizedPipelineTemplatePayload @@ -579,10 +690,7 @@ export const zPatchRagPipelineCustomizedTemplatesByTemplateIdPath = z.object({ /** * Pipeline template updated */ -export const zPatchRagPipelineCustomizedTemplatesByTemplateIdResponse = z.record( - z.string(), - z.never(), -) +export const zPatchRagPipelineCustomizedTemplatesByTemplateIdResponse = z.void() export const zPostRagPipelineCustomizedTemplatesByTemplateIdPath = z.object({ template_id: z.string(), @@ -631,7 +739,7 @@ export const zGetRagPipelineTemplatesByTemplateIdResponse = zPipelineTemplateDet /** * Success */ -export const zGetRagPipelinesDatasourcePluginsResponse = z.record(z.string(), z.unknown()) +export const zGetRagPipelinesDatasourcePluginsResponse = zRagPipelineOpaqueResponse export const zPostRagPipelinesImportsBody = zRagPipelineImportPayload @@ -659,10 +767,14 @@ export const zGetRagPipelinesImportsByPipelineIdCheckDependenciesPath = z.object export const zGetRagPipelinesImportsByPipelineIdCheckDependenciesResponse = zRagPipelineImportCheckDependenciesResponse +export const zGetRagPipelinesRecommendedPluginsQuery = z.object({ + type: z.string().optional().default('all'), +}) + /** * Success */ -export const zGetRagPipelinesRecommendedPluginsResponse = z.record(z.string(), z.unknown()) +export const zGetRagPipelinesRecommendedPluginsResponse = zRagPipelineOpaqueResponse export const zPostRagPipelinesTransformDatasetsByDatasetIdPath = z.object({ dataset_id: z.string(), @@ -671,10 +783,7 @@ export const zPostRagPipelinesTransformDatasetsByDatasetIdPath = z.object({ /** * Success */ -export const zPostRagPipelinesTransformDatasetsByDatasetIdResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostRagPipelinesTransformDatasetsByDatasetIdResponse = zRagPipelineOpaqueResponse export const zPostRagPipelinesByPipelineIdCustomizedPublishBody = zCustomizedPipelineTemplatePayload @@ -685,10 +794,7 @@ export const zPostRagPipelinesByPipelineIdCustomizedPublishPath = z.object({ /** * Pipeline template published */ -export const zPostRagPipelinesByPipelineIdCustomizedPublishResponse = z.record( - z.string(), - z.never(), -) +export const zPostRagPipelinesByPipelineIdCustomizedPublishResponse = z.void() export const zGetRagPipelinesByPipelineIdExportsPath = z.object({ pipeline_id: z.string(), @@ -707,6 +813,11 @@ export const zGetRagPipelinesByPipelineIdWorkflowRunsPath = z.object({ pipeline_id: z.string(), }) +export const zGetRagPipelinesByPipelineIdWorkflowRunsQuery = z.object({ + last_id: z.string().optional(), + limit: z.int().gte(1).lte(100).optional().default(20), +}) + /** * Workflow runs retrieved successfully */ @@ -748,6 +859,13 @@ export const zGetRagPipelinesByPipelineIdWorkflowsPath = z.object({ pipeline_id: z.string(), }) +export const zGetRagPipelinesByPipelineIdWorkflowsQuery = z.object({ + limit: z.int().gte(1).lte(100).optional().default(10), + named_only: z.boolean().optional().default(false), + page: z.int().gte(1).lte(99999).optional().default(1), + user_id: z.string().optional(), +}) + /** * Published workflows retrieved successfully */ @@ -758,12 +876,10 @@ export const zGetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsPat }) /** - * Success + * Default block configs retrieved successfully */ -export const zGetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsResponse + = zDefaultBlockConfigsResponse export const zGetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypePath = z.object({ @@ -771,11 +887,16 @@ export const zGetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsByB pipeline_id: z.string(), }) +export const zGetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeQuery + = z.object({ + q: z.string().optional(), + }) + /** - * Success + * Default block config retrieved successfully */ export const zGetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponse - = z.record(z.string(), z.unknown()) + = zDefaultBlockConfigResponse export const zGetRagPipelinesByPipelineIdWorkflowsDraftPath = z.object({ pipeline_id: z.string(), @@ -786,6 +907,8 @@ export const zGetRagPipelinesByPipelineIdWorkflowsDraftPath = z.object({ */ export const zGetRagPipelinesByPipelineIdWorkflowsDraftResponse = zWorkflowResponse +export const zPostRagPipelinesByPipelineIdWorkflowsDraftBody = zDraftWorkflowSyncPayload + export const zPostRagPipelinesByPipelineIdWorkflowsDraftPath = z.object({ pipeline_id: z.string(), }) @@ -807,7 +930,7 @@ export const zPostRagPipelinesByPipelineIdWorkflowsDraftDatasourceNodesByNodeIdR * Success */ export const zPostRagPipelinesByPipelineIdWorkflowsDraftDatasourceNodesByNodeIdRunResponse - = z.record(z.string(), z.unknown()) + = zRagPipelineOpaqueResponse export const zPostRagPipelinesByPipelineIdWorkflowsDraftDatasourceVariablesInspectBody = zDatasourceVariablesPayload @@ -827,12 +950,10 @@ export const zGetRagPipelinesByPipelineIdWorkflowsDraftEnvironmentVariablesPath }) /** - * Success + * Environment variables retrieved successfully */ -export const zGetRagPipelinesByPipelineIdWorkflowsDraftEnvironmentVariablesResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetRagPipelinesByPipelineIdWorkflowsDraftEnvironmentVariablesResponse + = zEnvironmentVariableListResponse export const zPostRagPipelinesByPipelineIdWorkflowsDraftIterationNodesByNodeIdRunBody = zNodeRunPayload @@ -846,7 +967,7 @@ export const zPostRagPipelinesByPipelineIdWorkflowsDraftIterationNodesByNodeIdRu * Success */ export const zPostRagPipelinesByPipelineIdWorkflowsDraftIterationNodesByNodeIdRunResponse - = z.record(z.string(), z.unknown()) + = zRagPipelineOpaqueResponse export const zPostRagPipelinesByPipelineIdWorkflowsDraftLoopNodesByNodeIdRunBody = zNodeRunPayload @@ -858,10 +979,8 @@ export const zPostRagPipelinesByPipelineIdWorkflowsDraftLoopNodesByNodeIdRunPath /** * Success */ -export const zPostRagPipelinesByPipelineIdWorkflowsDraftLoopNodesByNodeIdRunResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostRagPipelinesByPipelineIdWorkflowsDraftLoopNodesByNodeIdRunResponse + = zRagPipelineOpaqueResponse export const zGetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdLastRunPath = z.object({ node_id: z.string(), @@ -894,12 +1013,9 @@ export const zDeleteRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariables }) /** - * Success + * Node variables deleted successfully */ -export const zDeleteRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesResponse = z.record( - z.string(), - z.unknown(), -) +export const zDeleteRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesResponse = z.void() export const zGetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesPath = z.object({ node_id: z.string(), @@ -907,36 +1023,38 @@ export const zGetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesPat }) /** - * Success + * Node variables retrieved successfully */ -export const zGetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesResponse + = zWorkflowDraftVariableList export const zGetRagPipelinesByPipelineIdWorkflowsDraftPreProcessingParametersPath = z.object({ pipeline_id: z.string(), }) -/** - * Success - */ -export const zGetRagPipelinesByPipelineIdWorkflowsDraftPreProcessingParametersResponse = z.record( - z.string(), - z.unknown(), -) - -export const zGetRagPipelinesByPipelineIdWorkflowsDraftProcessingParametersPath = z.object({ - pipeline_id: z.string(), +export const zGetRagPipelinesByPipelineIdWorkflowsDraftPreProcessingParametersQuery = z.object({ + node_id: z.string(), }) /** * Success */ -export const zGetRagPipelinesByPipelineIdWorkflowsDraftProcessingParametersResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetRagPipelinesByPipelineIdWorkflowsDraftPreProcessingParametersResponse + = zRagPipelineStepParametersResponse + +export const zGetRagPipelinesByPipelineIdWorkflowsDraftProcessingParametersPath = z.object({ + pipeline_id: z.string(), +}) + +export const zGetRagPipelinesByPipelineIdWorkflowsDraftProcessingParametersQuery = z.object({ + node_id: z.string(), +}) + +/** + * Success + */ +export const zGetRagPipelinesByPipelineIdWorkflowsDraftProcessingParametersResponse + = zRagPipelineStepParametersResponse export const zPostRagPipelinesByPipelineIdWorkflowsDraftRunBody = zDraftWorkflowRunPayload @@ -947,46 +1065,41 @@ export const zPostRagPipelinesByPipelineIdWorkflowsDraftRunPath = z.object({ /** * Success */ -export const zPostRagPipelinesByPipelineIdWorkflowsDraftRunResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostRagPipelinesByPipelineIdWorkflowsDraftRunResponse = zRagPipelineOpaqueResponse export const zGetRagPipelinesByPipelineIdWorkflowsDraftSystemVariablesPath = z.object({ pipeline_id: z.string(), }) /** - * Success + * System variables retrieved successfully */ -export const zGetRagPipelinesByPipelineIdWorkflowsDraftSystemVariablesResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetRagPipelinesByPipelineIdWorkflowsDraftSystemVariablesResponse + = zWorkflowDraftVariableList export const zDeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesPath = z.object({ pipeline_id: z.string(), }) /** - * Success + * Workflow variables deleted successfully */ -export const zDeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesResponse = z.record( - z.string(), - z.unknown(), -) +export const zDeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesResponse = z.void() export const zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesPath = z.object({ pipeline_id: z.string(), }) +export const zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesQuery = z.object({ + limit: z.int().gte(1).lte(100).optional().default(20), + page: z.int().gte(1).lte(100000).optional().default(1), +}) + /** - * Success + * Workflow variables retrieved successfully */ -export const zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesResponse + = zWorkflowDraftVariableListWithoutValue export const zDeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdPath = z.object({ pipeline_id: z.string(), @@ -994,12 +1107,9 @@ export const zDeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdP }) /** - * Success + * Variable deleted successfully */ -export const zDeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponse = z.record( - z.string(), - z.unknown(), -) +export const zDeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponse = z.void() export const zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdPath = z.object({ pipeline_id: z.string(), @@ -1007,12 +1117,10 @@ export const zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdPath }) /** - * Success + * Variable retrieved successfully */ -export const zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponse + = zWorkflowDraftVariable export const zPatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdBody = zWorkflowDraftVariablePatchPayload @@ -1023,23 +1131,19 @@ export const zPatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdPa }) /** - * Success + * Variable updated successfully */ -export const zPatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponse = z.record( - z.string(), - z.unknown(), -) +export const zPatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponse + = zWorkflowDraftVariable export const zPutRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResetPath = z.object({ pipeline_id: z.string(), variable_id: z.string(), }) -/** - * Success - */ -export const zPutRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResetResponse - = z.record(z.string(), z.unknown()) +export const zPutRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResetResponse = z.union( + [zWorkflowDraftVariable, z.void()], +) export const zGetRagPipelinesByPipelineIdWorkflowsPublishPath = z.object({ pipeline_id: z.string(), @@ -1073,7 +1177,7 @@ export const zPostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNod * Success */ export const zPostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdPreviewResponse - = z.record(z.string(), z.unknown()) + = zDataSourceContentPreviewResponse export const zPostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdRunBody = zDatasourceNodeRunPayload @@ -1088,29 +1192,35 @@ export const zPostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNod * Success */ export const zPostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdRunResponse - = z.record(z.string(), z.unknown()) + = zRagPipelineOpaqueResponse export const zGetRagPipelinesByPipelineIdWorkflowsPublishedPreProcessingParametersPath = z.object({ pipeline_id: z.string(), }) +export const zGetRagPipelinesByPipelineIdWorkflowsPublishedPreProcessingParametersQuery = z.object({ + node_id: z.string(), +}) + /** * Success */ export const zGetRagPipelinesByPipelineIdWorkflowsPublishedPreProcessingParametersResponse - = z.record(z.string(), z.unknown()) + = zRagPipelineStepParametersResponse export const zGetRagPipelinesByPipelineIdWorkflowsPublishedProcessingParametersPath = z.object({ pipeline_id: z.string(), }) +export const zGetRagPipelinesByPipelineIdWorkflowsPublishedProcessingParametersQuery = z.object({ + node_id: z.string(), +}) + /** * Success */ -export const zGetRagPipelinesByPipelineIdWorkflowsPublishedProcessingParametersResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetRagPipelinesByPipelineIdWorkflowsPublishedProcessingParametersResponse + = zRagPipelineStepParametersResponse export const zPostRagPipelinesByPipelineIdWorkflowsPublishedRunBody = zPublishedWorkflowRunPayload @@ -1121,10 +1231,7 @@ export const zPostRagPipelinesByPipelineIdWorkflowsPublishedRunPath = z.object({ /** * Success */ -export const zPostRagPipelinesByPipelineIdWorkflowsPublishedRunResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostRagPipelinesByPipelineIdWorkflowsPublishedRunResponse = zRagPipelineOpaqueResponse export const zDeleteRagPipelinesByPipelineIdWorkflowsByWorkflowIdPath = z.object({ pipeline_id: z.string(), @@ -1134,10 +1241,9 @@ export const zDeleteRagPipelinesByPipelineIdWorkflowsByWorkflowIdPath = z.object /** * Workflow deleted successfully */ -export const zDeleteRagPipelinesByPipelineIdWorkflowsByWorkflowIdResponse = z.record( - z.string(), - z.never(), -) +export const zDeleteRagPipelinesByPipelineIdWorkflowsByWorkflowIdResponse = z.void() + +export const zPatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdBody = zWorkflowUpdatePayload export const zPatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdPath = z.object({ pipeline_id: z.string(), diff --git a/packages/contracts/generated/api/console/rule-code-generate/orpc.gen.ts b/packages/contracts/generated/api/console/rule-code-generate/orpc.gen.ts index fb3909f7489..1c5252525c5 100644 --- a/packages/contracts/generated/api/console/rule-code-generate/orpc.gen.ts +++ b/packages/contracts/generated/api/console/rule-code-generate/orpc.gen.ts @@ -7,16 +7,10 @@ import { zPostRuleCodeGenerateBody, zPostRuleCodeGenerateResponse } from './zod. /** * Generate code rules using LLM - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post = oc .route({ - deprecated: true, - description: - 'Generate code rules using LLM\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Generate code rules using LLM', inputStructure: 'detailed', method: 'POST', operationId: 'postRuleCodeGenerate', diff --git a/packages/contracts/generated/api/console/rule-code-generate/types.gen.ts b/packages/contracts/generated/api/console/rule-code-generate/types.gen.ts index c5fafa90a92..a1165a4f8a2 100644 --- a/packages/contracts/generated/api/console/rule-code-generate/types.gen.ts +++ b/packages/contracts/generated/api/console/rule-code-generate/types.gen.ts @@ -11,6 +11,8 @@ export type RuleCodeGeneratePayload = { no_variable?: boolean } +export type GeneratorResponse = unknown + export type ModelConfig = { completion_params?: { [key: string]: unknown @@ -30,20 +32,12 @@ export type PostRuleCodeGenerateData = { } export type PostRuleCodeGenerateErrors = { - 400: { - [key: string]: unknown - } - 402: { - [key: string]: unknown - } + 400: unknown + 402: unknown } -export type PostRuleCodeGenerateError = PostRuleCodeGenerateErrors[keyof PostRuleCodeGenerateErrors] - export type PostRuleCodeGenerateResponses = { - 200: { - [key: string]: unknown - } + 200: GeneratorResponse } export type PostRuleCodeGenerateResponse diff --git a/packages/contracts/generated/api/console/rule-code-generate/zod.gen.ts b/packages/contracts/generated/api/console/rule-code-generate/zod.gen.ts index f98d2f5dc09..97e1b816289 100644 --- a/packages/contracts/generated/api/console/rule-code-generate/zod.gen.ts +++ b/packages/contracts/generated/api/console/rule-code-generate/zod.gen.ts @@ -2,6 +2,11 @@ import * as z from 'zod' +/** + * GeneratorResponse + */ +export const zGeneratorResponse = z.unknown() + /** * LLMMode * @@ -34,4 +39,4 @@ export const zPostRuleCodeGenerateBody = zRuleCodeGeneratePayload /** * Code rules generated successfully */ -export const zPostRuleCodeGenerateResponse = z.record(z.string(), z.unknown()) +export const zPostRuleCodeGenerateResponse = zGeneratorResponse diff --git a/packages/contracts/generated/api/console/rule-generate/orpc.gen.ts b/packages/contracts/generated/api/console/rule-generate/orpc.gen.ts index 1351b459cc5..7bd233de2bc 100644 --- a/packages/contracts/generated/api/console/rule-generate/orpc.gen.ts +++ b/packages/contracts/generated/api/console/rule-generate/orpc.gen.ts @@ -7,16 +7,10 @@ import { zPostRuleGenerateBody, zPostRuleGenerateResponse } from './zod.gen' /** * Generate rule configuration using LLM - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post = oc .route({ - deprecated: true, - description: - 'Generate rule configuration using LLM\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Generate rule configuration using LLM', inputStructure: 'detailed', method: 'POST', operationId: 'postRuleGenerate', diff --git a/packages/contracts/generated/api/console/rule-generate/types.gen.ts b/packages/contracts/generated/api/console/rule-generate/types.gen.ts index 01f44c096c7..4e7c1421461 100644 --- a/packages/contracts/generated/api/console/rule-generate/types.gen.ts +++ b/packages/contracts/generated/api/console/rule-generate/types.gen.ts @@ -10,6 +10,8 @@ export type RuleGeneratePayload = { no_variable?: boolean } +export type GeneratorResponse = unknown + export type ModelConfig = { completion_params?: { [key: string]: unknown @@ -29,20 +31,12 @@ export type PostRuleGenerateData = { } export type PostRuleGenerateErrors = { - 400: { - [key: string]: unknown - } - 402: { - [key: string]: unknown - } + 400: unknown + 402: unknown } -export type PostRuleGenerateError = PostRuleGenerateErrors[keyof PostRuleGenerateErrors] - export type PostRuleGenerateResponses = { - 200: { - [key: string]: unknown - } + 200: GeneratorResponse } export type PostRuleGenerateResponse = PostRuleGenerateResponses[keyof PostRuleGenerateResponses] diff --git a/packages/contracts/generated/api/console/rule-generate/zod.gen.ts b/packages/contracts/generated/api/console/rule-generate/zod.gen.ts index aae7b67f0fc..6e539e63f4e 100644 --- a/packages/contracts/generated/api/console/rule-generate/zod.gen.ts +++ b/packages/contracts/generated/api/console/rule-generate/zod.gen.ts @@ -2,6 +2,11 @@ import * as z from 'zod' +/** + * GeneratorResponse + */ +export const zGeneratorResponse = z.unknown() + /** * LLMMode * @@ -33,4 +38,4 @@ export const zPostRuleGenerateBody = zRuleGeneratePayload /** * Rule configuration generated successfully */ -export const zPostRuleGenerateResponse = z.record(z.string(), z.unknown()) +export const zPostRuleGenerateResponse = zGeneratorResponse diff --git a/packages/contracts/generated/api/console/rule-structured-output-generate/orpc.gen.ts b/packages/contracts/generated/api/console/rule-structured-output-generate/orpc.gen.ts index 3fef5f3c3fc..276442f1c91 100644 --- a/packages/contracts/generated/api/console/rule-structured-output-generate/orpc.gen.ts +++ b/packages/contracts/generated/api/console/rule-structured-output-generate/orpc.gen.ts @@ -10,16 +10,10 @@ import { /** * Generate structured output rules using LLM - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post = oc .route({ - deprecated: true, - description: - 'Generate structured output rules using LLM\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Generate structured output rules using LLM', inputStructure: 'detailed', method: 'POST', operationId: 'postRuleStructuredOutputGenerate', diff --git a/packages/contracts/generated/api/console/rule-structured-output-generate/types.gen.ts b/packages/contracts/generated/api/console/rule-structured-output-generate/types.gen.ts index 0ab9d90904b..f7da1cd5cc8 100644 --- a/packages/contracts/generated/api/console/rule-structured-output-generate/types.gen.ts +++ b/packages/contracts/generated/api/console/rule-structured-output-generate/types.gen.ts @@ -9,6 +9,8 @@ export type RuleStructuredOutputPayload = { model_config: ModelConfig } +export type GeneratorResponse = unknown + export type ModelConfig = { completion_params?: { [key: string]: unknown @@ -28,21 +30,12 @@ export type PostRuleStructuredOutputGenerateData = { } export type PostRuleStructuredOutputGenerateErrors = { - 400: { - [key: string]: unknown - } - 402: { - [key: string]: unknown - } + 400: unknown + 402: unknown } -export type PostRuleStructuredOutputGenerateError - = PostRuleStructuredOutputGenerateErrors[keyof PostRuleStructuredOutputGenerateErrors] - export type PostRuleStructuredOutputGenerateResponses = { - 200: { - [key: string]: unknown - } + 200: GeneratorResponse } export type PostRuleStructuredOutputGenerateResponse diff --git a/packages/contracts/generated/api/console/rule-structured-output-generate/zod.gen.ts b/packages/contracts/generated/api/console/rule-structured-output-generate/zod.gen.ts index ddcabbba492..6119b0010d0 100644 --- a/packages/contracts/generated/api/console/rule-structured-output-generate/zod.gen.ts +++ b/packages/contracts/generated/api/console/rule-structured-output-generate/zod.gen.ts @@ -2,6 +2,11 @@ import * as z from 'zod' +/** + * GeneratorResponse + */ +export const zGeneratorResponse = z.unknown() + /** * LLMMode * @@ -32,4 +37,4 @@ export const zPostRuleStructuredOutputGenerateBody = zRuleStructuredOutputPayloa /** * Structured output generated successfully */ -export const zPostRuleStructuredOutputGenerateResponse = z.record(z.string(), z.unknown()) +export const zPostRuleStructuredOutputGenerateResponse = zGeneratorResponse diff --git a/packages/contracts/generated/api/console/snippets/orpc.gen.ts b/packages/contracts/generated/api/console/snippets/orpc.gen.ts index d64e39b2110..29c8c3c6ee5 100644 --- a/packages/contracts/generated/api/console/snippets/orpc.gen.ts +++ b/packages/contracts/generated/api/console/snippets/orpc.gen.ts @@ -15,6 +15,7 @@ import { zGetSnippetsBySnippetIdWorkflowRunsByRunIdPath, zGetSnippetsBySnippetIdWorkflowRunsByRunIdResponse, zGetSnippetsBySnippetIdWorkflowRunsPath, + zGetSnippetsBySnippetIdWorkflowRunsQuery, zGetSnippetsBySnippetIdWorkflowRunsResponse, zGetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsPath, zGetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsResponse, @@ -76,16 +77,11 @@ import { * * Uses both the legacy stop flag mechanism and the graph engine * command channel for backward compatibility. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post = oc .route({ - deprecated: true, description: - 'Uses both the legacy stop flag mechanism and the graph engine\ncommand channel for backward compatibility.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + 'Uses both the legacy stop flag mechanism and the graph engine\ncommand channel for backward compatibility.', inputStructure: 'detailed', method: 'POST', operationId: 'postSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStop', @@ -159,7 +155,12 @@ export const get3 = oc summary: 'List workflow runs for snippet', tags: ['console'], }) - .input(z.object({ params: zGetSnippetsBySnippetIdWorkflowRunsPath })) + .input( + z.object({ + params: zGetSnippetsBySnippetIdWorkflowRunsPath, + query: zGetSnippetsBySnippetIdWorkflowRunsQuery.optional(), + }), + ) .output(zGetSnippetsBySnippetIdWorkflowRunsResponse) export const workflowRuns = { @@ -170,16 +171,9 @@ export const workflowRuns = { /** * Get default block configurations for snippet workflow - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get4 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigs', @@ -196,16 +190,9 @@ export const defaultWorkflowBlockConfigs = { /** * Get snippet draft workflow configuration limits - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get5 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getSnippetsBySnippetIdWorkflowsDraftConfig', @@ -222,16 +209,11 @@ export const config = { /** * Conversation variables are not used in snippet workflows; returns an empty list for API parity - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get6 = oc .route({ - deprecated: true, description: - 'Conversation variables are not used in snippet workflows; returns an empty list for API parity\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + 'Conversation variables are not used in snippet workflows; returns an empty list for API parity', inputStructure: 'detailed', method: 'GET', operationId: 'getSnippetsBySnippetIdWorkflowsDraftConversationVariables', @@ -247,16 +229,10 @@ export const conversationVariables = { /** * Get environment variables from snippet draft workflow graph - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get7 = oc .route({ - deprecated: true, - description: - 'Get environment variables from snippet draft workflow graph\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get environment variables from snippet draft workflow graph', inputStructure: 'detailed', method: 'GET', operationId: 'getSnippetsBySnippetIdWorkflowsDraftEnvironmentVariables', @@ -276,16 +252,11 @@ export const environmentVariables = { * Run draft workflow iteration node for snippet * Iteration nodes execute their internal sub-graph multiple times over an input list. * Returns an SSE event stream with iteration progress and results. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post2 = oc .route({ - deprecated: true, description: - 'Run draft workflow iteration node for snippet\nIteration nodes execute their internal sub-graph multiple times over an input list.\nReturns an SSE event stream with iteration progress and results.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + 'Run draft workflow iteration node for snippet\nIteration nodes execute their internal sub-graph multiple times over an input list.\nReturns an SSE event stream with iteration progress and results.', inputStructure: 'detailed', method: 'POST', operationId: 'postSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRun', @@ -323,16 +294,11 @@ export const iteration = { * Run draft workflow loop node for snippet * Loop nodes execute their internal sub-graph repeatedly until a condition is met. * Returns an SSE event stream with loop progress and results. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post3 = oc .route({ - deprecated: true, description: - 'Run draft workflow loop node for snippet\nLoop nodes execute their internal sub-graph repeatedly until a condition is met.\nReturns an SSE event stream with loop progress and results.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + 'Run draft workflow loop node for snippet\nLoop nodes execute their internal sub-graph repeatedly until a condition is met.\nReturns an SSE event stream with loop progress and results.', inputStructure: 'detailed', method: 'POST', operationId: 'postSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRun', @@ -395,16 +361,11 @@ export const lastRun = { * Run a single node in snippet draft workflow (single-step debugging) * Executes a specific node with provided inputs for single-step debugging. * Returns the node execution result including status, outputs, and timing. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post4 = oc .route({ - deprecated: true, description: - 'Run a single node in snippet draft workflow (single-step debugging)\nExecutes a specific node with provided inputs for single-step debugging.\nReturns the node execution result including status, outputs, and timing.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + 'Run a single node in snippet draft workflow (single-step debugging)\nExecutes a specific node with provided inputs for single-step debugging.\nReturns the node execution result including status, outputs, and timing.', inputStructure: 'detailed', method: 'POST', operationId: 'postSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRun', @@ -442,16 +403,10 @@ export const delete_ = oc /** * Get variables for a specific node (snippet draft workflow) - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get9 = oc .route({ - deprecated: true, - description: - 'Get variables for a specific node (snippet draft workflow)\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get variables for a specific node (snippet draft workflow)', inputStructure: 'detailed', method: 'GET', operationId: 'getSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariables', @@ -481,16 +436,11 @@ export const nodes3 = { * * Executes the snippet's draft workflow with the provided inputs * and returns an SSE event stream with execution progress and results. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post5 = oc .route({ - deprecated: true, description: - 'Executes the snippet\'s draft workflow with the provided inputs\nand returns an SSE event stream with execution progress and results.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + 'Executes the snippet\'s draft workflow with the provided inputs\nand returns an SSE event stream with execution progress and results.', inputStructure: 'detailed', method: 'POST', operationId: 'postSnippetsBySnippetIdWorkflowsDraftRun', @@ -512,16 +462,11 @@ export const run4 = { /** * System variables are not used in snippet workflows; returns an empty list for API parity - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get10 = oc .route({ - deprecated: true, description: - 'System variables are not used in snippet workflows; returns an empty list for API parity\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + 'System variables are not used in snippet workflows; returns an empty list for API parity', inputStructure: 'detailed', method: 'GET', operationId: 'getSnippetsBySnippetIdWorkflowsDraftSystemVariables', @@ -537,16 +482,10 @@ export const systemVariables = { /** * Reset a draft workflow variable to its default value (snippet scope) - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const put = oc .route({ - deprecated: true, - description: - 'Reset a draft workflow variable to its default value (snippet scope)\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Reset a draft workflow variable to its default value (snippet scope)', inputStructure: 'detailed', method: 'PUT', operationId: 'putSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdReset', @@ -578,16 +517,10 @@ export const delete2 = oc /** * Get a specific draft workflow variable (snippet scope) - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get11 = oc .route({ - deprecated: true, - description: - 'Get a specific draft workflow variable (snippet scope)\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get a specific draft workflow variable (snippet scope)', inputStructure: 'detailed', method: 'GET', operationId: 'getSnippetsBySnippetIdWorkflowsDraftVariablesByVariableId', @@ -599,16 +532,10 @@ export const get11 = oc /** * Update a draft workflow variable (snippet scope) - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const patch = oc .route({ - deprecated: true, - description: - 'Update a draft workflow variable (snippet scope)\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Update a draft workflow variable (snippet scope)', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableId', @@ -648,16 +575,10 @@ export const delete3 = oc /** * List draft workflow variables without values (paginated, snippet scope) - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get12 = oc .route({ - deprecated: true, - description: - 'List draft workflow variables without values (paginated, snippet scope)\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'List draft workflow variables without values (paginated, snippet scope)', inputStructure: 'detailed', method: 'GET', operationId: 'getSnippetsBySnippetIdWorkflowsDraftVariables', @@ -680,16 +601,9 @@ export const variables2 = { /** * Get draft workflow for snippet - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get13 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getSnippetsBySnippetIdWorkflowsDraft', @@ -702,16 +616,9 @@ export const get13 = oc /** * Sync draft workflow for snippet - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post6 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postSnippetsBySnippetIdWorkflowsDraft', @@ -743,16 +650,9 @@ export const draft = { /** * Get published workflow for snippet - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get14 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getSnippetsBySnippetIdWorkflowsPublish', @@ -765,16 +665,9 @@ export const get14 = oc /** * Publish snippet workflow - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post7 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postSnippetsBySnippetIdWorkflowsPublish', @@ -799,16 +692,10 @@ export const publish = { * Restore a published snippet workflow version into the draft workflow * * Restore a published snippet workflow version into the draft workflow - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post8 = oc .route({ - deprecated: true, - description: - 'Restore a published snippet workflow version into the draft workflow\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Restore a published snippet workflow version into the draft workflow', inputStructure: 'detailed', method: 'POST', operationId: 'postSnippetsBySnippetIdWorkflowsByWorkflowIdRestore', @@ -831,16 +718,10 @@ export const byWorkflowId = { * Get all published workflow versions for snippet * * Get all published workflows for a snippet - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get15 = oc .route({ - deprecated: true, - description: - 'Get all published workflows for a snippet\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get all published workflows for a snippet', inputStructure: 'detailed', method: 'GET', operationId: 'getSnippetsBySnippetIdWorkflows', diff --git a/packages/contracts/generated/api/console/snippets/types.gen.ts b/packages/contracts/generated/api/console/snippets/types.gen.ts index ae46ee98134..d46a6389a52 100644 --- a/packages/contracts/generated/api/console/snippets/types.gen.ts +++ b/packages/contracts/generated/api/console/snippets/types.gen.ts @@ -10,10 +10,14 @@ export type WorkflowRunPaginationResponse = { limit: number } +export type SimpleResultResponse = { + result: string +} + export type WorkflowRunDetailResponse = { created_at?: number | null - created_by_account?: SimpleAccount - created_by_end_user?: SimpleEndUser + created_by_account?: SimpleAccount | null + created_by_end_user?: SimpleEndUser | null created_by_role?: string | null elapsed_time?: number | null error?: string | null @@ -40,10 +44,14 @@ export type WorkflowPaginationResponse = { page: number } +export type DefaultBlockConfigsResponse = Array<{ + [key: string]: unknown +}> + export type SnippetWorkflowResponse = { conversation_variables: Array created_at: number - created_by?: SimpleAccount + created_by?: SimpleAccount | null environment_variables: Array features: { [key: string]: unknown @@ -61,7 +69,7 @@ export type SnippetWorkflowResponse = { rag_pipeline_variables: Array tool_published: boolean updated_at: number - updated_by?: SimpleAccount + updated_by?: SimpleAccount | null version: string } @@ -78,16 +86,32 @@ export type SnippetDraftSyncPayload = { }> | null } +export type WorkflowRestoreResponse = { + hash: string + result: string + updated_at: number +} + +export type SnippetDraftConfigResponse = { + parallel_depth_limit: number +} + export type WorkflowDraftVariableList = { items?: Array } +export type EnvironmentVariableListResponse = { + items: Array +} + export type SnippetIterationNodeRunPayload = { inputs?: { [key: string]: unknown } | null } +export type GeneratedAppResponse = JsonValue + export type SnippetLoopNodeRunPayload = { inputs?: { [key: string]: unknown @@ -96,8 +120,8 @@ export type SnippetLoopNodeRunPayload = { export type WorkflowRunNodeExecutionResponse = { created_at?: number | null - created_by_account?: SimpleAccount - created_by_end_user?: SimpleEndUser + created_by_account?: SimpleAccount | null + created_by_end_user?: SimpleEndUser | null created_by_role?: string | null elapsed_time?: number | null error?: string | null @@ -140,9 +164,7 @@ export type SnippetDraftRunPayload = { export type WorkflowDraftVariableListWithoutValue = { items?: Array - total?: { - [key: string]: unknown - } + total?: number } export type WorkflowDraftVariable = { @@ -156,16 +178,23 @@ export type WorkflowDraftVariable = { name?: string selector?: Array type?: string - value?: { - [key: string]: unknown - } + value?: + | string + | number + | number + | boolean + | { + [key: string]: unknown + } + | Array + | null value_type?: string visible?: boolean } export type WorkflowDraftVariableUpdatePayload = { name?: string | null - value?: unknown + value?: unknown | null } export type PublishWorkflowPayload = { @@ -174,9 +203,14 @@ export type PublishWorkflowPayload = { } | null } +export type WorkflowPublishResponse = { + created_at: number + result: string +} + export type WorkflowRunForListResponse = { created_at?: number | null - created_by_account?: SimpleAccount + created_by_account?: SimpleAccount | null elapsed_time?: number | null exceptions_count?: number | null finished_at?: number | null @@ -204,7 +238,7 @@ export type SimpleEndUser = { export type WorkflowResponse = { conversation_variables: Array created_at: number - created_by?: SimpleAccount + created_by?: SimpleAccount | null environment_variables: Array features: { [key: string]: unknown @@ -219,7 +253,7 @@ export type WorkflowResponse = { rag_pipeline_variables: Array tool_published: boolean updated_at: number - updated_by?: SimpleAccount + updated_by?: SimpleAccount | null version: string } @@ -227,9 +261,7 @@ export type WorkflowConversationVariableResponse = { description: string id: string name: string - value: { - [key: string]: unknown - } + value: unknown value_type: string } @@ -237,9 +269,7 @@ export type WorkflowEnvironmentVariableResponse = { description: string id: string name: string - value: { - [key: string]: unknown - } + value: unknown value_type: string } @@ -248,9 +278,7 @@ export type PipelineVariableResponse = { allowed_file_types?: Array | null allowed_file_upload_methods?: Array | null belong_to_node_id: string - default_value?: { - [key: string]: unknown - } + default_value?: unknown label: string max_length?: number | null options?: Array | null @@ -262,6 +290,30 @@ export type PipelineVariableResponse = { variable: string } +export type EnvironmentVariableItemResponse = { + description?: string | null + editable: boolean + edited: boolean + id: string + name: string + selector: Array + type: string + value: unknown + value_type: string + visible: boolean +} + +export type JsonValue + = | string + | number + | number + | boolean + | { + [key: string]: unknown + } + | Array + | null + export type WorkflowDraftVariableWithoutValue = { description?: string edited?: boolean @@ -279,7 +331,10 @@ export type GetSnippetsBySnippetIdWorkflowRunsData = { path: { snippet_id: string } - query?: never + query?: { + last_id?: string + limit?: number + } url: '/snippets/{snippet_id}/workflow-runs' } @@ -301,18 +356,11 @@ export type PostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopData = { } export type PostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type PostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopError - = PostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopErrors[keyof PostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopErrors] - export type PostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopResponses = { - 200: { - [key: string]: unknown - } + 200: SimpleResultResponse } export type PostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopResponse @@ -329,14 +377,9 @@ export type GetSnippetsBySnippetIdWorkflowRunsByRunIdData = { } export type GetSnippetsBySnippetIdWorkflowRunsByRunIdErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type GetSnippetsBySnippetIdWorkflowRunsByRunIdError - = GetSnippetsBySnippetIdWorkflowRunsByRunIdErrors[keyof GetSnippetsBySnippetIdWorkflowRunsByRunIdErrors] - export type GetSnippetsBySnippetIdWorkflowRunsByRunIdResponses = { 200: WorkflowRunDetailResponse } @@ -390,9 +433,7 @@ export type GetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsData = { } export type GetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsResponses = { - 200: { - [key: string]: unknown - } + 200: DefaultBlockConfigsResponse } export type GetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsResponse @@ -408,14 +449,9 @@ export type GetSnippetsBySnippetIdWorkflowsDraftData = { } export type GetSnippetsBySnippetIdWorkflowsDraftErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type GetSnippetsBySnippetIdWorkflowsDraftError - = GetSnippetsBySnippetIdWorkflowsDraftErrors[keyof GetSnippetsBySnippetIdWorkflowsDraftErrors] - export type GetSnippetsBySnippetIdWorkflowsDraftResponses = { 200: SnippetWorkflowResponse } @@ -433,18 +469,11 @@ export type PostSnippetsBySnippetIdWorkflowsDraftData = { } export type PostSnippetsBySnippetIdWorkflowsDraftErrors = { - 400: { - [key: string]: unknown - } + 400: unknown } -export type PostSnippetsBySnippetIdWorkflowsDraftError - = PostSnippetsBySnippetIdWorkflowsDraftErrors[keyof PostSnippetsBySnippetIdWorkflowsDraftErrors] - export type PostSnippetsBySnippetIdWorkflowsDraftResponses = { - 200: { - [key: string]: unknown - } + 200: WorkflowRestoreResponse } export type PostSnippetsBySnippetIdWorkflowsDraftResponse @@ -460,9 +489,7 @@ export type GetSnippetsBySnippetIdWorkflowsDraftConfigData = { } export type GetSnippetsBySnippetIdWorkflowsDraftConfigResponses = { - 200: { - [key: string]: unknown - } + 200: SnippetDraftConfigResponse } export type GetSnippetsBySnippetIdWorkflowsDraftConfigResponse @@ -494,18 +521,11 @@ export type GetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesData = { } export type GetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type GetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesError - = GetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesErrors[keyof GetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesErrors] - export type GetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesResponses = { - 200: { - [key: string]: unknown - } + 200: EnvironmentVariableListResponse } export type GetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesResponse @@ -522,18 +542,11 @@ export type PostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunData = } export type PostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type PostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunError - = PostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunErrors[keyof PostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunErrors] - export type PostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunResponses = { - 200: { - [key: string]: unknown - } + 200: GeneratedAppResponse } export type PostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunResponse @@ -550,18 +563,11 @@ export type PostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunData = { } export type PostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type PostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunError - = PostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunErrors[keyof PostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunErrors] - export type PostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunResponses = { - 200: { - [key: string]: unknown - } + 200: GeneratedAppResponse } export type PostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunResponse @@ -578,14 +584,9 @@ export type GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunData = { } export type GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunError - = GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunErrors[keyof GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunErrors] - export type GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunResponses = { 200: WorkflowRunNodeExecutionResponse } @@ -604,14 +605,9 @@ export type PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunData = { } export type PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunError - = PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunErrors[keyof PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunErrors] - export type PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunResponses = { 200: WorkflowRunNodeExecutionResponse } @@ -630,9 +626,7 @@ export type DeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesData = } export type DeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponse @@ -665,18 +659,11 @@ export type PostSnippetsBySnippetIdWorkflowsDraftRunData = { } export type PostSnippetsBySnippetIdWorkflowsDraftRunErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type PostSnippetsBySnippetIdWorkflowsDraftRunError - = PostSnippetsBySnippetIdWorkflowsDraftRunErrors[keyof PostSnippetsBySnippetIdWorkflowsDraftRunErrors] - export type PostSnippetsBySnippetIdWorkflowsDraftRunResponses = { - 200: { - [key: string]: unknown - } + 200: GeneratedAppResponse } export type PostSnippetsBySnippetIdWorkflowsDraftRunResponse @@ -708,9 +695,7 @@ export type DeleteSnippetsBySnippetIdWorkflowsDraftVariablesData = { } export type DeleteSnippetsBySnippetIdWorkflowsDraftVariablesResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteSnippetsBySnippetIdWorkflowsDraftVariablesResponse @@ -746,18 +731,11 @@ export type DeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdData = { } export type DeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type DeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdError - = DeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdErrors[keyof DeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdErrors] - export type DeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse @@ -774,14 +752,9 @@ export type GetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdData = { } export type GetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type GetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdError - = GetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdErrors[keyof GetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdErrors] - export type GetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponses = { 200: WorkflowDraftVariable } @@ -800,14 +773,9 @@ export type PatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdData = { } export type PatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type PatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdError - = PatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdErrors[keyof PatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdErrors] - export type PatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponses = { 200: WorkflowDraftVariable } @@ -826,19 +794,12 @@ export type PutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetData = } export type PutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type PutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetError - = PutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetErrors[keyof PutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetErrors] - export type PutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetResponses = { 200: WorkflowDraftVariable - 204: { - [key: string]: never - } + 204: void } export type PutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetResponse @@ -854,14 +815,9 @@ export type GetSnippetsBySnippetIdWorkflowsPublishData = { } export type GetSnippetsBySnippetIdWorkflowsPublishErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type GetSnippetsBySnippetIdWorkflowsPublishError - = GetSnippetsBySnippetIdWorkflowsPublishErrors[keyof GetSnippetsBySnippetIdWorkflowsPublishErrors] - export type GetSnippetsBySnippetIdWorkflowsPublishResponses = { 200: SnippetWorkflowResponse } @@ -879,18 +835,11 @@ export type PostSnippetsBySnippetIdWorkflowsPublishData = { } export type PostSnippetsBySnippetIdWorkflowsPublishErrors = { - 400: { - [key: string]: unknown - } + 400: unknown } -export type PostSnippetsBySnippetIdWorkflowsPublishError - = PostSnippetsBySnippetIdWorkflowsPublishErrors[keyof PostSnippetsBySnippetIdWorkflowsPublishErrors] - export type PostSnippetsBySnippetIdWorkflowsPublishResponses = { - 200: { - [key: string]: unknown - } + 200: WorkflowPublishResponse } export type PostSnippetsBySnippetIdWorkflowsPublishResponse @@ -907,21 +856,12 @@ export type PostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreData = { } export type PostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreErrors = { - 400: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 400: unknown + 404: unknown } -export type PostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreError - = PostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreErrors[keyof PostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreErrors] - export type PostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreResponses = { - 200: { - [key: string]: unknown - } + 200: WorkflowRestoreResponse } export type PostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreResponse diff --git a/packages/contracts/generated/api/console/snippets/zod.gen.ts b/packages/contracts/generated/api/console/snippets/zod.gen.ts index 20b932592a6..8f1756ba499 100644 --- a/packages/contracts/generated/api/console/snippets/zod.gen.ts +++ b/packages/contracts/generated/api/console/snippets/zod.gen.ts @@ -2,6 +2,18 @@ import * as z from 'zod' +/** + * SimpleResultResponse + */ +export const zSimpleResultResponse = z.object({ + result: z.string(), +}) + +/** + * DefaultBlockConfigsResponse + */ +export const zDefaultBlockConfigsResponse = z.array(z.record(z.string(), z.unknown())) + /** * SnippetDraftSyncPayload * @@ -14,6 +26,22 @@ export const zSnippetDraftSyncPayload = z.object({ input_fields: z.array(z.record(z.string(), z.unknown())).nullish(), }) +/** + * WorkflowRestoreResponse + */ +export const zWorkflowRestoreResponse = z.object({ + hash: z.string(), + result: z.string(), + updated_at: z.int(), +}) + +/** + * SnippetDraftConfigResponse + */ +export const zSnippetDraftConfigResponse = z.object({ + parallel_depth_limit: z.int(), +}) + /** * SnippetIterationNodeRunPayload * @@ -62,7 +90,16 @@ export const zWorkflowDraftVariable = z.object({ name: z.string().optional(), selector: z.array(z.string()).optional(), type: z.string().optional(), - value: z.record(z.string(), z.unknown()).optional(), + value: z + .union([ + z.string(), + z.int(), + z.number(), + z.boolean(), + z.record(z.string(), z.unknown()), + z.array(z.unknown()), + ]) + .nullish(), value_type: z.string().optional(), visible: z.boolean().optional(), }) @@ -76,7 +113,7 @@ export const zWorkflowDraftVariableList = z.object({ */ export const zWorkflowDraftVariableUpdatePayload = z.object({ name: z.string().nullish(), - value: z.unknown().optional(), + value: z.unknown().nullish(), }) /** @@ -88,6 +125,14 @@ export const zPublishWorkflowPayload = z.object({ knowledge_base_setting: z.record(z.string(), z.unknown()).nullish(), }) +/** + * WorkflowPublishResponse + */ +export const zWorkflowPublishResponse = z.object({ + created_at: z.int(), + result: z.string(), +}) + /** * SimpleAccount */ @@ -102,7 +147,7 @@ export const zSimpleAccount = z.object({ */ export const zWorkflowRunForListResponse = z.object({ created_at: z.int().nullish(), - created_by_account: zSimpleAccount.optional(), + created_by_account: zSimpleAccount.nullish(), elapsed_time: z.number().nullish(), exceptions_count: z.int().nullish(), finished_at: z.int().nullish(), @@ -138,8 +183,8 @@ export const zSimpleEndUser = z.object({ */ export const zWorkflowRunDetailResponse = z.object({ created_at: z.int().nullish(), - created_by_account: zSimpleAccount.optional(), - created_by_end_user: zSimpleEndUser.optional(), + created_by_account: zSimpleAccount.nullish(), + created_by_end_user: zSimpleEndUser.nullish(), created_by_role: z.string().nullish(), elapsed_time: z.number().nullish(), error: z.string().nullish(), @@ -160,8 +205,8 @@ export const zWorkflowRunDetailResponse = z.object({ */ export const zWorkflowRunNodeExecutionResponse = z.object({ created_at: z.int().nullish(), - created_by_account: zSimpleAccount.optional(), - created_by_end_user: zSimpleEndUser.optional(), + created_by_account: zSimpleAccount.nullish(), + created_by_end_user: zSimpleEndUser.nullish(), created_by_role: z.string().nullish(), elapsed_time: z.number().nullish(), error: z.string().nullish(), @@ -197,7 +242,7 @@ export const zWorkflowConversationVariableResponse = z.object({ description: z.string(), id: z.string(), name: z.string(), - value: z.record(z.string(), z.unknown()), + value: z.unknown(), value_type: z.string(), }) @@ -208,7 +253,7 @@ export const zWorkflowEnvironmentVariableResponse = z.object({ description: z.string(), id: z.string(), name: z.string(), - value: z.record(z.string(), z.unknown()), + value: z.unknown(), value_type: z.string(), }) @@ -220,7 +265,7 @@ export const zPipelineVariableResponse = z.object({ allowed_file_types: z.array(z.string()).nullish(), allowed_file_upload_methods: z.array(z.string()).nullish(), belong_to_node_id: z.string(), - default_value: z.record(z.string(), z.unknown()).optional(), + default_value: z.unknown().optional(), label: z.string(), max_length: z.int().nullish(), options: z.array(z.string()).nullish(), @@ -238,7 +283,7 @@ export const zPipelineVariableResponse = z.object({ export const zSnippetWorkflowResponse = z.object({ conversation_variables: z.array(zWorkflowConversationVariableResponse), created_at: z.int(), - created_by: zSimpleAccount.optional(), + created_by: zSimpleAccount.nullish(), environment_variables: z.array(zWorkflowEnvironmentVariableResponse), features: z.record(z.string(), z.unknown()), graph: z.record(z.string(), z.unknown()), @@ -250,7 +295,7 @@ export const zSnippetWorkflowResponse = z.object({ rag_pipeline_variables: z.array(zPipelineVariableResponse), tool_published: z.boolean(), updated_at: z.int(), - updated_by: zSimpleAccount.optional(), + updated_by: zSimpleAccount.nullish(), version: z.string(), }) @@ -260,7 +305,7 @@ export const zSnippetWorkflowResponse = z.object({ export const zWorkflowResponse = z.object({ conversation_variables: z.array(zWorkflowConversationVariableResponse), created_at: z.int(), - created_by: zSimpleAccount.optional(), + created_by: zSimpleAccount.nullish(), environment_variables: z.array(zWorkflowEnvironmentVariableResponse), features: z.record(z.string(), z.unknown()), graph: z.record(z.string(), z.unknown()), @@ -271,7 +316,7 @@ export const zWorkflowResponse = z.object({ rag_pipeline_variables: z.array(zPipelineVariableResponse), tool_published: z.boolean(), updated_at: z.int(), - updated_by: zSimpleAccount.optional(), + updated_by: zSimpleAccount.nullish(), version: z.string(), }) @@ -285,6 +330,45 @@ export const zWorkflowPaginationResponse = z.object({ page: z.int(), }) +/** + * EnvironmentVariableItemResponse + */ +export const zEnvironmentVariableItemResponse = z.object({ + description: z.string().nullish(), + editable: z.boolean(), + edited: z.boolean(), + id: z.string(), + name: z.string(), + selector: z.array(z.string()), + type: z.string(), + value: z.unknown(), + value_type: z.string(), + visible: z.boolean(), +}) + +/** + * EnvironmentVariableListResponse + */ +export const zEnvironmentVariableListResponse = z.object({ + items: z.array(zEnvironmentVariableItemResponse), +}) + +export const zJsonValue = z + .union([ + z.string(), + z.int(), + z.number(), + z.boolean(), + z.record(z.string(), z.unknown()), + z.array(z.unknown()), + ]) + .nullable() + +/** + * GeneratedAppResponse + */ +export const zGeneratedAppResponse = zJsonValue + export const zWorkflowDraftVariableWithoutValue = z.object({ description: z.string().optional(), edited: z.boolean().optional(), @@ -299,13 +383,18 @@ export const zWorkflowDraftVariableWithoutValue = z.object({ export const zWorkflowDraftVariableListWithoutValue = z.object({ items: z.array(zWorkflowDraftVariableWithoutValue).optional(), - total: z.record(z.string(), z.unknown()).optional(), + total: z.int().optional(), }) export const zGetSnippetsBySnippetIdWorkflowRunsPath = z.object({ snippet_id: z.string(), }) +export const zGetSnippetsBySnippetIdWorkflowRunsQuery = z.object({ + last_id: z.string().optional(), + limit: z.int().gte(1).lte(100).optional().default(20), +}) + /** * Workflow runs retrieved successfully */ @@ -319,10 +408,7 @@ export const zPostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopPath = z.objec /** * Task stopped successfully */ -export const zPostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopResponse = zSimpleResultResponse export const zGetSnippetsBySnippetIdWorkflowRunsByRunIdPath = z.object({ run_id: z.string(), @@ -366,10 +452,8 @@ export const zGetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsPath = z /** * Default block configs retrieved successfully */ -export const zGetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsResponse + = zDefaultBlockConfigsResponse export const zGetSnippetsBySnippetIdWorkflowsDraftPath = z.object({ snippet_id: z.string(), @@ -389,7 +473,7 @@ export const zPostSnippetsBySnippetIdWorkflowsDraftPath = z.object({ /** * Draft workflow synced successfully */ -export const zPostSnippetsBySnippetIdWorkflowsDraftResponse = z.record(z.string(), z.unknown()) +export const zPostSnippetsBySnippetIdWorkflowsDraftResponse = zWorkflowRestoreResponse export const zGetSnippetsBySnippetIdWorkflowsDraftConfigPath = z.object({ snippet_id: z.string(), @@ -398,7 +482,7 @@ export const zGetSnippetsBySnippetIdWorkflowsDraftConfigPath = z.object({ /** * Draft config retrieved successfully */ -export const zGetSnippetsBySnippetIdWorkflowsDraftConfigResponse = z.record(z.string(), z.unknown()) +export const zGetSnippetsBySnippetIdWorkflowsDraftConfigResponse = zSnippetDraftConfigResponse export const zGetSnippetsBySnippetIdWorkflowsDraftConversationVariablesPath = z.object({ snippet_id: z.string(), @@ -417,10 +501,8 @@ export const zGetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesPath = z.o /** * Environment variables retrieved successfully */ -export const zGetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesResponse + = zEnvironmentVariableListResponse export const zPostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunBody = zSnippetIterationNodeRunPayload @@ -433,10 +515,8 @@ export const zPostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunPath /** * Iteration node run started successfully (SSE stream) */ -export const zPostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunResponse + = zGeneratedAppResponse export const zPostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunBody = zSnippetLoopNodeRunPayload @@ -449,10 +529,8 @@ export const zPostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunPath = z. /** * Loop node run started successfully (SSE stream) */ -export const zPostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunResponse + = zGeneratedAppResponse export const zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunPath = z.object({ node_id: z.string(), @@ -487,10 +565,7 @@ export const zDeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesPath /** * Node variables deleted successfully */ -export const zDeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponse = z.record( - z.string(), - z.never(), -) +export const zDeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponse = z.void() export const zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesPath = z.object({ node_id: z.string(), @@ -512,7 +587,7 @@ export const zPostSnippetsBySnippetIdWorkflowsDraftRunPath = z.object({ /** * Draft workflow run started successfully (SSE stream) */ -export const zPostSnippetsBySnippetIdWorkflowsDraftRunResponse = z.record(z.string(), z.unknown()) +export const zPostSnippetsBySnippetIdWorkflowsDraftRunResponse = zGeneratedAppResponse export const zGetSnippetsBySnippetIdWorkflowsDraftSystemVariablesPath = z.object({ snippet_id: z.string(), @@ -531,10 +606,7 @@ export const zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesPath = z.object({ /** * Workflow variables deleted successfully */ -export const zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesResponse = z.record( - z.string(), - z.never(), -) +export const zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesResponse = z.void() export const zGetSnippetsBySnippetIdWorkflowsDraftVariablesPath = z.object({ snippet_id: z.string(), @@ -559,10 +631,7 @@ export const zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdPath = /** * Variable deleted successfully */ -export const zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse = z.record( - z.string(), - z.never(), -) +export const zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse = z.void() export const zGetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdPath = z.object({ snippet_id: z.string(), @@ -596,7 +665,7 @@ export const zPutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetPath export const zPutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetResponse = z.union([ zWorkflowDraftVariable, - z.record(z.string(), z.never()), + z.void(), ]) export const zGetSnippetsBySnippetIdWorkflowsPublishPath = z.object({ @@ -617,7 +686,7 @@ export const zPostSnippetsBySnippetIdWorkflowsPublishPath = z.object({ /** * Workflow published successfully */ -export const zPostSnippetsBySnippetIdWorkflowsPublishResponse = z.record(z.string(), z.unknown()) +export const zPostSnippetsBySnippetIdWorkflowsPublishResponse = zWorkflowPublishResponse export const zPostSnippetsBySnippetIdWorkflowsByWorkflowIdRestorePath = z.object({ snippet_id: z.string(), @@ -627,7 +696,4 @@ export const zPostSnippetsBySnippetIdWorkflowsByWorkflowIdRestorePath = z.object /** * Workflow restored successfully */ -export const zPostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreResponse = zWorkflowRestoreResponse diff --git a/packages/contracts/generated/api/console/spec/orpc.gen.ts b/packages/contracts/generated/api/console/spec/orpc.gen.ts index 9af554b5c50..bd2e750e6d6 100644 --- a/packages/contracts/generated/api/console/spec/orpc.gen.ts +++ b/packages/contracts/generated/api/console/spec/orpc.gen.ts @@ -8,16 +8,10 @@ import { zGetSpecSchemaDefinitionsResponse } from './zod.gen' * Get system JSON Schema definitions specification * * Used for frontend component type mapping - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get = oc .route({ - deprecated: true, - description: - 'Used for frontend component type mapping\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Used for frontend component type mapping', inputStructure: 'detailed', method: 'GET', operationId: 'getSpecSchemaDefinitions', diff --git a/packages/contracts/generated/api/console/spec/types.gen.ts b/packages/contracts/generated/api/console/spec/types.gen.ts index eaad80aa9a6..e7b9c0ea9b2 100644 --- a/packages/contracts/generated/api/console/spec/types.gen.ts +++ b/packages/contracts/generated/api/console/spec/types.gen.ts @@ -4,6 +4,8 @@ export type ClientOptions = { baseUrl: `${string}://${string}/console/api` | (string & {}) } +export type SchemaDefinitionsResponse = unknown + export type GetSpecSchemaDefinitionsData = { body?: never path?: never @@ -12,9 +14,7 @@ export type GetSpecSchemaDefinitionsData = { } export type GetSpecSchemaDefinitionsResponses = { - 200: { - [key: string]: unknown - } + 200: SchemaDefinitionsResponse } export type GetSpecSchemaDefinitionsResponse diff --git a/packages/contracts/generated/api/console/spec/zod.gen.ts b/packages/contracts/generated/api/console/spec/zod.gen.ts index fa057bc2692..8fc487d5ac4 100644 --- a/packages/contracts/generated/api/console/spec/zod.gen.ts +++ b/packages/contracts/generated/api/console/spec/zod.gen.ts @@ -2,7 +2,12 @@ import * as z from 'zod' +/** + * SchemaDefinitionsResponse + */ +export const zSchemaDefinitionsResponse = z.unknown() + /** * Success */ -export const zGetSpecSchemaDefinitionsResponse = z.record(z.string(), z.unknown()) +export const zGetSpecSchemaDefinitionsResponse = zSchemaDefinitionsResponse diff --git a/packages/contracts/generated/api/console/system-features/zod.gen.ts b/packages/contracts/generated/api/console/system-features/zod.gen.ts index bbd08821186..7464057a391 100644 --- a/packages/contracts/generated/api/console/system-features/zod.gen.ts +++ b/packages/contracts/generated/api/console/system-features/zod.gen.ts @@ -43,8 +43,12 @@ export const zLicenseLimitationModel = z.object({ */ export const zLicenseModel = z.object({ expired_at: z.string().default(''), - status: zLicenseStatus, - workspaces: zLicenseLimitationModel, + status: zLicenseStatus.default('none'), + workspaces: zLicenseLimitationModel.default({ + enabled: false, + limit: 0, + size: 0, + }), }) /** @@ -61,7 +65,7 @@ export const zPluginInstallationScope = z.enum([ * PluginInstallationPermissionModel */ export const zPluginInstallationPermissionModel = z.object({ - plugin_installation_scope: zPluginInstallationScope, + plugin_installation_scope: zPluginInstallationScope.default('all'), restrict_to_marketplace_only: z.boolean().default(false), }) @@ -80,14 +84,20 @@ export const zWebAppAuthModel = z.object({ allow_email_password_login: z.boolean().default(false), allow_sso: z.boolean().default(false), enabled: z.boolean().default(false), - sso_config: zWebAppAuthSsoModel, + sso_config: zWebAppAuthSsoModel.default({ protocol: '' }), }) /** * SystemFeatureModel */ export const zSystemFeatureModel = z.object({ - branding: zBrandingModel, + branding: zBrandingModel.default({ + application_title: '', + enabled: false, + favicon: '', + login_page_logo: '', + workspace_logo: '', + }), enable_change_email: z.boolean().default(true), enable_collaboration_mode: z.boolean().default(true), enable_creators_platform: z.boolean().default(false), @@ -100,13 +110,30 @@ export const zSystemFeatureModel = z.object({ is_allow_create_workspace: z.boolean().default(false), is_allow_register: z.boolean().default(false), is_email_setup: z.boolean().default(false), - license: zLicenseModel, + license: zLicenseModel.default({ + expired_at: '', + status: 'none', + workspaces: { + enabled: false, + limit: 0, + size: 0, + }, + }), max_plugin_package_size: z.int().default(15728640), - plugin_installation_permission: zPluginInstallationPermissionModel, - plugin_manager: zPluginManagerModel, + plugin_installation_permission: zPluginInstallationPermissionModel.default({ + plugin_installation_scope: 'all', + restrict_to_marketplace_only: false, + }), + plugin_manager: zPluginManagerModel.default({ enabled: false }), sso_enforced_for_signin: z.boolean().default(false), sso_enforced_for_signin_protocol: z.string().default(''), - webapp_auth: zWebAppAuthModel, + webapp_auth: zWebAppAuthModel.default({ + allow_email_code_login: false, + allow_email_password_login: false, + allow_sso: false, + enabled: false, + sso_config: { protocol: '' }, + }), }) /** diff --git a/packages/contracts/generated/api/console/tags/types.gen.ts b/packages/contracts/generated/api/console/tags/types.gen.ts index 8ecf1a55e75..33b39716e6e 100644 --- a/packages/contracts/generated/api/console/tags/types.gen.ts +++ b/packages/contracts/generated/api/console/tags/types.gen.ts @@ -27,7 +27,7 @@ export type GetTagsData = { path?: never query?: { keyword?: string - type?: string + type?: '' | 'app' | 'knowledge' | 'snippet' } url: '/tags' } @@ -61,9 +61,7 @@ export type DeleteTagsByTagIdData = { } export type DeleteTagsByTagIdResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteTagsByTagIdResponse = DeleteTagsByTagIdResponses[keyof DeleteTagsByTagIdResponses] diff --git a/packages/contracts/generated/api/console/tags/zod.gen.ts b/packages/contracts/generated/api/console/tags/zod.gen.ts index b0479b09508..0e7d7bd1c60 100644 --- a/packages/contracts/generated/api/console/tags/zod.gen.ts +++ b/packages/contracts/generated/api/console/tags/zod.gen.ts @@ -36,7 +36,7 @@ export const zTagBasePayload = z.object({ export const zGetTagsQuery = z.object({ keyword: z.string().optional(), - type: z.string().optional(), + type: z.enum(['', 'app', 'knowledge', 'snippet']).optional().default(''), }) /** @@ -58,7 +58,7 @@ export const zDeleteTagsByTagIdPath = z.object({ /** * Tag deleted successfully */ -export const zDeleteTagsByTagIdResponse = z.record(z.string(), z.never()) +export const zDeleteTagsByTagIdResponse = z.void() export const zPatchTagsByTagIdBody = zTagUpdateRequestPayload diff --git a/packages/contracts/generated/api/console/test/orpc.gen.ts b/packages/contracts/generated/api/console/test/orpc.gen.ts index 27b46e33812..1bdf526b70c 100644 --- a/packages/contracts/generated/api/console/test/orpc.gen.ts +++ b/packages/contracts/generated/api/console/test/orpc.gen.ts @@ -7,16 +7,10 @@ import { zPostTestRetrievalBody, zPostTestRetrievalResponse } from './zod.gen' /** * Bedrock retrieval test (internal use only) - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post = oc .route({ - deprecated: true, - description: - 'Bedrock retrieval test (internal use only)\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Bedrock retrieval test (internal use only)', inputStructure: 'detailed', method: 'POST', operationId: 'postTestRetrieval', diff --git a/packages/contracts/generated/api/console/test/types.gen.ts b/packages/contracts/generated/api/console/test/types.gen.ts index 3e04b732eee..421460c015d 100644 --- a/packages/contracts/generated/api/console/test/types.gen.ts +++ b/packages/contracts/generated/api/console/test/types.gen.ts @@ -10,6 +10,14 @@ export type BedrockRetrievalPayload = { retrieval_setting: BedrockRetrievalSetting } +export type ExternalRetrievalTestResponse + = | { + [key: string]: unknown + } + | Array<{ + [key: string]: unknown + }> + export type BedrockRetrievalSetting = { score_threshold?: number top_k?: number | null @@ -23,9 +31,7 @@ export type PostTestRetrievalData = { } export type PostTestRetrievalResponses = { - 200: { - [key: string]: unknown - } + 200: ExternalRetrievalTestResponse } export type PostTestRetrievalResponse = PostTestRetrievalResponses[keyof PostTestRetrievalResponses] diff --git a/packages/contracts/generated/api/console/test/zod.gen.ts b/packages/contracts/generated/api/console/test/zod.gen.ts index 9421c6c03f0..35ec1f4b034 100644 --- a/packages/contracts/generated/api/console/test/zod.gen.ts +++ b/packages/contracts/generated/api/console/test/zod.gen.ts @@ -2,6 +2,14 @@ import * as z from 'zod' +/** + * ExternalRetrievalTestResponse + */ +export const zExternalRetrievalTestResponse = z.union([ + z.record(z.string(), z.unknown()), + z.array(z.record(z.string(), z.unknown())), +]) + /** * BedrockRetrievalSetting * @@ -26,4 +34,4 @@ export const zPostTestRetrievalBody = zBedrockRetrievalPayload /** * Bedrock retrieval test completed */ -export const zPostTestRetrievalResponse = z.record(z.string(), z.unknown()) +export const zPostTestRetrievalResponse = zExternalRetrievalTestResponse diff --git a/packages/contracts/generated/api/console/trial-apps/orpc.gen.ts b/packages/contracts/generated/api/console/trial-apps/orpc.gen.ts index 4a10ee7cb85..ebc2624fa19 100644 --- a/packages/contracts/generated/api/console/trial-apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/trial-apps/orpc.gen.ts @@ -5,6 +5,7 @@ import * as z from 'zod' import { zGetTrialAppsByAppIdDatasetsPath, + zGetTrialAppsByAppIdDatasetsQuery, zGetTrialAppsByAppIdDatasetsResponse, zGetTrialAppsByAppIdMessagesByMessageIdSuggestedQuestionsPath, zGetTrialAppsByAppIdMessagesByMessageIdSuggestedQuestionsResponse, @@ -34,16 +35,8 @@ import { zPostTrialAppsByAppIdWorkflowsTasksByTaskIdStopResponse, } from './zod.gen' -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postTrialAppsByAppIdAudioToText', @@ -57,16 +50,8 @@ export const audioToText = { post, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post2 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postTrialAppsByAppIdChatMessages', @@ -85,16 +70,8 @@ export const chatMessages = { post: post2, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post3 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postTrialAppsByAppIdCompletionMessages', @@ -113,39 +90,28 @@ export const completionMessages = { post: post3, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const get = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getTrialAppsByAppIdDatasets', path: '/trial-apps/{app_id}/datasets', tags: ['console'], }) - .input(z.object({ params: zGetTrialAppsByAppIdDatasetsPath })) + .input( + z.object({ + params: zGetTrialAppsByAppIdDatasetsPath, + query: zGetTrialAppsByAppIdDatasetsQuery.optional(), + }), + ) .output(zGetTrialAppsByAppIdDatasetsResponse) export const datasets = { get, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const get2 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getTrialAppsByAppIdMessagesByMessageIdSuggestedQuestions', @@ -169,16 +135,9 @@ export const messages = { /** * Retrieve app parameters - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get3 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getTrialAppsByAppIdParameters', @@ -197,16 +156,11 @@ export const parameters = { * Retrieve app site info * * Returns the site configuration for the application including theme, icons, and text. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get4 = oc .route({ - deprecated: true, description: - 'Returns the site configuration for the application including theme, icons, and text.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + 'Returns the site configuration for the application including theme, icons, and text.', inputStructure: 'detailed', method: 'GET', operationId: 'getTrialAppsByAppIdSite', @@ -221,16 +175,8 @@ export const site = { get: get4, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post4 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postTrialAppsByAppIdTextToAudio', @@ -251,16 +197,9 @@ export const textToAudio = { /** * Run workflow - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post5 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postTrialAppsByAppIdWorkflowsRun', @@ -282,16 +221,9 @@ export const run = { /** * Stop workflow task - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post6 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postTrialAppsByAppIdWorkflowsTasksByTaskIdStop', @@ -316,16 +248,9 @@ export const tasks = { /** * Get workflow detail - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get5 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getTrialAppsByAppIdWorkflows', @@ -344,16 +269,9 @@ export const workflows = { /** * Get app detail - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get6 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getTrialAppsByAppId', diff --git a/packages/contracts/generated/api/console/trial-apps/types.gen.ts b/packages/contracts/generated/api/console/trial-apps/types.gen.ts index 2965aafebf6..5021da0afdf 100644 --- a/packages/contracts/generated/api/console/trial-apps/types.gen.ts +++ b/packages/contracts/generated/api/console/trial-apps/types.gen.ts @@ -4,6 +4,36 @@ export type ClientOptions = { baseUrl: `${string}://${string}/console/api` | (string & {}) } +export type TrialAppDetailWithSite = { + access_mode?: string + api_base_url?: string + created_at?: number + created_by?: string + deleted_tools?: Array + description?: string + enable_api?: boolean + enable_site?: boolean + icon?: string + icon_background?: string + icon_type?: string + icon_url?: string + id?: string + max_active_requests?: number + mode?: string + model_config?: TrialAppModelConfig + name?: string + site?: TrialSite + tags?: Array + updated_at?: number + updated_by?: string + use_icon_as_answer_icon?: boolean + workflow?: TrialWorkflowPartial +} + +export type AudioTranscriptResponse = { + text: string +} + export type ChatRequest = { conversation_id?: string | null files?: Array | null @@ -15,6 +45,8 @@ export type ChatRequest = { retriever_from?: string } +export type GeneratedAppResponse = JsonValue + export type CompletionRequest = { files?: Array | null inputs: { @@ -25,6 +57,50 @@ export type CompletionRequest = { retriever_from?: string } +export type TrialDatasetList = { + data?: Array + has_more?: boolean + limit?: number + page?: number + total?: number +} + +export type SuggestedQuestionsResponse = { + data: Array +} + +export type Parameters = { + annotation_reply: JsonObject + file_upload: JsonObject + more_like_this: JsonObject + opening_statement?: string | null + retriever_resource: JsonObject + sensitive_word_avoidance: JsonObject + speech_to_text: JsonObject + suggested_questions: Array + suggested_questions_after_answer: JsonObject + system_parameters: SystemParameters + text_to_speech: JsonObject + user_input_form: Array +} + +export type Site = { + chat_color_theme?: string | null + chat_color_theme_inverted: boolean + copyright?: string | null + custom_disclaimer?: string | null + default_language: string + description?: string | null + icon?: string | null + icon_background?: string | null + icon_type?: string | null + readonly icon_url: string | null + privacy_policy?: string | null + show_workflow_steps: boolean + title: string + use_icon_as_answer_icon: boolean +} + export type TextToSpeechRequest = { message_id?: string | null streaming?: boolean | null @@ -32,6 +108,32 @@ export type TextToSpeechRequest = { voice?: string | null } +export type AudioBinaryResponse = Blob | File + +export type TrialWorkflow = { + conversation_variables?: Array + created_at?: number + created_by?: TrialSimpleAccount + environment_variables?: Array<{ + [key: string]: unknown + }> + features?: { + [key: string]: unknown + } + graph?: { + [key: string]: unknown + } + hash?: string + id?: string + marked_comment?: string + marked_name?: string + rag_pipeline_variables?: Array + tool_published?: boolean + updated_at?: number + updated_by?: TrialSimpleAccount + version?: string +} + export type WorkflowRunRequest = { files?: Array | null inputs: { @@ -39,6 +141,215 @@ export type WorkflowRunRequest = { } } +export type SimpleResultResponse = { + result: string +} + +export type TrialDeletedTool = { + provider_id?: string + tool_name?: string + type?: string +} + +export type TrialAppModelConfig = { + agent_mode?: { + [key: string]: unknown + } + annotation_reply?: { + [key: string]: unknown + } + chat_prompt_config?: { + [key: string]: unknown + } + completion_prompt_config?: { + [key: string]: unknown + } + created_at?: number + created_by?: string + dataset_configs?: { + [key: string]: unknown + } + dataset_query_variable?: string + external_data_tools?: Array<{ + [key: string]: unknown + }> + file_upload?: { + [key: string]: unknown + } + model?: { + [key: string]: unknown + } + more_like_this?: { + [key: string]: unknown + } + opening_statement?: string + pre_prompt?: string + prompt_type?: string + retriever_resource?: { + [key: string]: unknown + } + sensitive_word_avoidance?: { + [key: string]: unknown + } + speech_to_text?: { + [key: string]: unknown + } + suggested_questions?: Array + suggested_questions_after_answer?: { + [key: string]: unknown + } + text_to_speech?: { + [key: string]: unknown + } + updated_at?: number + updated_by?: string + user_input_form?: Array<{ + [key: string]: unknown + }> +} + +export type TrialSite = { + access_token?: string + app_base_url?: string + chat_color_theme?: string + chat_color_theme_inverted?: boolean + code?: string + copyright?: string + created_at?: number + created_by?: string + custom_disclaimer?: string + customize_domain?: string + customize_token_strategy?: string + default_language?: string + description?: string + icon?: string + icon_background?: string + icon_type?: string + icon_url?: string + privacy_policy?: string + prompt_public?: boolean + show_workflow_steps?: boolean + title?: string + updated_at?: number + updated_by?: string + use_icon_as_answer_icon?: boolean +} + +export type TrialTag = { + id?: string + name?: string + type?: string +} + +export type TrialWorkflowPartial = { + created_at?: number + created_by?: string + id?: string + updated_at?: number + updated_by?: string +} + +export type JsonValue + = | string + | number + | number + | boolean + | { + [key: string]: unknown + } + | Array + | null + +export type TrialDataset = { + created_at?: number + created_by?: string + data_source_type?: string + description?: string + id?: string + indexing_technique?: string + name?: string + permission?: string +} + +export type JsonObject = { + [key: string]: unknown +} + +export type SystemParameters = { + audio_file_size_limit: number + file_size_limit: number + image_file_size_limit: number + video_file_size_limit: number + workflow_file_upload_limit: number +} + +export type TrialConversationVariable = { + description?: string + id?: string + name?: string + value?: + | string + | number + | number + | boolean + | { + [key: string]: unknown + } + | Array + | null + value_type?: string +} + +export type TrialSimpleAccount = { + email?: string + id?: string + name?: string +} + +export type TrialPipelineVariable = { + allow_file_extension?: Array + allow_file_upload_methods?: Array + allowed_file_types?: Array + belong_to_node_id?: string + default_value?: + | string + | number + | number + | boolean + | { + [key: string]: unknown + } + | Array + | null + label?: string + max_length?: number + options?: Array + placeholder?: string + required?: boolean + tooltips?: string + type?: string + unit?: string + variable?: string +} + +export type GeneratedAppResponseWritable = JsonValue + +export type SiteWritable = { + chat_color_theme?: string | null + chat_color_theme_inverted: boolean + copyright?: string | null + custom_disclaimer?: string | null + default_language: string + description?: string | null + icon?: string | null + icon_background?: string | null + icon_type?: string | null + privacy_policy?: string | null + show_workflow_steps: boolean + title: string + use_icon_as_answer_icon: boolean +} + export type GetTrialAppsByAppIdData = { body?: never path: { @@ -49,9 +360,7 @@ export type GetTrialAppsByAppIdData = { } export type GetTrialAppsByAppIdResponses = { - 200: { - [key: string]: unknown - } + 200: TrialAppDetailWithSite } export type GetTrialAppsByAppIdResponse @@ -67,9 +376,7 @@ export type PostTrialAppsByAppIdAudioToTextData = { } export type PostTrialAppsByAppIdAudioToTextResponses = { - 200: { - [key: string]: unknown - } + 200: AudioTranscriptResponse } export type PostTrialAppsByAppIdAudioToTextResponse @@ -85,9 +392,7 @@ export type PostTrialAppsByAppIdChatMessagesData = { } export type PostTrialAppsByAppIdChatMessagesResponses = { - 200: { - [key: string]: unknown - } + 200: GeneratedAppResponse } export type PostTrialAppsByAppIdChatMessagesResponse @@ -103,9 +408,7 @@ export type PostTrialAppsByAppIdCompletionMessagesData = { } export type PostTrialAppsByAppIdCompletionMessagesResponses = { - 200: { - [key: string]: unknown - } + 200: GeneratedAppResponse } export type PostTrialAppsByAppIdCompletionMessagesResponse @@ -116,14 +419,16 @@ export type GetTrialAppsByAppIdDatasetsData = { path: { app_id: string } - query?: never + query?: { + ids?: Array + limit?: number + page?: number + } url: '/trial-apps/{app_id}/datasets' } export type GetTrialAppsByAppIdDatasetsResponses = { - 200: { - [key: string]: unknown - } + 200: TrialDatasetList } export type GetTrialAppsByAppIdDatasetsResponse @@ -140,9 +445,7 @@ export type GetTrialAppsByAppIdMessagesByMessageIdSuggestedQuestionsData = { } export type GetTrialAppsByAppIdMessagesByMessageIdSuggestedQuestionsResponses = { - 200: { - [key: string]: unknown - } + 200: SuggestedQuestionsResponse } export type GetTrialAppsByAppIdMessagesByMessageIdSuggestedQuestionsResponse @@ -158,9 +461,7 @@ export type GetTrialAppsByAppIdParametersData = { } export type GetTrialAppsByAppIdParametersResponses = { - 200: { - [key: string]: unknown - } + 200: Parameters } export type GetTrialAppsByAppIdParametersResponse @@ -176,9 +477,7 @@ export type GetTrialAppsByAppIdSiteData = { } export type GetTrialAppsByAppIdSiteResponses = { - 200: { - [key: string]: unknown - } + 200: Site } export type GetTrialAppsByAppIdSiteResponse @@ -194,9 +493,7 @@ export type PostTrialAppsByAppIdTextToAudioData = { } export type PostTrialAppsByAppIdTextToAudioResponses = { - 200: { - [key: string]: unknown - } + 200: AudioBinaryResponse } export type PostTrialAppsByAppIdTextToAudioResponse @@ -212,9 +509,7 @@ export type GetTrialAppsByAppIdWorkflowsData = { } export type GetTrialAppsByAppIdWorkflowsResponses = { - 200: { - [key: string]: unknown - } + 200: TrialWorkflow } export type GetTrialAppsByAppIdWorkflowsResponse @@ -230,9 +525,7 @@ export type PostTrialAppsByAppIdWorkflowsRunData = { } export type PostTrialAppsByAppIdWorkflowsRunResponses = { - 200: { - [key: string]: unknown - } + 200: GeneratedAppResponse } export type PostTrialAppsByAppIdWorkflowsRunResponse @@ -249,9 +542,7 @@ export type PostTrialAppsByAppIdWorkflowsTasksByTaskIdStopData = { } export type PostTrialAppsByAppIdWorkflowsTasksByTaskIdStopResponses = { - 200: { - [key: string]: unknown - } + 200: SimpleResultResponse } export type PostTrialAppsByAppIdWorkflowsTasksByTaskIdStopResponse diff --git a/packages/contracts/generated/api/console/trial-apps/zod.gen.ts b/packages/contracts/generated/api/console/trial-apps/zod.gen.ts index f7a52425a2c..8f284cda862 100644 --- a/packages/contracts/generated/api/console/trial-apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/trial-apps/zod.gen.ts @@ -2,6 +2,13 @@ import * as z from 'zod' +/** + * AudioTranscriptResponse + */ +export const zAudioTranscriptResponse = z.object({ + text: z.string(), +}) + /** * ChatRequest */ @@ -25,6 +32,33 @@ export const zCompletionRequest = z.object({ retriever_from: z.string().optional().default('explore_app'), }) +/** + * SuggestedQuestionsResponse + */ +export const zSuggestedQuestionsResponse = z.object({ + data: z.array(z.string()), +}) + +/** + * Site + */ +export const zSite = z.object({ + chat_color_theme: z.string().nullish(), + chat_color_theme_inverted: z.boolean(), + copyright: z.string().nullish(), + custom_disclaimer: z.string().nullish(), + default_language: z.string(), + description: z.string().nullish(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.string().nullish(), + icon_url: z.string().nullable(), + privacy_policy: z.string().nullish(), + show_workflow_steps: z.boolean(), + title: z.string(), + use_icon_as_answer_icon: z.boolean(), +}) + /** * TextToSpeechRequest */ @@ -35,6 +69,11 @@ export const zTextToSpeechRequest = z.object({ voice: z.string().nullish(), }) +/** + * AudioBinaryResponse + */ +export const zAudioBinaryResponse = z.custom() + /** * WorkflowRunRequest */ @@ -43,6 +82,358 @@ export const zWorkflowRunRequest = z.object({ inputs: z.record(z.string(), z.unknown()), }) +/** + * SimpleResultResponse + */ +export const zSimpleResultResponse = z.object({ + result: z.string(), +}) + +export const zTrialDeletedTool = z.object({ + provider_id: z.string().optional(), + tool_name: z.string().optional(), + type: z.string().optional(), +}) + +export const zTrialAppModelConfig = z.object({ + agent_mode: z.record(z.string(), z.unknown()).optional(), + annotation_reply: z.record(z.string(), z.unknown()).optional(), + chat_prompt_config: z.record(z.string(), z.unknown()).optional(), + completion_prompt_config: z.record(z.string(), z.unknown()).optional(), + created_at: z.coerce + .bigint() + .min(BigInt('-9223372036854775808'), { + error: 'Invalid value: Expected int64 to be >= -9223372036854775808', + }) + .max(BigInt('9223372036854775807'), { + error: 'Invalid value: Expected int64 to be <= 9223372036854775807', + }) + .optional(), + created_by: z.string().optional(), + dataset_configs: z.record(z.string(), z.unknown()).optional(), + dataset_query_variable: z.string().optional(), + external_data_tools: z.array(z.record(z.string(), z.unknown())).optional(), + file_upload: z.record(z.string(), z.unknown()).optional(), + model: z.record(z.string(), z.unknown()).optional(), + more_like_this: z.record(z.string(), z.unknown()).optional(), + opening_statement: z.string().optional(), + pre_prompt: z.string().optional(), + prompt_type: z.string().optional(), + retriever_resource: z.record(z.string(), z.unknown()).optional(), + sensitive_word_avoidance: z.record(z.string(), z.unknown()).optional(), + speech_to_text: z.record(z.string(), z.unknown()).optional(), + suggested_questions: z.array(z.string()).optional(), + suggested_questions_after_answer: z.record(z.string(), z.unknown()).optional(), + text_to_speech: z.record(z.string(), z.unknown()).optional(), + updated_at: z.coerce + .bigint() + .min(BigInt('-9223372036854775808'), { + error: 'Invalid value: Expected int64 to be >= -9223372036854775808', + }) + .max(BigInt('9223372036854775807'), { + error: 'Invalid value: Expected int64 to be <= 9223372036854775807', + }) + .optional(), + updated_by: z.string().optional(), + user_input_form: z.array(z.record(z.string(), z.unknown())).optional(), +}) + +export const zTrialSite = z.object({ + access_token: z.string().optional(), + app_base_url: z.string().optional(), + chat_color_theme: z.string().optional(), + chat_color_theme_inverted: z.boolean().optional(), + code: z.string().optional(), + copyright: z.string().optional(), + created_at: z.coerce + .bigint() + .min(BigInt('-9223372036854775808'), { + error: 'Invalid value: Expected int64 to be >= -9223372036854775808', + }) + .max(BigInt('9223372036854775807'), { + error: 'Invalid value: Expected int64 to be <= 9223372036854775807', + }) + .optional(), + created_by: z.string().optional(), + custom_disclaimer: z.string().optional(), + customize_domain: z.string().optional(), + customize_token_strategy: z.string().optional(), + default_language: z.string().optional(), + description: z.string().optional(), + icon: z.string().optional(), + icon_background: z.string().optional(), + icon_type: z.string().optional(), + icon_url: z.string().optional(), + privacy_policy: z.string().optional(), + prompt_public: z.boolean().optional(), + show_workflow_steps: z.boolean().optional(), + title: z.string().optional(), + updated_at: z.coerce + .bigint() + .min(BigInt('-9223372036854775808'), { + error: 'Invalid value: Expected int64 to be >= -9223372036854775808', + }) + .max(BigInt('9223372036854775807'), { + error: 'Invalid value: Expected int64 to be <= 9223372036854775807', + }) + .optional(), + updated_by: z.string().optional(), + use_icon_as_answer_icon: z.boolean().optional(), +}) + +export const zTrialTag = z.object({ + id: z.string().optional(), + name: z.string().optional(), + type: z.string().optional(), +}) + +export const zTrialWorkflowPartial = z.object({ + created_at: z.coerce + .bigint() + .min(BigInt('-9223372036854775808'), { + error: 'Invalid value: Expected int64 to be >= -9223372036854775808', + }) + .max(BigInt('9223372036854775807'), { + error: 'Invalid value: Expected int64 to be <= 9223372036854775807', + }) + .optional(), + created_by: z.string().optional(), + id: z.string().optional(), + updated_at: z.coerce + .bigint() + .min(BigInt('-9223372036854775808'), { + error: 'Invalid value: Expected int64 to be >= -9223372036854775808', + }) + .max(BigInt('9223372036854775807'), { + error: 'Invalid value: Expected int64 to be <= 9223372036854775807', + }) + .optional(), + updated_by: z.string().optional(), +}) + +export const zTrialAppDetailWithSite = z.object({ + access_mode: z.string().optional(), + api_base_url: z.string().optional(), + created_at: z.coerce + .bigint() + .min(BigInt('-9223372036854775808'), { + error: 'Invalid value: Expected int64 to be >= -9223372036854775808', + }) + .max(BigInt('9223372036854775807'), { + error: 'Invalid value: Expected int64 to be <= 9223372036854775807', + }) + .optional(), + created_by: z.string().optional(), + deleted_tools: z.array(zTrialDeletedTool).optional(), + description: z.string().optional(), + enable_api: z.boolean().optional(), + enable_site: z.boolean().optional(), + icon: z.string().optional(), + icon_background: z.string().optional(), + icon_type: z.string().optional(), + icon_url: z.string().optional(), + id: z.string().optional(), + max_active_requests: z.int().optional(), + mode: z.string().optional(), + model_config: zTrialAppModelConfig.optional(), + name: z.string().optional(), + site: zTrialSite.optional(), + tags: z.array(zTrialTag).optional(), + updated_at: z.coerce + .bigint() + .min(BigInt('-9223372036854775808'), { + error: 'Invalid value: Expected int64 to be >= -9223372036854775808', + }) + .max(BigInt('9223372036854775807'), { + error: 'Invalid value: Expected int64 to be <= 9223372036854775807', + }) + .optional(), + updated_by: z.string().optional(), + use_icon_as_answer_icon: z.boolean().optional(), + workflow: zTrialWorkflowPartial.optional(), +}) + +export const zJsonValue = z + .union([ + z.string(), + z.int(), + z.number(), + z.boolean(), + z.record(z.string(), z.unknown()), + z.array(z.unknown()), + ]) + .nullable() + +/** + * GeneratedAppResponse + */ +export const zGeneratedAppResponse = zJsonValue + +export const zTrialDataset = z.object({ + created_at: z.coerce + .bigint() + .min(BigInt('-9223372036854775808'), { + error: 'Invalid value: Expected int64 to be >= -9223372036854775808', + }) + .max(BigInt('9223372036854775807'), { + error: 'Invalid value: Expected int64 to be <= 9223372036854775807', + }) + .optional(), + created_by: z.string().optional(), + data_source_type: z.string().optional(), + description: z.string().optional(), + id: z.string().optional(), + indexing_technique: z.string().optional(), + name: z.string().optional(), + permission: z.string().optional(), +}) + +export const zTrialDatasetList = z.object({ + data: z.array(zTrialDataset).optional(), + has_more: z.boolean().optional(), + limit: z.int().optional(), + page: z.int().optional(), + total: z.int().optional(), +}) + +export const zJsonObject = z.record(z.string(), z.unknown()) + +/** + * SystemParameters + */ +export const zSystemParameters = z.object({ + audio_file_size_limit: z.int(), + file_size_limit: z.int(), + image_file_size_limit: z.int(), + video_file_size_limit: z.int(), + workflow_file_upload_limit: z.int(), +}) + +/** + * Parameters + */ +export const zParameters = z.object({ + annotation_reply: zJsonObject, + file_upload: zJsonObject, + more_like_this: zJsonObject, + opening_statement: z.string().nullish(), + retriever_resource: zJsonObject, + sensitive_word_avoidance: zJsonObject, + speech_to_text: zJsonObject, + suggested_questions: z.array(z.string()), + suggested_questions_after_answer: zJsonObject, + system_parameters: zSystemParameters, + text_to_speech: zJsonObject, + user_input_form: z.array(zJsonObject), +}) + +export const zTrialConversationVariable = z.object({ + description: z.string().optional(), + id: z.string().optional(), + name: z.string().optional(), + value: z + .union([ + z.string(), + z.int(), + z.number(), + z.boolean(), + z.record(z.string(), z.unknown()), + z.array(z.unknown()), + ]) + .nullish(), + value_type: z.string().optional(), +}) + +export const zTrialSimpleAccount = z.object({ + email: z.string().optional(), + id: z.string().optional(), + name: z.string().optional(), +}) + +export const zTrialPipelineVariable = z.object({ + allow_file_extension: z.array(z.string()).optional(), + allow_file_upload_methods: z.array(z.string()).optional(), + allowed_file_types: z.array(z.string()).optional(), + belong_to_node_id: z.string().optional(), + default_value: z + .union([ + z.string(), + z.int(), + z.number(), + z.boolean(), + z.record(z.string(), z.unknown()), + z.array(z.unknown()), + ]) + .nullish(), + label: z.string().optional(), + max_length: z.int().optional(), + options: z.array(z.string()).optional(), + placeholder: z.string().optional(), + required: z.boolean().optional(), + tooltips: z.string().optional(), + type: z.string().optional(), + unit: z.string().optional(), + variable: z.string().optional(), +}) + +export const zTrialWorkflow = z.object({ + conversation_variables: z.array(zTrialConversationVariable).optional(), + created_at: z.coerce + .bigint() + .min(BigInt('-9223372036854775808'), { + error: 'Invalid value: Expected int64 to be >= -9223372036854775808', + }) + .max(BigInt('9223372036854775807'), { + error: 'Invalid value: Expected int64 to be <= 9223372036854775807', + }) + .optional(), + created_by: zTrialSimpleAccount.optional(), + environment_variables: z.array(z.record(z.string(), z.unknown())).optional(), + features: z.record(z.string(), z.unknown()).optional(), + graph: z.record(z.string(), z.unknown()).optional(), + hash: z.string().optional(), + id: z.string().optional(), + marked_comment: z.string().optional(), + marked_name: z.string().optional(), + rag_pipeline_variables: z.array(zTrialPipelineVariable).optional(), + tool_published: z.boolean().optional(), + updated_at: z.coerce + .bigint() + .min(BigInt('-9223372036854775808'), { + error: 'Invalid value: Expected int64 to be >= -9223372036854775808', + }) + .max(BigInt('9223372036854775807'), { + error: 'Invalid value: Expected int64 to be <= 9223372036854775807', + }) + .optional(), + updated_by: zTrialSimpleAccount.optional(), + version: z.string().optional(), +}) + +/** + * GeneratedAppResponse + */ +export const zGeneratedAppResponseWritable = zJsonValue + +/** + * Site + */ +export const zSiteWritable = z.object({ + chat_color_theme: z.string().nullish(), + chat_color_theme_inverted: z.boolean(), + copyright: z.string().nullish(), + custom_disclaimer: z.string().nullish(), + default_language: z.string(), + description: z.string().nullish(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.string().nullish(), + privacy_policy: z.string().nullish(), + show_workflow_steps: z.boolean(), + title: z.string(), + use_icon_as_answer_icon: z.boolean(), +}) + export const zGetTrialAppsByAppIdPath = z.object({ app_id: z.string(), }) @@ -50,7 +441,7 @@ export const zGetTrialAppsByAppIdPath = z.object({ /** * Success */ -export const zGetTrialAppsByAppIdResponse = z.record(z.string(), z.unknown()) +export const zGetTrialAppsByAppIdResponse = zTrialAppDetailWithSite export const zPostTrialAppsByAppIdAudioToTextPath = z.object({ app_id: z.string(), @@ -59,7 +450,7 @@ export const zPostTrialAppsByAppIdAudioToTextPath = z.object({ /** * Success */ -export const zPostTrialAppsByAppIdAudioToTextResponse = z.record(z.string(), z.unknown()) +export const zPostTrialAppsByAppIdAudioToTextResponse = zAudioTranscriptResponse export const zPostTrialAppsByAppIdChatMessagesBody = zChatRequest @@ -70,7 +461,7 @@ export const zPostTrialAppsByAppIdChatMessagesPath = z.object({ /** * Success */ -export const zPostTrialAppsByAppIdChatMessagesResponse = z.record(z.string(), z.unknown()) +export const zPostTrialAppsByAppIdChatMessagesResponse = zGeneratedAppResponse export const zPostTrialAppsByAppIdCompletionMessagesBody = zCompletionRequest @@ -81,16 +472,22 @@ export const zPostTrialAppsByAppIdCompletionMessagesPath = z.object({ /** * Success */ -export const zPostTrialAppsByAppIdCompletionMessagesResponse = z.record(z.string(), z.unknown()) +export const zPostTrialAppsByAppIdCompletionMessagesResponse = zGeneratedAppResponse export const zGetTrialAppsByAppIdDatasetsPath = z.object({ app_id: z.string(), }) +export const zGetTrialAppsByAppIdDatasetsQuery = z.object({ + ids: z.array(z.string()).optional(), + limit: z.int().gte(1).optional().default(20), + page: z.int().gte(1).optional().default(1), +}) + /** * Success */ -export const zGetTrialAppsByAppIdDatasetsResponse = z.record(z.string(), z.unknown()) +export const zGetTrialAppsByAppIdDatasetsResponse = zTrialDatasetList export const zGetTrialAppsByAppIdMessagesByMessageIdSuggestedQuestionsPath = z.object({ app_id: z.string(), @@ -100,10 +497,8 @@ export const zGetTrialAppsByAppIdMessagesByMessageIdSuggestedQuestionsPath = z.o /** * Success */ -export const zGetTrialAppsByAppIdMessagesByMessageIdSuggestedQuestionsResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetTrialAppsByAppIdMessagesByMessageIdSuggestedQuestionsResponse + = zSuggestedQuestionsResponse export const zGetTrialAppsByAppIdParametersPath = z.object({ app_id: z.string(), @@ -112,7 +507,7 @@ export const zGetTrialAppsByAppIdParametersPath = z.object({ /** * Success */ -export const zGetTrialAppsByAppIdParametersResponse = z.record(z.string(), z.unknown()) +export const zGetTrialAppsByAppIdParametersResponse = zParameters export const zGetTrialAppsByAppIdSitePath = z.object({ app_id: z.string(), @@ -121,7 +516,7 @@ export const zGetTrialAppsByAppIdSitePath = z.object({ /** * Success */ -export const zGetTrialAppsByAppIdSiteResponse = z.record(z.string(), z.unknown()) +export const zGetTrialAppsByAppIdSiteResponse = zSite export const zPostTrialAppsByAppIdTextToAudioBody = zTextToSpeechRequest @@ -132,7 +527,7 @@ export const zPostTrialAppsByAppIdTextToAudioPath = z.object({ /** * Success */ -export const zPostTrialAppsByAppIdTextToAudioResponse = z.record(z.string(), z.unknown()) +export const zPostTrialAppsByAppIdTextToAudioResponse = zAudioBinaryResponse export const zGetTrialAppsByAppIdWorkflowsPath = z.object({ app_id: z.string(), @@ -141,7 +536,7 @@ export const zGetTrialAppsByAppIdWorkflowsPath = z.object({ /** * Success */ -export const zGetTrialAppsByAppIdWorkflowsResponse = z.record(z.string(), z.unknown()) +export const zGetTrialAppsByAppIdWorkflowsResponse = zTrialWorkflow export const zPostTrialAppsByAppIdWorkflowsRunBody = zWorkflowRunRequest @@ -152,7 +547,7 @@ export const zPostTrialAppsByAppIdWorkflowsRunPath = z.object({ /** * Success */ -export const zPostTrialAppsByAppIdWorkflowsRunResponse = z.record(z.string(), z.unknown()) +export const zPostTrialAppsByAppIdWorkflowsRunResponse = zGeneratedAppResponse export const zPostTrialAppsByAppIdWorkflowsTasksByTaskIdStopPath = z.object({ app_id: z.string(), @@ -162,7 +557,4 @@ export const zPostTrialAppsByAppIdWorkflowsTasksByTaskIdStopPath = z.object({ /** * Success */ -export const zPostTrialAppsByAppIdWorkflowsTasksByTaskIdStopResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostTrialAppsByAppIdWorkflowsTasksByTaskIdStopResponse = zSimpleResultResponse diff --git a/packages/contracts/generated/api/console/website/orpc.gen.ts b/packages/contracts/generated/api/console/website/orpc.gen.ts index 5ed1fcd8abf..698f6569678 100644 --- a/packages/contracts/generated/api/console/website/orpc.gen.ts +++ b/packages/contracts/generated/api/console/website/orpc.gen.ts @@ -13,16 +13,10 @@ import { /** * Get website crawl status - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get = oc .route({ - deprecated: true, - description: - 'Get website crawl status\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get website crawl status', inputStructure: 'detailed', method: 'GET', operationId: 'getWebsiteCrawlStatusByJobId', @@ -47,16 +41,10 @@ export const status = { /** * Crawl website content - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post = oc .route({ - deprecated: true, - description: - 'Crawl website content\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Crawl website content', inputStructure: 'detailed', method: 'POST', operationId: 'postWebsiteCrawl', diff --git a/packages/contracts/generated/api/console/website/types.gen.ts b/packages/contracts/generated/api/console/website/types.gen.ts index c8ea0de7b65..79640ae6f69 100644 --- a/packages/contracts/generated/api/console/website/types.gen.ts +++ b/packages/contracts/generated/api/console/website/types.gen.ts @@ -12,6 +12,10 @@ export type WebsiteCrawlPayload = { url: string } +export type WebsiteCrawlResponse = { + [key: string]: unknown +} + export type PostWebsiteCrawlData = { body: WebsiteCrawlPayload path?: never @@ -20,17 +24,11 @@ export type PostWebsiteCrawlData = { } export type PostWebsiteCrawlErrors = { - 400: { - [key: string]: unknown - } + 400: unknown } -export type PostWebsiteCrawlError = PostWebsiteCrawlErrors[keyof PostWebsiteCrawlErrors] - export type PostWebsiteCrawlResponses = { - 200: { - [key: string]: unknown - } + 200: WebsiteCrawlResponse } export type PostWebsiteCrawlResponse = PostWebsiteCrawlResponses[keyof PostWebsiteCrawlResponses] @@ -47,21 +45,12 @@ export type GetWebsiteCrawlStatusByJobIdData = { } export type GetWebsiteCrawlStatusByJobIdErrors = { - 400: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 400: unknown + 404: unknown } -export type GetWebsiteCrawlStatusByJobIdError - = GetWebsiteCrawlStatusByJobIdErrors[keyof GetWebsiteCrawlStatusByJobIdErrors] - export type GetWebsiteCrawlStatusByJobIdResponses = { - 200: { - [key: string]: unknown - } + 200: WebsiteCrawlResponse } export type GetWebsiteCrawlStatusByJobIdResponse diff --git a/packages/contracts/generated/api/console/website/zod.gen.ts b/packages/contracts/generated/api/console/website/zod.gen.ts index 88f1d4a7c81..fbcdff022ba 100644 --- a/packages/contracts/generated/api/console/website/zod.gen.ts +++ b/packages/contracts/generated/api/console/website/zod.gen.ts @@ -11,12 +11,17 @@ export const zWebsiteCrawlPayload = z.object({ url: z.string(), }) +/** + * WebsiteCrawlResponse + */ +export const zWebsiteCrawlResponse = z.record(z.string(), z.unknown()) + export const zPostWebsiteCrawlBody = zWebsiteCrawlPayload /** * Website crawl initiated successfully */ -export const zPostWebsiteCrawlResponse = z.record(z.string(), z.unknown()) +export const zPostWebsiteCrawlResponse = zWebsiteCrawlResponse export const zGetWebsiteCrawlStatusByJobIdPath = z.object({ job_id: z.string(), @@ -29,4 +34,4 @@ export const zGetWebsiteCrawlStatusByJobIdQuery = z.object({ /** * Crawl status retrieved successfully */ -export const zGetWebsiteCrawlStatusByJobIdResponse = z.record(z.string(), z.unknown()) +export const zGetWebsiteCrawlStatusByJobIdResponse = zWebsiteCrawlResponse diff --git a/packages/contracts/generated/api/console/workflow-generate/orpc.gen.ts b/packages/contracts/generated/api/console/workflow-generate/orpc.gen.ts index c5d3715d9a4..79168ac377f 100644 --- a/packages/contracts/generated/api/console/workflow-generate/orpc.gen.ts +++ b/packages/contracts/generated/api/console/workflow-generate/orpc.gen.ts @@ -7,16 +7,10 @@ import { zPostWorkflowGenerateBody, zPostWorkflowGenerateResponse } from './zod. /** * Generate a Dify workflow graph from natural language - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post = oc .route({ - deprecated: true, - description: - 'Generate a Dify workflow graph from natural language\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Generate a Dify workflow graph from natural language', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkflowGenerate', diff --git a/packages/contracts/generated/api/console/workflow-generate/types.gen.ts b/packages/contracts/generated/api/console/workflow-generate/types.gen.ts index a97ef338061..7f67a572cb5 100644 --- a/packages/contracts/generated/api/console/workflow-generate/types.gen.ts +++ b/packages/contracts/generated/api/console/workflow-generate/types.gen.ts @@ -14,6 +14,8 @@ export type WorkflowGeneratePayload = { model_config: ModelConfig } +export type GeneratorResponse = unknown + export type ModelConfig = { completion_params?: { [key: string]: unknown @@ -33,20 +35,12 @@ export type PostWorkflowGenerateData = { } export type PostWorkflowGenerateErrors = { - 400: { - [key: string]: unknown - } - 402: { - [key: string]: unknown - } + 400: unknown + 402: unknown } -export type PostWorkflowGenerateError = PostWorkflowGenerateErrors[keyof PostWorkflowGenerateErrors] - export type PostWorkflowGenerateResponses = { - 200: { - [key: string]: unknown - } + 200: GeneratorResponse } export type PostWorkflowGenerateResponse diff --git a/packages/contracts/generated/api/console/workflow-generate/zod.gen.ts b/packages/contracts/generated/api/console/workflow-generate/zod.gen.ts index 1bfc7b2bda5..c57f0e31412 100644 --- a/packages/contracts/generated/api/console/workflow-generate/zod.gen.ts +++ b/packages/contracts/generated/api/console/workflow-generate/zod.gen.ts @@ -2,6 +2,11 @@ import * as z from 'zod' +/** + * GeneratorResponse + */ +export const zGeneratorResponse = z.unknown() + /** * LLMMode * @@ -41,4 +46,4 @@ export const zPostWorkflowGenerateBody = zWorkflowGeneratePayload /** * Workflow graph generated successfully */ -export const zPostWorkflowGenerateResponse = z.record(z.string(), z.unknown()) +export const zPostWorkflowGenerateResponse = zGeneratorResponse diff --git a/packages/contracts/generated/api/console/workflow/orpc.gen.ts b/packages/contracts/generated/api/console/workflow/orpc.gen.ts index 1470137236d..66f3e74a48d 100644 --- a/packages/contracts/generated/api/console/workflow/orpc.gen.ts +++ b/packages/contracts/generated/api/console/workflow/orpc.gen.ts @@ -16,16 +16,11 @@ import { * GET /console/api/workflow//events * * Returns Server-Sent Events stream. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get = oc .route({ - deprecated: true, description: - 'GET /console/api/workflow//events\n\nReturns Server-Sent Events stream.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + 'GET /console/api/workflow//events\n\nReturns Server-Sent Events stream.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkflowByWorkflowRunIdEvents', diff --git a/packages/contracts/generated/api/console/workflow/types.gen.ts b/packages/contracts/generated/api/console/workflow/types.gen.ts index cf794515d40..bf0e0464a89 100644 --- a/packages/contracts/generated/api/console/workflow/types.gen.ts +++ b/packages/contracts/generated/api/console/workflow/types.gen.ts @@ -4,6 +4,8 @@ export type ClientOptions = { baseUrl: `${string}://${string}/console/api` | (string & {}) } +export type EventStreamResponse = string + export type WorkflowPauseDetailsResponse = { paused_at?: string | null paused_nodes: Array @@ -18,7 +20,7 @@ export type PausedNodeResponse = { export type HumanInputPauseTypeResponse = { backstage_input_url?: string | null form_id: string - type: string + type: 'human_input' } export type GetWorkflowByWorkflowRunIdEventsData = { @@ -31,9 +33,7 @@ export type GetWorkflowByWorkflowRunIdEventsData = { } export type GetWorkflowByWorkflowRunIdEventsResponses = { - 200: { - [key: string]: unknown - } + 200: EventStreamResponse } export type GetWorkflowByWorkflowRunIdEventsResponse @@ -49,14 +49,9 @@ export type GetWorkflowByWorkflowRunIdPauseDetailsData = { } export type GetWorkflowByWorkflowRunIdPauseDetailsErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type GetWorkflowByWorkflowRunIdPauseDetailsError - = GetWorkflowByWorkflowRunIdPauseDetailsErrors[keyof GetWorkflowByWorkflowRunIdPauseDetailsErrors] - export type GetWorkflowByWorkflowRunIdPauseDetailsResponses = { 200: WorkflowPauseDetailsResponse } diff --git a/packages/contracts/generated/api/console/workflow/zod.gen.ts b/packages/contracts/generated/api/console/workflow/zod.gen.ts index 6a737a683f8..100ad25c224 100644 --- a/packages/contracts/generated/api/console/workflow/zod.gen.ts +++ b/packages/contracts/generated/api/console/workflow/zod.gen.ts @@ -2,13 +2,18 @@ import * as z from 'zod' +/** + * EventStreamResponse + */ +export const zEventStreamResponse = z.string() + /** * HumanInputPauseTypeResponse */ export const zHumanInputPauseTypeResponse = z.object({ backstage_input_url: z.string().nullish(), form_id: z.string(), - type: z.string(), + type: z.literal('human_input'), }) /** @@ -33,9 +38,9 @@ export const zGetWorkflowByWorkflowRunIdEventsPath = z.object({ }) /** - * Success + * SSE event stream */ -export const zGetWorkflowByWorkflowRunIdEventsResponse = z.record(z.string(), z.unknown()) +export const zGetWorkflowByWorkflowRunIdEventsResponse = zEventStreamResponse export const zGetWorkflowByWorkflowRunIdPauseDetailsPath = z.object({ workflow_run_id: z.string(), diff --git a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts index 91a36f10b6c..630e8f6b354 100644 --- a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts @@ -33,6 +33,7 @@ import { zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesPath, zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesResponse, zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportPath, + zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportQuery, zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportResponse, zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdPath, zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdResponse, @@ -66,6 +67,11 @@ import { zGetWorkspacesCurrentPermissionResponse, zGetWorkspacesCurrentPluginAssetQuery, zGetWorkspacesCurrentPluginAssetResponse, + zGetWorkspacesCurrentPluginAutoUpgradeFetchQuery, + zGetWorkspacesCurrentPluginAutoUpgradeFetchResponse, + zGetWorkspacesCurrentPluginByCategoryListPath, + zGetWorkspacesCurrentPluginByCategoryListQuery, + zGetWorkspacesCurrentPluginByCategoryListResponse, zGetWorkspacesCurrentPluginDebuggingKeyResponse, zGetWorkspacesCurrentPluginFetchManifestQuery, zGetWorkspacesCurrentPluginFetchManifestResponse, @@ -78,7 +84,6 @@ import { zGetWorkspacesCurrentPluginParametersDynamicOptionsQuery, zGetWorkspacesCurrentPluginParametersDynamicOptionsResponse, zGetWorkspacesCurrentPluginPermissionFetchResponse, - zGetWorkspacesCurrentPluginPreferencesFetchResponse, zGetWorkspacesCurrentPluginReadmeQuery, zGetWorkspacesCurrentPluginReadmeResponse, zGetWorkspacesCurrentPluginTasksByTaskIdPath, @@ -86,14 +91,19 @@ import { zGetWorkspacesCurrentPluginTasksQuery, zGetWorkspacesCurrentPluginTasksResponse, zGetWorkspacesCurrentToolLabelsResponse, + zGetWorkspacesCurrentToolProviderApiGetQuery, zGetWorkspacesCurrentToolProviderApiGetResponse, + zGetWorkspacesCurrentToolProviderApiRemoteQuery, zGetWorkspacesCurrentToolProviderApiRemoteResponse, + zGetWorkspacesCurrentToolProviderApiToolsQuery, zGetWorkspacesCurrentToolProviderApiToolsResponse, zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfoPath, + zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfoQuery, zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfoResponse, zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialSchemaByCredentialTypePath, zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialSchemaByCredentialTypeResponse, zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialsPath, + zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialsQuery, zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialsResponse, zGetWorkspacesCurrentToolProviderBuiltinByProviderIconPath, zGetWorkspacesCurrentToolProviderBuiltinByProviderIconResponse, @@ -109,8 +119,11 @@ import { zGetWorkspacesCurrentToolProviderMcpToolsByProviderIdResponse, zGetWorkspacesCurrentToolProviderMcpUpdateByProviderIdPath, zGetWorkspacesCurrentToolProviderMcpUpdateByProviderIdResponse, + zGetWorkspacesCurrentToolProvidersQuery, zGetWorkspacesCurrentToolProvidersResponse, + zGetWorkspacesCurrentToolProviderWorkflowGetQuery, zGetWorkspacesCurrentToolProviderWorkflowGetResponse, + zGetWorkspacesCurrentToolProviderWorkflowToolsQuery, zGetWorkspacesCurrentToolProviderWorkflowToolsResponse, zGetWorkspacesCurrentToolsApiResponse, zGetWorkspacesCurrentToolsBuiltinResponse, @@ -205,6 +218,10 @@ import { zPostWorkspacesCurrentModelProvidersByProviderPreferredProviderTypeBody, zPostWorkspacesCurrentModelProvidersByProviderPreferredProviderTypePath, zPostWorkspacesCurrentModelProvidersByProviderPreferredProviderTypeResponse, + zPostWorkspacesCurrentPluginAutoUpgradeChangeBody, + zPostWorkspacesCurrentPluginAutoUpgradeChangeResponse, + zPostWorkspacesCurrentPluginAutoUpgradeExcludeBody, + zPostWorkspacesCurrentPluginAutoUpgradeExcludeResponse, zPostWorkspacesCurrentPluginInstallGithubBody, zPostWorkspacesCurrentPluginInstallGithubResponse, zPostWorkspacesCurrentPluginInstallMarketplaceBody, @@ -219,10 +236,6 @@ import { zPostWorkspacesCurrentPluginParametersDynamicOptionsWithCredentialsResponse, zPostWorkspacesCurrentPluginPermissionChangeBody, zPostWorkspacesCurrentPluginPermissionChangeResponse, - zPostWorkspacesCurrentPluginPreferencesAutoupgradeExcludeBody, - zPostWorkspacesCurrentPluginPreferencesAutoupgradeExcludeResponse, - zPostWorkspacesCurrentPluginPreferencesChangeBody, - zPostWorkspacesCurrentPluginPreferencesChangeResponse, zPostWorkspacesCurrentPluginTasksByTaskIdDeleteByIdentifierPath, zPostWorkspacesCurrentPluginTasksByTaskIdDeleteByIdentifierResponse, zPostWorkspacesCurrentPluginTasksByTaskIdDeletePath, @@ -319,16 +332,10 @@ import { /** * Get specific agent provider details - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get = oc .route({ - deprecated: true, - description: - 'Get specific agent provider details\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get specific agent provider details', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentAgentProviderByProviderName', @@ -348,16 +355,10 @@ export const agentProvider = { /** * Get list of available agent providers - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get2 = oc .route({ - deprecated: true, - description: - 'Get list of available agent providers\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get list of available agent providers', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentAgentProviders', @@ -374,16 +375,10 @@ export const agentProviders = { * Confirm a pending snippet import * * Confirm a pending snippet import - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post = oc .route({ - deprecated: true, - description: - 'Confirm a pending snippet import\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Confirm a pending snippet import', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirm', @@ -406,16 +401,10 @@ export const byImportId = { * Import snippet from DSL * * Import snippet from DSL - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post2 = oc .route({ - deprecated: true, - description: - 'Import snippet from DSL\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Import snippet from DSL', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentCustomizedSnippetsImports', @@ -435,16 +424,10 @@ export const imports = { * Check dependencies for a snippet * * Check dependencies for a snippet - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get3 = oc .route({ - deprecated: true, - description: - 'Check dependencies for a snippet\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Check dependencies for a snippet', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependencies', @@ -465,16 +448,10 @@ export const checkDependencies = { * Export snippet as DSL * * Export snippet configuration as DSL - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get4 = oc .route({ - deprecated: true, - description: - 'Export snippet configuration as DSL\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Export snippet configuration as DSL', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentCustomizedSnippetsBySnippetIdExport', @@ -482,7 +459,12 @@ export const get4 = oc summary: 'Export snippet as DSL', tags: ['console'], }) - .input(z.object({ params: zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportPath })) + .input( + z.object({ + params: zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportPath, + query: zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportQuery.optional(), + }), + ) .output(zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportResponse) export const export_ = { @@ -493,16 +475,10 @@ export const export_ = { * Increment snippet use count when it is inserted into a workflow * * Increment snippet use count by 1 - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post3 = oc .route({ - deprecated: true, - description: - 'Increment snippet use count by 1\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Increment snippet use count by 1', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrement', @@ -541,16 +517,9 @@ export const delete_ = oc /** * Get customized snippet details - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get5 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentCustomizedSnippetsBySnippetId', @@ -563,16 +532,9 @@ export const get5 = oc /** * Update customized snippet - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const patch = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchWorkspacesCurrentCustomizedSnippetsBySnippetId', @@ -599,16 +561,9 @@ export const bySnippetId = { /** * List customized snippets with pagination and search - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get6 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentCustomizedSnippets', @@ -621,16 +576,9 @@ export const get6 = oc /** * Create a new customized snippet - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post4 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentCustomizedSnippets', @@ -663,16 +611,8 @@ export const datasetOperators = { get: get7, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const get8 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentDefaultModel', @@ -701,15 +641,13 @@ export const defaultModel = { /** * Deprecated legacy alias for creating a plugin endpoint. Use POST /workspaces/current/endpoints instead. * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * * @deprecated */ export const post6 = oc .route({ deprecated: true, description: - 'Deprecated legacy alias for creating a plugin endpoint. Use POST /workspaces/current/endpoints instead.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + 'Deprecated legacy alias for creating a plugin endpoint. Use POST /workspaces/current/endpoints instead.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentEndpointsCreate', @@ -786,16 +724,10 @@ export const enable = { /** * List endpoints for a specific plugin - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get9 = oc .route({ - deprecated: true, - description: - 'List endpoints for a specific plugin\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'List endpoints for a specific plugin', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentEndpointsListPlugin', @@ -811,16 +743,10 @@ export const plugin = { /** * List plugin endpoints with pagination - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get10 = oc .route({ - deprecated: true, - description: - 'List plugin endpoints with pagination\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'List plugin endpoints with pagination', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentEndpointsList', @@ -838,15 +764,13 @@ export const list = { /** * Deprecated legacy alias for updating a plugin endpoint. Use PATCH /workspaces/current/endpoints/{id} instead. * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * * @deprecated */ export const post10 = oc .route({ deprecated: true, description: - 'Deprecated legacy alias for updating a plugin endpoint. Use PATCH /workspaces/current/endpoints/{id} instead.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + 'Deprecated legacy alias for updating a plugin endpoint. Use PATCH /workspaces/current/endpoints/{id} instead.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentEndpointsUpdate', @@ -877,16 +801,10 @@ export const delete3 = oc /** * Update a plugin endpoint - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const patch2 = oc .route({ - deprecated: true, - description: - 'Update a plugin endpoint\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Update a plugin endpoint', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchWorkspacesCurrentEndpointsById', @@ -908,16 +826,10 @@ export const byId = { /** * Create a new plugin endpoint - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post11 = oc .route({ - deprecated: true, - description: - 'Create a new plugin endpoint\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Create a new plugin endpoint', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentEndpoints', @@ -938,20 +850,13 @@ export const endpoints = { byId, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post12 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentMembersInviteEmail', path: '/workspaces/current/members/invite-email', + successStatus: 201, tags: ['console'], }) .input(z.object({ body: zPostWorkspacesCurrentMembersInviteEmailBody })) @@ -991,16 +896,8 @@ export const sendOwnerTransferConfirmEmail = { post: post14, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post15 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentMembersByMemberIdOwnerTransfer', @@ -1019,16 +916,8 @@ export const ownerTransfer = { post: post15, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const put = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PUT', operationId: 'putWorkspacesCurrentMembersByMemberIdUpdateRole', @@ -1047,16 +936,8 @@ export const updateRole = { put, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const delete4 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteWorkspacesCurrentMembersByMemberId', @@ -1090,16 +971,8 @@ export const members = { byMemberId, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const get12 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentModelProvidersByProviderCheckoutUrl', @@ -1133,16 +1006,8 @@ export const switch_ = { post: post16, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post17 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentModelProvidersByProviderCredentialsValidate', @@ -1178,16 +1043,8 @@ export const delete5 = oc ) .output(zDeleteWorkspacesCurrentModelProvidersByProviderCredentialsResponse) -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const get13 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentModelProvidersByProviderCredentials', @@ -1202,20 +1059,13 @@ export const get13 = oc ) .output(zGetWorkspacesCurrentModelProvidersByProviderCredentialsResponse) -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post18 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentModelProvidersByProviderCredentials', path: '/workspaces/current/model-providers/{provider}/credentials', + successStatus: 201, tags: ['console'], }) .input( @@ -1226,16 +1076,8 @@ export const post18 = oc ) .output(zPostWorkspacesCurrentModelProvidersByProviderCredentialsResponse) -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const put2 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PUT', operationId: 'putWorkspacesCurrentModelProvidersByProviderCredentials', @@ -1279,16 +1121,8 @@ export const switch2 = { post: post19, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post20 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentModelProvidersByProviderModelsCredentialsValidate', @@ -1324,16 +1158,8 @@ export const delete6 = oc ) .output(zDeleteWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse) -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const get14 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentModelProvidersByProviderModelsCredentials', @@ -1348,20 +1174,13 @@ export const get14 = oc ) .output(zGetWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse) -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post21 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentModelProvidersByProviderModelsCredentials', path: '/workspaces/current/model-providers/{provider}/models/credentials', + successStatus: 201, tags: ['console'], }) .input( @@ -1372,16 +1191,8 @@ export const post21 = oc ) .output(zPostWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse) -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const put3 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PUT', operationId: 'putWorkspacesCurrentModelProvidersByProviderModelsCredentials', @@ -1445,16 +1256,8 @@ export const enable2 = { patch: patch4, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post22 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: @@ -1477,16 +1280,8 @@ export const credentialsValidate = { post: post22, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post23 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: @@ -1518,16 +1313,8 @@ export const loadBalancingConfigs = { byConfigId, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const get15 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentModelProvidersByProviderModelsParameterRules', @@ -1563,16 +1350,8 @@ export const delete7 = oc ) .output(zDeleteWorkspacesCurrentModelProvidersByProviderModelsResponse) -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const get16 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentModelProvidersByProviderModels', @@ -1582,16 +1361,8 @@ export const get16 = oc .input(z.object({ params: zGetWorkspacesCurrentModelProvidersByProviderModelsPath })) .output(zGetWorkspacesCurrentModelProvidersByProviderModelsResponse) -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post24 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentModelProvidersByProviderModels', @@ -1644,16 +1415,8 @@ export const byProvider = { preferredProviderType, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const get17 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentModelProviders', @@ -1668,16 +1431,8 @@ export const modelProviders = { byProvider, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const get18 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentModelsModelTypesByModelType', @@ -1721,16 +1476,8 @@ export const permission = { get: get19, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const get20 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentPluginAsset', @@ -1744,7 +1491,58 @@ export const asset = { get: get20, } +export const post26 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentPluginAutoUpgradeChange', + path: '/workspaces/current/plugin/auto-upgrade/change', + tags: ['console'], + }) + .input(z.object({ body: zPostWorkspacesCurrentPluginAutoUpgradeChangeBody })) + .output(zPostWorkspacesCurrentPluginAutoUpgradeChangeResponse) + +export const change = { + post: post26, +} + +export const post27 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentPluginAutoUpgradeExclude', + path: '/workspaces/current/plugin/auto-upgrade/exclude', + tags: ['console'], + }) + .input(z.object({ body: zPostWorkspacesCurrentPluginAutoUpgradeExcludeBody })) + .output(zPostWorkspacesCurrentPluginAutoUpgradeExcludeResponse) + +export const exclude = { + post: post27, +} + export const get21 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentPluginAutoUpgradeFetch', + path: '/workspaces/current/plugin/auto-upgrade/fetch', + tags: ['console'], + }) + .input(z.object({ query: zGetWorkspacesCurrentPluginAutoUpgradeFetchQuery })) + .output(zGetWorkspacesCurrentPluginAutoUpgradeFetchResponse) + +export const fetch_ = { + get: get21, +} + +export const autoUpgrade = { + change, + exclude, + fetch: fetch_, +} + +export const get22 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1755,19 +1553,11 @@ export const get21 = oc .output(zGetWorkspacesCurrentPluginDebuggingKeyResponse) export const debuggingKey = { - get: get21, + get: get22, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get22 = oc +export const get23 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentPluginFetchManifest', @@ -1778,19 +1568,11 @@ export const get22 = oc .output(zGetWorkspacesCurrentPluginFetchManifestResponse) export const fetchManifest = { - get: get22, + get: get23, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get23 = oc +export const get24 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentPluginIcon', @@ -1801,19 +1583,11 @@ export const get23 = oc .output(zGetWorkspacesCurrentPluginIconResponse) export const icon = { - get: get23, + get: get24, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const post26 = oc +export const post28 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentPluginInstallGithub', @@ -1824,19 +1598,11 @@ export const post26 = oc .output(zPostWorkspacesCurrentPluginInstallGithubResponse) export const github = { - post: post26, + post: post28, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const post27 = oc +export const post29 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentPluginInstallMarketplace', @@ -1847,19 +1613,11 @@ export const post27 = oc .output(zPostWorkspacesCurrentPluginInstallMarketplaceResponse) export const marketplace = { - post: post27, + post: post29, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const post28 = oc +export const post30 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentPluginInstallPkg', @@ -1870,7 +1628,7 @@ export const post28 = oc .output(zPostWorkspacesCurrentPluginInstallPkgResponse) export const pkg = { - post: post28, + post: post30, } export const install = { @@ -1879,16 +1637,8 @@ export const install = { pkg, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const post29 = oc +export const post31 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentPluginListInstallationsIds', @@ -1899,23 +1649,15 @@ export const post29 = oc .output(zPostWorkspacesCurrentPluginListInstallationsIdsResponse) export const ids = { - post: post29, + post: post31, } export const installations = { ids, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const post30 = oc +export const post32 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentPluginListLatestVersions', @@ -1926,19 +1668,11 @@ export const post30 = oc .output(zPostWorkspacesCurrentPluginListLatestVersionsResponse) export const latestVersions = { - post: post30, + post: post32, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get24 = oc +export const get25 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentPluginList', @@ -1949,21 +1683,13 @@ export const get24 = oc .output(zGetWorkspacesCurrentPluginListResponse) export const list2 = { - get: get24, + get: get25, installations, latestVersions, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get25 = oc +export const get26 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentPluginMarketplacePkg', @@ -1974,23 +1700,15 @@ export const get25 = oc .output(zGetWorkspacesCurrentPluginMarketplacePkgResponse) export const pkg2 = { - get: get25, + get: get26, } export const marketplace2 = { pkg: pkg2, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get26 = oc +export const get27 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentPluginParametersDynamicOptions', @@ -2001,21 +1719,14 @@ export const get26 = oc .output(zGetWorkspacesCurrentPluginParametersDynamicOptionsResponse) export const dynamicOptions = { - get: get26, + get: get27, } /** * Fetch dynamic options using credentials directly (for edit mode) - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const post31 = oc +export const post33 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentPluginParametersDynamicOptionsWithCredentials', @@ -2029,7 +1740,7 @@ export const post31 = oc .output(zPostWorkspacesCurrentPluginParametersDynamicOptionsWithCredentialsResponse) export const dynamicOptionsWithCredentials = { - post: post31, + post: post33, } export const parameters = { @@ -2037,7 +1748,7 @@ export const parameters = { dynamicOptionsWithCredentials, } -export const post32 = oc +export const post34 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2048,20 +1759,12 @@ export const post32 = oc .input(z.object({ body: zPostWorkspacesCurrentPluginPermissionChangeBody })) .output(zPostWorkspacesCurrentPluginPermissionChangeResponse) -export const change = { - post: post32, +export const change2 = { + post: post34, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get27 = oc +export const get28 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentPluginPermissionFetch', @@ -2070,103 +1773,17 @@ export const get27 = oc }) .output(zGetWorkspacesCurrentPluginPermissionFetchResponse) -export const fetch_ = { - get: get27, -} - -export const permission2 = { - change, - fetch: fetch_, -} - -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const post33 = oc - .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postWorkspacesCurrentPluginPreferencesAutoupgradeExclude', - path: '/workspaces/current/plugin/preferences/autoupgrade/exclude', - tags: ['console'], - }) - .input(z.object({ body: zPostWorkspacesCurrentPluginPreferencesAutoupgradeExcludeBody })) - .output(zPostWorkspacesCurrentPluginPreferencesAutoupgradeExcludeResponse) - -export const exclude = { - post: post33, -} - -export const autoupgrade = { - exclude, -} - -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const post34 = oc - .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postWorkspacesCurrentPluginPreferencesChange', - path: '/workspaces/current/plugin/preferences/change', - tags: ['console'], - }) - .input(z.object({ body: zPostWorkspacesCurrentPluginPreferencesChangeBody })) - .output(zPostWorkspacesCurrentPluginPreferencesChangeResponse) - -export const change2 = { - post: post34, -} - -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get28 = oc - .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getWorkspacesCurrentPluginPreferencesFetch', - path: '/workspaces/current/plugin/preferences/fetch', - tags: ['console'], - }) - .output(zGetWorkspacesCurrentPluginPreferencesFetchResponse) - export const fetch2 = { get: get28, } -export const preferences = { - autoupgrade, +export const permission2 = { change: change2, fetch: fetch2, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const get29 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentPluginReadme', @@ -2225,16 +1842,8 @@ export const delete8 = { byIdentifier, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const get30 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentPluginTasksByTaskId', @@ -2249,16 +1858,8 @@ export const byTaskId = { delete: delete8, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const get31 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentPluginTasks', @@ -2289,16 +1890,8 @@ export const uninstall = { post: post38, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post39 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentPluginUpgradeGithub', @@ -2312,16 +1905,8 @@ export const github2 = { post: post39, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post40 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentPluginUpgradeMarketplace', @@ -2340,16 +1925,8 @@ export const upgrade = { marketplace: marketplace3, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post41 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentPluginUploadBundle', @@ -2362,16 +1939,8 @@ export const bundle = { post: post41, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post42 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentPluginUploadGithub', @@ -2385,16 +1954,8 @@ export const github3 = { post: post42, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post43 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentPluginUploadPkg', @@ -2413,8 +1974,33 @@ export const upload = { pkg: pkg3, } +export const get32 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentPluginByCategoryList', + path: '/workspaces/current/plugin/{category}/list', + tags: ['console'], + }) + .input( + z.object({ + params: zGetWorkspacesCurrentPluginByCategoryListPath, + query: zGetWorkspacesCurrentPluginByCategoryListQuery.optional(), + }), + ) + .output(zGetWorkspacesCurrentPluginByCategoryListResponse) + +export const list3 = { + get: get32, +} + +export const byCategory = { + list: list3, +} + export const plugin2 = { asset, + autoUpgrade, debuggingKey, fetchManifest, icon, @@ -2423,24 +2009,16 @@ export const plugin2 = { marketplace: marketplace2, parameters, permission: permission2, - preferences, readme, tasks, uninstall, upgrade, upload, + byCategory, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get32 = oc +export const get33 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolLabels', @@ -2450,19 +2028,11 @@ export const get32 = oc .output(zGetWorkspacesCurrentToolLabelsResponse) export const toolLabels = { - get: get32, + get: get33, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post44 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentToolProviderApiAdd', @@ -2476,16 +2046,8 @@ export const add = { post: post44, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post45 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentToolProviderApiDelete', @@ -2499,60 +2061,38 @@ export const delete9 = { post: post45, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get33 = oc +export const get34 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolProviderApiGet', path: '/workspaces/current/tool-provider/api/get', tags: ['console'], }) + .input(z.object({ query: zGetWorkspacesCurrentToolProviderApiGetQuery })) .output(zGetWorkspacesCurrentToolProviderApiGetResponse) -export const get34 = { - get: get33, +export const get35 = { + get: get34, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get35 = oc +export const get36 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolProviderApiRemote', path: '/workspaces/current/tool-provider/api/remote', tags: ['console'], }) + .input(z.object({ query: zGetWorkspacesCurrentToolProviderApiRemoteQuery })) .output(zGetWorkspacesCurrentToolProviderApiRemoteResponse) export const remote = { - get: get35, + get: get36, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post46 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentToolProviderApiSchema', @@ -2566,16 +2106,8 @@ export const schema = { post: post46, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post47 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentToolProviderApiTestPre', @@ -2593,38 +2125,23 @@ export const test = { pre, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get36 = oc +export const get37 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolProviderApiTools', path: '/workspaces/current/tool-provider/api/tools', tags: ['console'], }) + .input(z.object({ query: zGetWorkspacesCurrentToolProviderApiToolsQuery })) .output(zGetWorkspacesCurrentToolProviderApiToolsResponse) export const tools = { - get: get36, + get: get37, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post48 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentToolProviderApiUpdate', @@ -2641,7 +2158,7 @@ export const update2 = { export const api = { add, delete: delete9, - get: get34, + get: get35, remote, schema, test, @@ -2649,16 +2166,8 @@ export const api = { update: update2, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post49 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentToolProviderBuiltinByProviderAdd', @@ -2677,39 +2186,28 @@ export const add2 = { post: post49, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get37 = oc +export const get38 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfo', path: '/workspaces/current/tool-provider/builtin/{provider}/credential/info', tags: ['console'], }) - .input(z.object({ params: zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfoPath })) + .input( + z.object({ + params: zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfoPath, + query: zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfoQuery.optional(), + }), + ) .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfoResponse) export const info = { - get: get37, + get: get38, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get38 = oc +export const get39 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: @@ -2728,7 +2226,7 @@ export const get38 = oc ) export const byCredentialType = { - get: get38, + get: get39, } export const schema2 = { @@ -2740,39 +2238,28 @@ export const credential = { schema: schema2, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get39 = oc +export const get40 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolProviderBuiltinByProviderCredentials', path: '/workspaces/current/tool-provider/builtin/{provider}/credentials', tags: ['console'], }) - .input(z.object({ params: zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialsPath })) + .input( + z.object({ + params: zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialsPath, + query: zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialsQuery.optional(), + }), + ) .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialsResponse) export const credentials3 = { - get: get39, + get: get40, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post50 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentToolProviderBuiltinByProviderDefaultCredential', @@ -2791,16 +2278,8 @@ export const defaultCredential = { post: post50, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post51 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentToolProviderBuiltinByProviderDelete', @@ -2819,16 +2298,8 @@ export const delete10 = { post: post51, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get40 = oc +export const get41 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolProviderBuiltinByProviderIcon', @@ -2839,19 +2310,11 @@ export const get40 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderIconResponse) export const icon2 = { - get: get40, + get: get41, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get41 = oc +export const get42 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolProviderBuiltinByProviderInfo', @@ -2862,19 +2325,11 @@ export const get41 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderInfoResponse) export const info2 = { - get: get41, + get: get42, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get42 = oc +export const get43 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolProviderBuiltinByProviderOauthClientSchema', @@ -2887,19 +2342,11 @@ export const get42 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderOauthClientSchemaResponse) export const clientSchema = { - get: get42, + get: get43, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const delete11 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClient', @@ -2913,16 +2360,8 @@ export const delete11 = oc ) .output(zDeleteWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse) -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get43 = oc +export const get44 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClient', @@ -2934,16 +2373,8 @@ export const get43 = oc ) .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse) -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post52 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClient', @@ -2960,7 +2391,7 @@ export const post52 = oc export const customClient = { delete: delete11, - get: get43, + get: get44, post: post52, } @@ -2969,16 +2400,8 @@ export const oauth = { customClient, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get44 = oc +export const get45 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolProviderBuiltinByProviderTools', @@ -2989,19 +2412,11 @@ export const get44 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderToolsResponse) export const tools2 = { - get: get44, + get: get45, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post53 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentToolProviderBuiltinByProviderUpdate', @@ -3037,16 +2452,8 @@ export const builtin = { byProvider: byProvider2, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post54 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentToolProviderMcpAuth', @@ -3060,16 +2467,8 @@ export const auth = { post: post54, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get45 = oc +export const get46 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolProviderMcpToolsByProviderId', @@ -3080,23 +2479,15 @@ export const get45 = oc .output(zGetWorkspacesCurrentToolProviderMcpToolsByProviderIdResponse) export const byProviderId = { - get: get45, + get: get46, } export const tools3 = { byProviderId, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get46 = oc +export const get47 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolProviderMcpUpdateByProviderId', @@ -3107,7 +2498,7 @@ export const get46 = oc .output(zGetWorkspacesCurrentToolProviderMcpUpdateByProviderIdResponse) export const byProviderId2 = { - get: get46, + get: get47, } export const update4 = { @@ -3125,16 +2516,8 @@ export const delete12 = oc .input(z.object({ body: zDeleteWorkspacesCurrentToolProviderMcpBody })) .output(zDeleteWorkspacesCurrentToolProviderMcpResponse) -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post55 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentToolProviderMcp', @@ -3144,16 +2527,8 @@ export const post55 = oc .input(z.object({ body: zPostWorkspacesCurrentToolProviderMcpBody })) .output(zPostWorkspacesCurrentToolProviderMcpResponse) -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const put4 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PUT', operationId: 'putWorkspacesCurrentToolProviderMcp', @@ -3172,16 +2547,8 @@ export const mcp = { update: update4, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post56 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentToolProviderWorkflowCreate', @@ -3195,16 +2562,8 @@ export const create2 = { post: post56, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post57 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentToolProviderWorkflowDelete', @@ -3218,60 +2577,38 @@ export const delete13 = { post: post57, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get47 = oc +export const get48 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolProviderWorkflowGet', path: '/workspaces/current/tool-provider/workflow/get', tags: ['console'], }) + .input(z.object({ query: zGetWorkspacesCurrentToolProviderWorkflowGetQuery.optional() })) .output(zGetWorkspacesCurrentToolProviderWorkflowGetResponse) -export const get48 = { - get: get47, +export const get49 = { + get: get48, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get49 = oc +export const get50 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolProviderWorkflowTools', path: '/workspaces/current/tool-provider/workflow/tools', tags: ['console'], }) + .input(z.object({ query: zGetWorkspacesCurrentToolProviderWorkflowToolsQuery })) .output(zGetWorkspacesCurrentToolProviderWorkflowToolsResponse) export const tools4 = { - get: get49, + get: get50, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post58 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentToolProviderWorkflowUpdate', @@ -3288,7 +2625,7 @@ export const update5 = { export const workflow = { create: create2, delete: delete13, - get: get48, + get: get49, tools: tools4, update: update5, } @@ -3300,38 +2637,23 @@ export const toolProvider = { workflow, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get50 = oc +export const get51 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolProviders', path: '/workspaces/current/tool-providers', tags: ['console'], }) + .input(z.object({ query: zGetWorkspacesCurrentToolProvidersQuery.optional() })) .output(zGetWorkspacesCurrentToolProvidersResponse) export const toolProviders = { - get: get50, + get: get51, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get51 = oc +export const get52 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolsApi', @@ -3341,19 +2663,11 @@ export const get51 = oc .output(zGetWorkspacesCurrentToolsApiResponse) export const api2 = { - get: get51, + get: get52, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get52 = oc +export const get53 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolsBuiltin', @@ -3363,19 +2677,11 @@ export const get52 = oc .output(zGetWorkspacesCurrentToolsBuiltinResponse) export const builtin2 = { - get: get52, + get: get53, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get53 = oc +export const get54 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolsMcp', @@ -3385,19 +2691,11 @@ export const get53 = oc .output(zGetWorkspacesCurrentToolsMcpResponse) export const mcp2 = { - get: get53, + get: get54, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get54 = oc +export const get55 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolsWorkflow', @@ -3407,7 +2705,7 @@ export const get54 = oc .output(zGetWorkspacesCurrentToolsWorkflowResponse) export const workflow2 = { - get: get54, + get: get55, } export const tools5 = { @@ -3417,16 +2715,8 @@ export const tools5 = { workflow: workflow2, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get55 = oc +export const get56 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentTriggerProviderByProviderIcon', @@ -3437,21 +2727,14 @@ export const get55 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderIconResponse) export const icon3 = { - get: get55, + get: get56, } /** * Get info for a trigger provider - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get56 = oc +export const get57 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentTriggerProviderByProviderInfo', @@ -3463,21 +2746,14 @@ export const get56 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderInfoResponse) export const info3 = { - get: get56, + get: get57, } /** * Remove custom OAuth client configuration - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const delete14 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteWorkspacesCurrentTriggerProviderByProviderOauthClient', @@ -3490,16 +2766,9 @@ export const delete14 = oc /** * Get OAuth client configuration for a provider - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get57 = oc +export const get58 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentTriggerProviderByProviderOauthClient', @@ -3512,16 +2781,9 @@ export const get57 = oc /** * Configure custom OAuth client for a provider - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post59 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentTriggerProviderByProviderOauthClient', @@ -3539,7 +2801,7 @@ export const post59 = oc export const client = { delete: delete14, - get: get57, + get: get58, post: post59, } @@ -3549,16 +2811,9 @@ export const oauth2 = { /** * Build a subscription instance for a trigger provider - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post60 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: @@ -3588,16 +2843,9 @@ export const build = { /** * Add a new subscription instance for a trigger provider - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post61 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderCreate', @@ -3619,16 +2867,9 @@ export const create3 = { /** * Get the request logs for a subscription instance for a trigger provider - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get58 = oc +export const get59 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: @@ -3648,7 +2889,7 @@ export const get58 = oc ) export const bySubscriptionBuilderId2 = { - get: get58, + get: get59, } export const logs = { @@ -3657,16 +2898,9 @@ export const logs = { /** * Update a subscription instance for a trigger provider - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post62 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: @@ -3696,16 +2930,9 @@ export const update6 = { /** * Verify and update a subscription instance for a trigger provider - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post63 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: @@ -3735,16 +2962,9 @@ export const verifyAndUpdate = { /** * Get a subscription instance for a trigger provider - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get59 = oc +export const get60 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: @@ -3764,7 +2984,7 @@ export const get59 = oc ) export const bySubscriptionBuilderId5 = { - get: get59, + get: get60, } export const builder = { @@ -3778,16 +2998,9 @@ export const builder = { /** * List all trigger subscriptions for the current tenant's provider - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get60 = oc +export const get61 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentTriggerProviderByProviderSubscriptionsList', @@ -3798,22 +3011,15 @@ export const get60 = oc .input(z.object({ params: zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListPath })) .output(zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListResponse) -export const list3 = { - get: get60, +export const list4 = { + get: get61, } /** * Initiate OAuth authorization flow for a trigger provider - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get61 = oc +export const get62 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentTriggerProviderByProviderSubscriptionsOauthAuthorize', @@ -3829,7 +3035,7 @@ export const get61 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsOauthAuthorizeResponse) export const authorize = { - get: get61, + get: get62, } export const oauth3 = { @@ -3838,16 +3044,9 @@ export const oauth3 = { /** * Verify credentials for an existing subscription (edit mode only) - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post64 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: @@ -3877,7 +3076,7 @@ export const verify = { export const subscriptions = { builder, - list: list3, + list: list4, oauth: oauth3, verify, } @@ -3914,16 +3113,9 @@ export const delete15 = { /** * Update a subscription instance - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post66 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsUpdate', @@ -3959,16 +3151,9 @@ export const triggerProvider = { /** * List all trigger providers for the current tenant - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ -export const get62 = oc +export const get63 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentTriggers', @@ -3979,7 +3164,7 @@ export const get62 = oc .output(zGetWorkspacesCurrentTriggersResponse) export const triggers = { - get: get62, + get: get63, } export const post67 = oc @@ -4013,20 +3198,13 @@ export const current = { triggers, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post68 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCustomConfigWebappLogoUpload', path: '/workspaces/custom-config/webapp-logo/upload', + successStatus: 201, tags: ['console'], }) .output(zPostWorkspacesCustomConfigWebappLogoUploadResponse) @@ -4039,16 +3217,8 @@ export const webappLogo = { upload: upload2, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post69 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCustomConfig', @@ -4063,16 +3233,8 @@ export const customConfig = { webappLogo, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post70 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesInfo', @@ -4086,16 +3248,8 @@ export const info4 = { post: post70, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post71 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesSwitch', @@ -4109,16 +3263,8 @@ export const switch3 = { post: post71, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get63 = oc +export const get64 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesByTenantIdModelProvidersByProviderByIconTypeByLang', @@ -4129,7 +3275,7 @@ export const get63 = oc .output(zGetWorkspacesByTenantIdModelProvidersByProviderByIconTypeByLangResponse) export const byLang = { - get: get63, + get: get64, } export const byIconType = { @@ -4148,16 +3294,8 @@ export const byTenantId = { modelProviders: modelProviders2, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get64 = oc +export const get65 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspaces', @@ -4167,7 +3305,7 @@ export const get64 = oc .output(zGetWorkspacesResponse) export const workspaces = { - get: get64, + get: get65, current, customConfig, info: info4, diff --git a/packages/contracts/generated/api/console/workspaces/types.gen.ts b/packages/contracts/generated/api/console/workspaces/types.gen.ts index 696620bbe5e..8b6f34b18ff 100644 --- a/packages/contracts/generated/api/console/workspaces/types.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/types.gen.ts @@ -4,9 +4,13 @@ export type ClientOptions = { baseUrl: `${string}://${string}/console/api` | (string & {}) } +export type TenantListResponse = { + workspaces: Array +} + export type TenantInfoResponse = { created_at?: number | null - custom_config?: WorkspaceCustomConfigResponse + custom_config?: WorkspaceCustomConfigResponse | null id: string in_trial?: boolean | null name?: string | null @@ -19,8 +23,16 @@ export type TenantInfoResponse = { trial_end_reason?: string | null } +export type AgentProviderResponse = { + [key: string]: unknown +} + +export type AgentProviderListResponse = Array<{ + [key: string]: unknown +}> + export type SnippetPagination = { - data?: Array + data?: Array has_more?: boolean limit?: number page?: number @@ -32,16 +44,14 @@ export type CreateSnippetPayload = { graph?: { [key: string]: unknown } | null - icon_info?: IconInfo + icon_info?: IconInfo | null input_fields?: Array | null name: string type?: 'group' | 'node' } export type Snippet = { - created_at?: { - [key: string]: unknown - } + created_at?: number created_by?: AnonymousInlineModelB0Fd3F86D9D5 description?: string graph?: { @@ -58,9 +68,7 @@ export type Snippet = { name?: string tags?: Array type?: string - updated_at?: { - [key: string]: unknown - } + updated_at?: number updated_by?: AnonymousInlineModelB0Fd3F86D9D5 use_count?: number version?: number @@ -75,16 +83,35 @@ export type SnippetImportPayload = { yaml_url?: string | null } +export type SnippetImportResponse = { + [key: string]: unknown +} + export type UpdateSnippetPayload = { description?: string | null - icon_info?: IconInfo + icon_info?: IconInfo | null name?: string | null } +export type SnippetDependencyCheckResponse = { + [key: string]: unknown +} + +export type TextFileResponse = string + +export type SnippetUseCountResponse = { + result: string + use_count: number +} + export type AccountWithRoleList = { accounts: Array } +export type DefaultModelDataResponse = { + data?: DefaultModelResponse | null +} + export type ParserPostDefault = { model_settings: Array } @@ -158,6 +185,12 @@ export type MemberInvitePayload = { role: TenantAccountRole } +export type MemberInviteResponse = { + invitation_results: Array + result: string + tenant_id: string +} + export type OwnerTransferCheckPayload = { code: string token: string @@ -178,6 +211,11 @@ export type SimpleResultDataResponse = { result: string } +export type MemberActionTenantResponse = { + result: string + tenant_id: string +} + export type OwnerTransferPayload = { token: string } @@ -186,10 +224,24 @@ export type MemberRoleUpdatePayload = { role: string } +export type ModelProviderListResponse = { + data: Array +} + +export type ModelProviderPaymentCheckoutUrlResponse = { + payment_link: string +} + export type ParserCredentialDelete = { credential_id: string } +export type ProviderCredentialResponse = { + credentials?: { + [key: string]: unknown + } | null +} + export type ParserCredentialCreate = { credentials: { [key: string]: unknown @@ -215,15 +267,24 @@ export type ParserCredentialValidate = { } } +export type ProviderCredentialValidateResponse = { + error?: string | null + result: 'error' | 'success' +} + export type ParserDeleteModels = { model: string model_type: ModelType } +export type ModelWithProviderListResponse = { + data: Array +} + export type ParserPostModels = { config_from?: string | null credential_id?: string | null - load_balancing?: LoadBalancingPayload + load_balancing?: LoadBalancingPayload | null model: string model_type: ModelType } @@ -234,6 +295,16 @@ export type ParserDeleteCredential = { model_type: ModelType } +export type ModelCredentialResponse = { + available_credentials: Array + credentials?: { + [key: string]: unknown + } + current_credential_id?: string | null + current_credential_name?: string | null + load_balancing: ModelCredentialLoadBalancingResponse +} + export type ParserCreateCredential = { credentials: { [key: string]: unknown @@ -267,6 +338,11 @@ export type ParserValidate = { model_type: ModelType } +export type ModelCredentialValidateResponse = { + error?: string | null + result: string +} + export type LoadBalancingCredentialPayload = { credentials: { [key: string]: unknown @@ -275,22 +351,65 @@ export type LoadBalancingCredentialPayload = { model_type: ModelType } +export type LoadBalancingCredentialValidateResponse = { + error?: string | null + result: string +} + +export type ModelParameterRulesResponse = { + data: Array +} + export type ParserPreferredProviderType = { preferred_provider_type: 'custom' | 'system' } +export type ProviderWithModelsDataResponse = { + data: Array +} + export type WorkspacePermissionResponse = { allow_member_invite: boolean allow_owner_transfer: boolean workspace_id: string } +export type BinaryFileResponse = Blob | File + +export type ParserAutoUpgradeChange = { + auto_upgrade: PluginAutoUpgradeSettingsPayload + category: PluginCategory +} + +export type PluginAutoUpgradeChangeResponse = { + message?: string | null + success: boolean +} + +export type ParserExcludePlugin = { + category: PluginCategory + plugin_id: string +} + +export type SuccessResponse = { + success: boolean +} + +export type PluginAutoUpgradeFetchResponse = { + auto_upgrade: PluginAutoUpgradeSettingsResponseModel + category: PluginCategory +} + export type PluginDebuggingKeyResponse = { host: string key: string port: number } +export type PluginManifestResponse = { + manifest: unknown +} + export type ParserGithubInstall = { package: string plugin_unique_identifier: string @@ -298,14 +417,33 @@ export type ParserGithubInstall = { version: string } +export type PluginDaemonOperationResponse = unknown + export type ParserPluginIdentifiers = { plugin_unique_identifiers: Array } +export type PluginListResponse = { + plugins: unknown + total: number +} + export type ParserLatest = { plugin_ids: Array } +export type PluginInstallationsResponse = { + plugins: unknown +} + +export type PluginVersionsResponse = { + versions: unknown +} + +export type PluginDynamicOptionsResponse = { + options: unknown +} + export type ParserDynamicOptionsWithCredentials = { action: string credential_id: string @@ -318,21 +456,25 @@ export type ParserDynamicOptionsWithCredentials = { } export type ParserPermissionChange = { + debug_permission?: DebugPermission + install_permission?: InstallPermission +} + +export type PluginPermissionResponse = { debug_permission: DebugPermission install_permission: InstallPermission } -export type SuccessResponse = { - success: boolean +export type PluginReadmeResponse = { + readme: string } -export type ParserExcludePlugin = { - plugin_id: string +export type PluginTasksResponse = { + tasks: unknown } -export type ParserPreferencesChange = { - auto_upgrade: PluginAutoUpgradeSettingsPayload - permission: PluginPermissionSettingsPayload +export type PluginTaskResponse = { + task: unknown } export type ParserUninstall = { @@ -358,6 +500,14 @@ export type ParserGithubUpload = { version: string } +export type PluginCategoryListResponse = { + builtin_tools: Array + has_more: boolean + plugins: Array +} + +export type ToolProviderOpaqueResponse = unknown + export type ApiToolProviderAddPayload = { credentials: { [key: string]: unknown @@ -427,6 +577,14 @@ export type BuiltinToolCredentialDeletePayload = { credential_id: string } +export type ToolOAuthClientSchemaResponse = Array<{ + [key: string]: unknown +}> + +export type ToolOAuthCustomClientResponse = { + [key: string]: unknown +} + export type ToolOAuthCustomClientPayload = { client_params?: { [key: string]: unknown @@ -459,7 +617,7 @@ export type McpProviderCreatePayload = { icon: string icon_background?: string icon_type: string - identity_mode?: IdentityMode + identity_mode?: IdentityMode | null name: string server_identifier: string server_url: string @@ -478,7 +636,7 @@ export type McpProviderUpdatePayload = { icon: string icon_background?: string icon_type: string - identity_mode?: IdentityMode + identity_mode?: IdentityMode | null name: string provider_id: string server_identifier: string @@ -520,6 +678,20 @@ export type WorkflowToolUpdatePayload = { workflow_tool_id: string } +export type TriggerProviderOpaqueResponse = unknown + +export type TriggerOAuthClientResponse = { + configured: boolean + custom_configured: boolean + custom_enabled: boolean + oauth_client_schema: unknown + params: { + [key: string]: unknown + } + redirect_uri: string + system_configured: boolean +} + export type TriggerOAuthClientPayload = { client_params?: { [key: string]: unknown @@ -550,11 +722,26 @@ export type TriggerSubscriptionBuilderVerifyPayload = { } } +export type TriggerOAuthAuthorizeResponse = { + authorization_url: string + subscription_builder: unknown + subscription_builder_id: string +} + export type WorkspaceCustomConfigPayload = { remove_webapp_brand?: boolean | null replace_webapp_logo?: string | null } +export type WorkspaceMutationResponse = { + result: string + tenant: TenantInfoResponse +} + +export type WorkspaceLogoUploadResponse = { + id: string +} + export type WorkspaceInfoPayload = { name: string } @@ -563,16 +750,28 @@ export type SwitchWorkspacePayload = { tenant_id: string } +export type SwitchWorkspaceResponse = { + new_tenant: TenantInfoResponse + result: string +} + +export type TenantListItemResponse = { + created_at?: number | null + current: boolean + id: string + name?: string | null + plan?: string | null + status?: string | null +} + export type WorkspaceCustomConfigResponse = { remove_webapp_brand?: boolean | null replace_webapp_logo?: string | null } -export type AnonymousInlineModel7B67Ac8A4Db8 = { +export type AnonymousInlineModelEfd591151Ea9 = { author_name?: string - created_at?: { - [key: string]: unknown - } + created_at?: number created_by?: string description?: string icon_info?: { @@ -583,9 +782,7 @@ export type AnonymousInlineModel7B67Ac8A4Db8 = { name?: string tags?: Array type?: string - updated_at?: { - [key: string]: unknown - } + updated_at?: number updated_by?: string use_count?: number version?: number @@ -633,6 +830,12 @@ export type AccountWithRole = { status: string } +export type DefaultModelResponse = { + model: string + model_type: ModelType + provider: SimpleProviderEntityResponse +} + export type Inner = { model?: string | null model_type: ModelType @@ -641,8 +844,49 @@ export type Inner = { export type TenantAccountRole = 'admin' | 'dataset_operator' | 'editor' | 'normal' | 'owner' +export type MemberInviteResultResponse = { + email: string + message?: string | null + status: string + url?: string | null +} + +export type ProviderResponse = { + background?: string | null + configurate_methods: Array + custom_configuration: CustomConfigurationResponse + description?: I18nObject | null + help?: ProviderHelpEntity | null + icon_small?: I18nObject | null + icon_small_dark?: I18nObject | null + label: I18nObject + model_credential_schema?: ModelCredentialSchema | null + preferred_provider_type: ProviderType + provider: string + provider_credential_schema?: ProviderCredentialSchema | null + supported_model_types: Array + system_configuration: SystemConfigurationResponse + tenant_id: string +} + export type ModelType = 'llm' | 'moderation' | 'rerank' | 'speech2text' | 'text-embedding' | 'tts' +export type ModelWithProviderEntityResponse = { + deprecated?: boolean + features?: Array | null + fetch_from: FetchFrom + has_invalid_load_balancing_configs?: boolean + label: I18nObject + load_balancing_enabled?: boolean + model: string + model_properties: { + [key in ModelPropertyKey]?: unknown + } + model_type: ModelType + provider: SimpleProviderEntityResponse + status: ModelStatus +} + export type LoadBalancingPayload = { configs?: Array<{ [key: string]: unknown @@ -650,9 +894,41 @@ export type LoadBalancingPayload = { enabled?: boolean | null } -export type DebugPermission = 'admins' | 'everyone' | 'noone' +export type CredentialConfiguration = { + credential_id: string + credential_name: string +} -export type InstallPermission = 'admins' | 'everyone' | 'noone' +export type ModelCredentialLoadBalancingResponse = { + configs?: Array<{ + [key: string]: unknown + }> + enabled: boolean +} + +export type ParameterRule = { + default?: unknown | null + help?: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject | null + label: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject + max?: number | null + min?: number | null + name: string + options?: Array + precision?: number | null + required?: boolean + type: ParameterType + use_template?: string | null +} + +export type ProviderWithModelsResponse = { + icon_small?: I18nObject | null + icon_small_dark?: I18nObject | null + label: I18nObject + models: Array + provider: string + status: CustomConfigurationStatus + tenant_id: string +} export type PluginAutoUpgradeSettingsPayload = { exclude_plugins?: Array @@ -662,9 +938,75 @@ export type PluginAutoUpgradeSettingsPayload = { upgrade_time_of_day?: number } -export type PluginPermissionSettingsPayload = { - debug_permission?: DebugPermission - install_permission?: InstallPermission +export type PluginCategory + = | 'agent-strategy' + | 'datasource' + | 'extension' + | 'model' + | 'tool' + | 'trigger' + +export type PluginAutoUpgradeSettingsResponseModel = { + exclude_plugins: Array + include_plugins: Array + strategy_setting: StrategySetting + upgrade_mode: UpgradeMode + upgrade_time_of_day: number +} + +export type DebugPermission = 'admins' | 'everyone' | 'noone' + +export type InstallPermission = 'admins' | 'everyone' | 'noone' + +export type PluginCategoryBuiltinToolProviderResponse = { + allow_delete: boolean + author: string + description: CoreToolsEntitiesCommonEntitiesI18nObject + icon: + | string + | { + [key: string]: string + } + icon_dark: + | string + | { + [key: string]: string + } + | null + id: string + is_team_authorization: boolean + label: CoreToolsEntitiesCommonEntitiesI18nObject + labels: Array + name: string + plugin_id: string | null + plugin_unique_identifier: string | null + team_credentials: { + [key: string]: unknown + } + tools: Array + type: ToolProviderType + [key: string]: unknown +} + +export type PluginCategoryInstalledPluginResponse = { + checksum: string + created_at: string + declaration: PluginDeclarationResponse + endpoints_active: number + endpoints_setups: number + id: string + installation_id: string + meta: { + [key: string]: unknown + } + name: string + plugin_id: string + plugin_unique_identifier: string + runtime_type: string + source: PluginInstallationSource + tenant_id: string + updated_at: string + version: string } export type ApiProviderSchemaType = 'openai_actions' | 'openai_plugin' | 'openapi' | 'swagger' @@ -679,12 +1021,301 @@ export type WorkflowToolParameterConfiguration = { name: string } +export type SimpleProviderEntityResponse = { + icon_small?: I18nObject | null + icon_small_dark?: I18nObject | null + label: I18nObject + models?: Array + provider: string + provider_name?: string + supported_model_types: Array + tenant_id: string +} + +export type ConfigurateMethod = 'customizable-model' | 'predefined-model' + +export type CustomConfigurationResponse = { + available_credentials?: Array | null + can_added_models?: Array | null + current_credential_id?: string | null + current_credential_name?: string | null + custom_models?: Array | null + status: CustomConfigurationStatus +} + +export type I18nObject = { + en_US: string + ja_JP?: string | null + pt_BR?: string | null + zh_Hans?: string | null +} + +export type ProviderHelpEntity = { + title: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject + url: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject +} + +export type ModelCredentialSchema = { + credential_form_schemas: Array + model: FieldModelSchema +} + +export type ProviderType = 'custom' | 'system' + +export type ProviderCredentialSchema = { + credential_form_schemas: Array +} + +export type SystemConfigurationResponse = { + current_quota_type?: ProviderQuotaType | null + enabled: boolean + quota_configurations?: Array +} + +export type ModelFeature + = | 'agent-thought' + | 'audio' + | 'document' + | 'multi-tool-call' + | 'polling' + | 'stream-tool-call' + | 'structured-output' + | 'tool-call' + | 'video' + | 'vision' + +export type FetchFrom = 'customizable-model' | 'predefined-model' + +export type ModelPropertyKey + = | 'audio_type' + | 'context_size' + | 'default_voice' + | 'file_upload_limit' + | 'max_characters_per_chunk' + | 'max_chunks' + | 'max_workers' + | 'mode' + | 'supported_file_extensions' + | 'voices' + | 'word_limit' + +export type ModelStatus + = | 'active' + | 'credential-removed' + | 'disabled' + | 'no-configure' + | 'no-permission' + | 'quota-exceeded' + +export type GraphonModelRuntimeEntitiesCommonEntitiesI18nObject = { + en_US: string + zh_Hans?: string | null +} + +export type ParameterType = 'boolean' | 'float' | 'int' | 'string' | 'text' + +export type ProviderModelWithStatusEntity = { + deprecated?: boolean + features?: Array | null + fetch_from: FetchFrom + has_invalid_load_balancing_configs?: boolean + label: I18nObject + load_balancing_enabled?: boolean + model: string + model_properties: { + [key in ModelPropertyKey]?: unknown + } + model_type: ModelType + status: ModelStatus +} + +export type CustomConfigurationStatus = 'active' | 'no-configure' + export type StrategySetting = 'disabled' | 'fix_only' | 'latest' export type UpgradeMode = 'all' | 'exclude' | 'partial' +export type CoreToolsEntitiesCommonEntitiesI18nObject = { + en_US: string + ja_JP?: string | null + pt_BR?: string | null + zh_Hans?: string | null +} + +export type PluginCategoryBuiltinToolResponse = { + author: string + description: CoreToolsEntitiesCommonEntitiesI18nObject + label: CoreToolsEntitiesCommonEntitiesI18nObject + labels: Array + name: string + output_schema: { + [key: string]: unknown + } + parameters?: Array<{ + [key: string]: unknown + }> | null + [key: string]: unknown +} + +export type ToolProviderType + = | 'api' + | 'app' + | 'builtin' + | 'dataset-retrieval' + | 'mcp' + | 'plugin' + | 'workflow' + +export type PluginDeclarationResponse = { + agent_strategy?: { + [key: string]: unknown + } | null + author: string | null + category: PluginCategory + created_at: string + datasource?: { + [key: string]: unknown + } | null + description: CoreToolsEntitiesCommonEntitiesI18nObject + endpoint?: { + [key: string]: unknown + } | null + icon: string + icon_dark?: string | null + label: CoreToolsEntitiesCommonEntitiesI18nObject + meta: { + [key: string]: unknown + } + model?: ProviderEntityResponse | null + name: string + plugins: { + [key: string]: Array | null + } + repo?: string | null + resource: { + [key: string]: unknown + } + tags?: Array + tool?: { + [key: string]: unknown + } | null + trigger?: { + [key: string]: unknown + } | null + verified?: boolean + version: string +} + +export type PluginInstallationSource = 'github' | 'marketplace' | 'package' | 'remote' + export type ToolParameterForm = 'form' | 'llm' | 'schema' +export type AiModelEntityResponse = { + deprecated?: boolean + features?: Array | null + fetch_from: FetchFrom + label: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject + model: string + model_properties: { + [key in ModelPropertyKey]?: unknown + } + model_type: ModelType + parameter_rules?: Array + pricing?: PriceConfigResponse | null +} + +export type UnaddedModelConfiguration = { + model: string + model_type: ModelType +} + +export type CustomModelConfiguration = { + available_model_credentials?: Array + credentials: { + [key: string]: unknown + } | null + current_credential_id?: string | null + current_credential_name?: string | null + model: string + model_type: ModelType + unadded_to_model_list?: boolean | null +} + +export type CredentialFormSchema = { + default?: string | null + label: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject + max_length?: number + options?: Array | null + placeholder?: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject | null + required?: boolean + show_on?: Array + type: FormType + variable: string +} + +export type FieldModelSchema = { + label: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject + placeholder?: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject | null +} + +export type ProviderQuotaType = 'free' | 'paid' | 'trial' + +export type QuotaConfiguration = { + is_valid: boolean + quota_limit: number + quota_type: ProviderQuotaType + quota_unit: QuotaUnit + quota_used: number + restrict_models?: Array +} + +export type ProviderEntityResponse = { + 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 PriceConfigResponse = { + currency: string + input: string + output?: string | null + unit: string +} + +export type FormOption = { + label: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject + show_on?: Array + value: string +} + +export type FormShowOnObject = { + value: string + variable: string +} + +export type FormType = 'radio' | 'secret-input' | 'select' | 'switch' | 'text-input' + +export type QuotaUnit = 'credits' | 'times' | 'tokens' + +export type RestrictModel = { + base_model_name?: string | null + model: string + model_type: ModelType +} + export type GetWorkspacesData = { body?: never path?: never @@ -693,9 +1324,7 @@ export type GetWorkspacesData = { } export type GetWorkspacesResponses = { - 200: { - [key: string]: unknown - } + 200: TenantListResponse } export type GetWorkspacesResponse = GetWorkspacesResponses[keyof GetWorkspacesResponses] @@ -724,9 +1353,7 @@ export type GetWorkspacesCurrentAgentProviderByProviderNameData = { } export type GetWorkspacesCurrentAgentProviderByProviderNameResponses = { - 200: { - [key: string]: unknown - } + 200: AgentProviderResponse } export type GetWorkspacesCurrentAgentProviderByProviderNameResponse @@ -740,9 +1367,7 @@ export type GetWorkspacesCurrentAgentProvidersData = { } export type GetWorkspacesCurrentAgentProvidersResponses = { - 200: Array<{ - [key: string]: unknown - }> + 200: AgentProviderListResponse } export type GetWorkspacesCurrentAgentProvidersResponse @@ -752,12 +1377,12 @@ export type GetWorkspacesCurrentCustomizedSnippetsData = { body?: never path?: never query?: { - creators?: Array | null - is_published?: boolean | null - keyword?: string | null + creators?: Array + is_published?: boolean + keyword?: string limit?: number page?: number - tag_ids?: Array | null + tag_ids?: Array } url: '/workspaces/current/customized-snippets' } @@ -777,14 +1402,9 @@ export type PostWorkspacesCurrentCustomizedSnippetsData = { } export type PostWorkspacesCurrentCustomizedSnippetsErrors = { - 400: { - [key: string]: unknown - } + 400: unknown } -export type PostWorkspacesCurrentCustomizedSnippetsError - = PostWorkspacesCurrentCustomizedSnippetsErrors[keyof PostWorkspacesCurrentCustomizedSnippetsErrors] - export type PostWorkspacesCurrentCustomizedSnippetsResponses = { 201: Snippet } @@ -800,21 +1420,12 @@ export type PostWorkspacesCurrentCustomizedSnippetsImportsData = { } export type PostWorkspacesCurrentCustomizedSnippetsImportsErrors = { - 400: { - [key: string]: unknown - } + 400: unknown } -export type PostWorkspacesCurrentCustomizedSnippetsImportsError - = PostWorkspacesCurrentCustomizedSnippetsImportsErrors[keyof PostWorkspacesCurrentCustomizedSnippetsImportsErrors] - export type PostWorkspacesCurrentCustomizedSnippetsImportsResponses = { - 200: { - [key: string]: unknown - } - 202: { - [key: string]: unknown - } + 200: SnippetImportResponse + 202: SnippetImportResponse } export type PostWorkspacesCurrentCustomizedSnippetsImportsResponse @@ -830,18 +1441,11 @@ export type PostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmData } export type PostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmErrors = { - 400: { - [key: string]: unknown - } + 400: unknown } -export type PostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmError - = PostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmErrors[keyof PostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmErrors] - export type PostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmResponses = { - 200: { - [key: string]: unknown - } + 200: SnippetImportResponse } export type PostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmResponse @@ -857,18 +1461,11 @@ export type DeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdData = { } export type DeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type DeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdError - = DeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdErrors[keyof DeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdErrors] - export type DeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdResponse @@ -884,14 +1481,9 @@ export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdData = { } export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdError - = GetWorkspacesCurrentCustomizedSnippetsBySnippetIdErrors[keyof GetWorkspacesCurrentCustomizedSnippetsBySnippetIdErrors] - export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdResponses = { 200: Snippet } @@ -909,17 +1501,10 @@ export type PatchWorkspacesCurrentCustomizedSnippetsBySnippetIdData = { } export type PatchWorkspacesCurrentCustomizedSnippetsBySnippetIdErrors = { - 400: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 400: unknown + 404: unknown } -export type PatchWorkspacesCurrentCustomizedSnippetsBySnippetIdError - = PatchWorkspacesCurrentCustomizedSnippetsBySnippetIdErrors[keyof PatchWorkspacesCurrentCustomizedSnippetsBySnippetIdErrors] - export type PatchWorkspacesCurrentCustomizedSnippetsBySnippetIdResponses = { 200: Snippet } @@ -937,18 +1522,11 @@ export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesDa } export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesError - = GetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesErrors[keyof GetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesErrors] - export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesResponses = { - 200: { - [key: string]: unknown - } + 200: SnippetDependencyCheckResponse } export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesResponse @@ -959,23 +1537,18 @@ export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportData = { path: { snippet_id: string } - query?: never + query?: { + include_secret?: string + } url: '/workspaces/current/customized-snippets/{snippet_id}/export' } export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportError - = GetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportErrors[keyof GetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportErrors] - export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportResponses = { - 200: { - [key: string]: unknown - } + 200: TextFileResponse } export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportResponse @@ -991,18 +1564,11 @@ export type PostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementD } export type PostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementErrors = { - 404: { - [key: string]: unknown - } + 404: unknown } -export type PostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementError - = PostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementErrors[keyof PostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementErrors] - export type PostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementResponses = { - 200: { - [key: string]: unknown - } + 200: SnippetUseCountResponse } export type PostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementResponse @@ -1026,15 +1592,13 @@ export type GetWorkspacesCurrentDefaultModelData = { body?: never path?: never query: { - model_type: string + model_type: 'llm' | 'moderation' | 'rerank' | 'speech2text' | 'text-embedding' | 'tts' } url: '/workspaces/current/default-model' } export type GetWorkspacesCurrentDefaultModelResponses = { - 200: { - [key: string]: unknown - } + 200: DefaultModelDataResponse } export type GetWorkspacesCurrentDefaultModelResponse @@ -1062,14 +1626,9 @@ export type PostWorkspacesCurrentEndpointsData = { } export type PostWorkspacesCurrentEndpointsErrors = { - 403: { - [key: string]: unknown - } + 403: unknown } -export type PostWorkspacesCurrentEndpointsError - = PostWorkspacesCurrentEndpointsErrors[keyof PostWorkspacesCurrentEndpointsErrors] - export type PostWorkspacesCurrentEndpointsResponses = { 200: EndpointCreateResponse } @@ -1085,14 +1644,9 @@ export type PostWorkspacesCurrentEndpointsCreateData = { } export type PostWorkspacesCurrentEndpointsCreateErrors = { - 403: { - [key: string]: unknown - } + 403: unknown } -export type PostWorkspacesCurrentEndpointsCreateError - = PostWorkspacesCurrentEndpointsCreateErrors[keyof PostWorkspacesCurrentEndpointsCreateErrors] - export type PostWorkspacesCurrentEndpointsCreateResponses = { 200: EndpointCreateResponse } @@ -1108,14 +1662,9 @@ export type PostWorkspacesCurrentEndpointsDeleteData = { } export type PostWorkspacesCurrentEndpointsDeleteErrors = { - 403: { - [key: string]: unknown - } + 403: unknown } -export type PostWorkspacesCurrentEndpointsDeleteError - = PostWorkspacesCurrentEndpointsDeleteErrors[keyof PostWorkspacesCurrentEndpointsDeleteErrors] - export type PostWorkspacesCurrentEndpointsDeleteResponses = { 200: EndpointDeleteResponse } @@ -1131,14 +1680,9 @@ export type PostWorkspacesCurrentEndpointsDisableData = { } export type PostWorkspacesCurrentEndpointsDisableErrors = { - 403: { - [key: string]: unknown - } + 403: unknown } -export type PostWorkspacesCurrentEndpointsDisableError - = PostWorkspacesCurrentEndpointsDisableErrors[keyof PostWorkspacesCurrentEndpointsDisableErrors] - export type PostWorkspacesCurrentEndpointsDisableResponses = { 200: EndpointDisableResponse } @@ -1154,14 +1698,9 @@ export type PostWorkspacesCurrentEndpointsEnableData = { } export type PostWorkspacesCurrentEndpointsEnableErrors = { - 403: { - [key: string]: unknown - } + 403: unknown } -export type PostWorkspacesCurrentEndpointsEnableError - = PostWorkspacesCurrentEndpointsEnableErrors[keyof PostWorkspacesCurrentEndpointsEnableErrors] - export type PostWorkspacesCurrentEndpointsEnableResponses = { 200: EndpointEnableResponse } @@ -1212,14 +1751,9 @@ export type PostWorkspacesCurrentEndpointsUpdateData = { } export type PostWorkspacesCurrentEndpointsUpdateErrors = { - 403: { - [key: string]: unknown - } + 403: unknown } -export type PostWorkspacesCurrentEndpointsUpdateError - = PostWorkspacesCurrentEndpointsUpdateErrors[keyof PostWorkspacesCurrentEndpointsUpdateErrors] - export type PostWorkspacesCurrentEndpointsUpdateResponses = { 200: EndpointUpdateResponse } @@ -1237,14 +1771,9 @@ export type DeleteWorkspacesCurrentEndpointsByIdData = { } export type DeleteWorkspacesCurrentEndpointsByIdErrors = { - 403: { - [key: string]: unknown - } + 403: unknown } -export type DeleteWorkspacesCurrentEndpointsByIdError - = DeleteWorkspacesCurrentEndpointsByIdErrors[keyof DeleteWorkspacesCurrentEndpointsByIdErrors] - export type DeleteWorkspacesCurrentEndpointsByIdResponses = { 200: EndpointDeleteResponse } @@ -1262,14 +1791,9 @@ export type PatchWorkspacesCurrentEndpointsByIdData = { } export type PatchWorkspacesCurrentEndpointsByIdErrors = { - 403: { - [key: string]: unknown - } + 403: unknown } -export type PatchWorkspacesCurrentEndpointsByIdError - = PatchWorkspacesCurrentEndpointsByIdErrors[keyof PatchWorkspacesCurrentEndpointsByIdErrors] - export type PatchWorkspacesCurrentEndpointsByIdResponses = { 200: EndpointUpdateResponse } @@ -1299,9 +1823,7 @@ export type PostWorkspacesCurrentMembersInviteEmailData = { } export type PostWorkspacesCurrentMembersInviteEmailResponses = { - 200: { - [key: string]: unknown - } + 201: MemberInviteResponse } export type PostWorkspacesCurrentMembersInviteEmailResponse @@ -1345,9 +1867,7 @@ export type DeleteWorkspacesCurrentMembersByMemberIdData = { } export type DeleteWorkspacesCurrentMembersByMemberIdResponses = { - 200: { - [key: string]: unknown - } + 200: MemberActionTenantResponse } export type DeleteWorkspacesCurrentMembersByMemberIdResponse @@ -1363,9 +1883,7 @@ export type PostWorkspacesCurrentMembersByMemberIdOwnerTransferData = { } export type PostWorkspacesCurrentMembersByMemberIdOwnerTransferResponses = { - 200: { - [key: string]: unknown - } + 200: SimpleResultResponse } export type PostWorkspacesCurrentMembersByMemberIdOwnerTransferResponse @@ -1381,9 +1899,7 @@ export type PutWorkspacesCurrentMembersByMemberIdUpdateRoleData = { } export type PutWorkspacesCurrentMembersByMemberIdUpdateRoleResponses = { - 200: { - [key: string]: unknown - } + 200: SimpleResultResponse } export type PutWorkspacesCurrentMembersByMemberIdUpdateRoleResponse @@ -1393,15 +1909,13 @@ export type GetWorkspacesCurrentModelProvidersData = { body?: never path?: never query?: { - model_type?: string | null + model_type?: 'llm' | 'moderation' | 'rerank' | 'speech2text' | 'text-embedding' | 'tts' } url: '/workspaces/current/model-providers' } export type GetWorkspacesCurrentModelProvidersResponses = { - 200: { - [key: string]: unknown - } + 200: ModelProviderListResponse } export type GetWorkspacesCurrentModelProvidersResponse @@ -1417,9 +1931,7 @@ export type GetWorkspacesCurrentModelProvidersByProviderCheckoutUrlData = { } export type GetWorkspacesCurrentModelProvidersByProviderCheckoutUrlResponses = { - 200: { - [key: string]: unknown - } + 200: ModelProviderPaymentCheckoutUrlResponse } export type GetWorkspacesCurrentModelProvidersByProviderCheckoutUrlResponse @@ -1435,9 +1947,7 @@ export type DeleteWorkspacesCurrentModelProvidersByProviderCredentialsData = { } export type DeleteWorkspacesCurrentModelProvidersByProviderCredentialsResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteWorkspacesCurrentModelProvidersByProviderCredentialsResponse @@ -1449,15 +1959,13 @@ export type GetWorkspacesCurrentModelProvidersByProviderCredentialsData = { provider: string } query?: { - credential_id?: string | null + credential_id?: string } url: '/workspaces/current/model-providers/{provider}/credentials' } export type GetWorkspacesCurrentModelProvidersByProviderCredentialsResponses = { - 200: { - [key: string]: unknown - } + 200: ProviderCredentialResponse } export type GetWorkspacesCurrentModelProvidersByProviderCredentialsResponse @@ -1473,9 +1981,7 @@ export type PostWorkspacesCurrentModelProvidersByProviderCredentialsData = { } export type PostWorkspacesCurrentModelProvidersByProviderCredentialsResponses = { - 200: { - [key: string]: unknown - } + 201: SimpleResultResponse } export type PostWorkspacesCurrentModelProvidersByProviderCredentialsResponse @@ -1491,9 +1997,7 @@ export type PutWorkspacesCurrentModelProvidersByProviderCredentialsData = { } export type PutWorkspacesCurrentModelProvidersByProviderCredentialsResponses = { - 200: { - [key: string]: unknown - } + 200: SimpleResultResponse } export type PutWorkspacesCurrentModelProvidersByProviderCredentialsResponse @@ -1525,9 +2029,7 @@ export type PostWorkspacesCurrentModelProvidersByProviderCredentialsValidateData } export type PostWorkspacesCurrentModelProvidersByProviderCredentialsValidateResponses = { - 200: { - [key: string]: unknown - } + 200: ProviderCredentialValidateResponse } export type PostWorkspacesCurrentModelProvidersByProviderCredentialsValidateResponse @@ -1543,9 +2045,7 @@ export type DeleteWorkspacesCurrentModelProvidersByProviderModelsData = { } export type DeleteWorkspacesCurrentModelProvidersByProviderModelsResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteWorkspacesCurrentModelProvidersByProviderModelsResponse @@ -1561,9 +2061,7 @@ export type GetWorkspacesCurrentModelProvidersByProviderModelsData = { } export type GetWorkspacesCurrentModelProvidersByProviderModelsResponses = { - 200: { - [key: string]: unknown - } + 200: ModelWithProviderListResponse } export type GetWorkspacesCurrentModelProvidersByProviderModelsResponse @@ -1579,9 +2077,7 @@ export type PostWorkspacesCurrentModelProvidersByProviderModelsData = { } export type PostWorkspacesCurrentModelProvidersByProviderModelsResponses = { - 200: { - [key: string]: unknown - } + 200: SimpleResultResponse } export type PostWorkspacesCurrentModelProvidersByProviderModelsResponse @@ -1597,9 +2093,7 @@ export type DeleteWorkspacesCurrentModelProvidersByProviderModelsCredentialsData } export type DeleteWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse @@ -1611,18 +2105,16 @@ export type GetWorkspacesCurrentModelProvidersByProviderModelsCredentialsData = provider: string } query: { - config_from?: string | null - credential_id?: string | null + config_from?: string + credential_id?: string model: string - model_type: string + model_type: 'llm' | 'moderation' | 'rerank' | 'speech2text' | 'text-embedding' | 'tts' } url: '/workspaces/current/model-providers/{provider}/models/credentials' } export type GetWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponses = { - 200: { - [key: string]: unknown - } + 200: ModelCredentialResponse } export type GetWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse @@ -1638,9 +2130,7 @@ export type PostWorkspacesCurrentModelProvidersByProviderModelsCredentialsData = } export type PostWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponses = { - 200: { - [key: string]: unknown - } + 201: SimpleResultResponse } export type PostWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse @@ -1656,9 +2146,7 @@ export type PutWorkspacesCurrentModelProvidersByProviderModelsCredentialsData = } export type PutWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponses = { - 200: { - [key: string]: unknown - } + 200: SimpleResultResponse } export type PutWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse @@ -1690,9 +2178,7 @@ export type PostWorkspacesCurrentModelProvidersByProviderModelsCredentialsValida } export type PostWorkspacesCurrentModelProvidersByProviderModelsCredentialsValidateResponses = { - 200: { - [key: string]: unknown - } + 200: ModelCredentialValidateResponse } export type PostWorkspacesCurrentModelProvidersByProviderModelsCredentialsValidateResponse @@ -1742,9 +2228,7 @@ export type PostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConf export type PostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsCredentialsValidateResponses = { - 200: { - [key: string]: unknown - } + 200: LoadBalancingCredentialValidateResponse } export type PostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsCredentialsValidateResponse @@ -1763,9 +2247,7 @@ export type PostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConf export type PostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsByConfigIdCredentialsValidateResponses = { - 200: { - [key: string]: unknown - } + 200: LoadBalancingCredentialValidateResponse } export type PostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsByConfigIdCredentialsValidateResponse @@ -1783,9 +2265,7 @@ export type GetWorkspacesCurrentModelProvidersByProviderModelsParameterRulesData } export type GetWorkspacesCurrentModelProvidersByProviderModelsParameterRulesResponses = { - 200: { - [key: string]: unknown - } + 200: ModelParameterRulesResponse } export type GetWorkspacesCurrentModelProvidersByProviderModelsParameterRulesResponse @@ -1817,9 +2297,7 @@ export type GetWorkspacesCurrentModelsModelTypesByModelTypeData = { } export type GetWorkspacesCurrentModelsModelTypesByModelTypeResponses = { - 200: { - [key: string]: unknown - } + 200: ProviderWithModelsDataResponse } export type GetWorkspacesCurrentModelsModelTypesByModelTypeResponse @@ -1850,14 +2328,56 @@ export type GetWorkspacesCurrentPluginAssetData = { } export type GetWorkspacesCurrentPluginAssetResponses = { - 200: { - [key: string]: unknown - } + 200: BinaryFileResponse } export type GetWorkspacesCurrentPluginAssetResponse = GetWorkspacesCurrentPluginAssetResponses[keyof GetWorkspacesCurrentPluginAssetResponses] +export type PostWorkspacesCurrentPluginAutoUpgradeChangeData = { + body: ParserAutoUpgradeChange + path?: never + query?: never + url: '/workspaces/current/plugin/auto-upgrade/change' +} + +export type PostWorkspacesCurrentPluginAutoUpgradeChangeResponses = { + 200: PluginAutoUpgradeChangeResponse +} + +export type PostWorkspacesCurrentPluginAutoUpgradeChangeResponse + = PostWorkspacesCurrentPluginAutoUpgradeChangeResponses[keyof PostWorkspacesCurrentPluginAutoUpgradeChangeResponses] + +export type PostWorkspacesCurrentPluginAutoUpgradeExcludeData = { + body: ParserExcludePlugin + path?: never + query?: never + url: '/workspaces/current/plugin/auto-upgrade/exclude' +} + +export type PostWorkspacesCurrentPluginAutoUpgradeExcludeResponses = { + 200: SuccessResponse +} + +export type PostWorkspacesCurrentPluginAutoUpgradeExcludeResponse + = PostWorkspacesCurrentPluginAutoUpgradeExcludeResponses[keyof PostWorkspacesCurrentPluginAutoUpgradeExcludeResponses] + +export type GetWorkspacesCurrentPluginAutoUpgradeFetchData = { + body?: never + path?: never + query: { + category: 'agent-strategy' | 'datasource' | 'extension' | 'model' | 'tool' | 'trigger' + } + url: '/workspaces/current/plugin/auto-upgrade/fetch' +} + +export type GetWorkspacesCurrentPluginAutoUpgradeFetchResponses = { + 200: PluginAutoUpgradeFetchResponse +} + +export type GetWorkspacesCurrentPluginAutoUpgradeFetchResponse + = GetWorkspacesCurrentPluginAutoUpgradeFetchResponses[keyof GetWorkspacesCurrentPluginAutoUpgradeFetchResponses] + export type GetWorkspacesCurrentPluginDebuggingKeyData = { body?: never path?: never @@ -1882,9 +2402,7 @@ export type GetWorkspacesCurrentPluginFetchManifestData = { } export type GetWorkspacesCurrentPluginFetchManifestResponses = { - 200: { - [key: string]: unknown - } + 200: PluginManifestResponse } export type GetWorkspacesCurrentPluginFetchManifestResponse @@ -1901,9 +2419,7 @@ export type GetWorkspacesCurrentPluginIconData = { } export type GetWorkspacesCurrentPluginIconResponses = { - 200: { - [key: string]: unknown - } + 200: BinaryFileResponse } export type GetWorkspacesCurrentPluginIconResponse @@ -1917,9 +2433,7 @@ export type PostWorkspacesCurrentPluginInstallGithubData = { } export type PostWorkspacesCurrentPluginInstallGithubResponses = { - 200: { - [key: string]: unknown - } + 200: PluginDaemonOperationResponse } export type PostWorkspacesCurrentPluginInstallGithubResponse @@ -1933,9 +2447,7 @@ export type PostWorkspacesCurrentPluginInstallMarketplaceData = { } export type PostWorkspacesCurrentPluginInstallMarketplaceResponses = { - 200: { - [key: string]: unknown - } + 200: PluginDaemonOperationResponse } export type PostWorkspacesCurrentPluginInstallMarketplaceResponse @@ -1949,9 +2461,7 @@ export type PostWorkspacesCurrentPluginInstallPkgData = { } export type PostWorkspacesCurrentPluginInstallPkgResponses = { - 200: { - [key: string]: unknown - } + 200: PluginDaemonOperationResponse } export type PostWorkspacesCurrentPluginInstallPkgResponse @@ -1968,9 +2478,7 @@ export type GetWorkspacesCurrentPluginListData = { } export type GetWorkspacesCurrentPluginListResponses = { - 200: { - [key: string]: unknown - } + 200: PluginListResponse } export type GetWorkspacesCurrentPluginListResponse @@ -1984,9 +2492,7 @@ export type PostWorkspacesCurrentPluginListInstallationsIdsData = { } export type PostWorkspacesCurrentPluginListInstallationsIdsResponses = { - 200: { - [key: string]: unknown - } + 200: PluginInstallationsResponse } export type PostWorkspacesCurrentPluginListInstallationsIdsResponse @@ -2000,9 +2506,7 @@ export type PostWorkspacesCurrentPluginListLatestVersionsData = { } export type PostWorkspacesCurrentPluginListLatestVersionsResponses = { - 200: { - [key: string]: unknown - } + 200: PluginVersionsResponse } export type PostWorkspacesCurrentPluginListLatestVersionsResponse @@ -2018,9 +2522,7 @@ export type GetWorkspacesCurrentPluginMarketplacePkgData = { } export type GetWorkspacesCurrentPluginMarketplacePkgResponses = { - 200: { - [key: string]: unknown - } + 200: PluginManifestResponse } export type GetWorkspacesCurrentPluginMarketplacePkgResponse @@ -2031,7 +2533,7 @@ export type GetWorkspacesCurrentPluginParametersDynamicOptionsData = { path?: never query: { action: string - credential_id?: string | null + credential_id?: string parameter: string plugin_id: string provider: string @@ -2041,9 +2543,7 @@ export type GetWorkspacesCurrentPluginParametersDynamicOptionsData = { } export type GetWorkspacesCurrentPluginParametersDynamicOptionsResponses = { - 200: { - [key: string]: unknown - } + 200: PluginDynamicOptionsResponse } export type GetWorkspacesCurrentPluginParametersDynamicOptionsResponse @@ -2057,9 +2557,7 @@ export type PostWorkspacesCurrentPluginParametersDynamicOptionsWithCredentialsDa } export type PostWorkspacesCurrentPluginParametersDynamicOptionsWithCredentialsResponses = { - 200: { - [key: string]: unknown - } + 200: PluginDynamicOptionsResponse } export type PostWorkspacesCurrentPluginParametersDynamicOptionsWithCredentialsResponse @@ -2087,62 +2585,12 @@ export type GetWorkspacesCurrentPluginPermissionFetchData = { } export type GetWorkspacesCurrentPluginPermissionFetchResponses = { - 200: { - [key: string]: unknown - } + 200: PluginPermissionResponse } export type GetWorkspacesCurrentPluginPermissionFetchResponse = GetWorkspacesCurrentPluginPermissionFetchResponses[keyof GetWorkspacesCurrentPluginPermissionFetchResponses] -export type PostWorkspacesCurrentPluginPreferencesAutoupgradeExcludeData = { - body: ParserExcludePlugin - path?: never - query?: never - url: '/workspaces/current/plugin/preferences/autoupgrade/exclude' -} - -export type PostWorkspacesCurrentPluginPreferencesAutoupgradeExcludeResponses = { - 200: { - [key: string]: unknown - } -} - -export type PostWorkspacesCurrentPluginPreferencesAutoupgradeExcludeResponse - = PostWorkspacesCurrentPluginPreferencesAutoupgradeExcludeResponses[keyof PostWorkspacesCurrentPluginPreferencesAutoupgradeExcludeResponses] - -export type PostWorkspacesCurrentPluginPreferencesChangeData = { - body: ParserPreferencesChange - path?: never - query?: never - url: '/workspaces/current/plugin/preferences/change' -} - -export type PostWorkspacesCurrentPluginPreferencesChangeResponses = { - 200: { - [key: string]: unknown - } -} - -export type PostWorkspacesCurrentPluginPreferencesChangeResponse - = PostWorkspacesCurrentPluginPreferencesChangeResponses[keyof PostWorkspacesCurrentPluginPreferencesChangeResponses] - -export type GetWorkspacesCurrentPluginPreferencesFetchData = { - body?: never - path?: never - query?: never - url: '/workspaces/current/plugin/preferences/fetch' -} - -export type GetWorkspacesCurrentPluginPreferencesFetchResponses = { - 200: { - [key: string]: unknown - } -} - -export type GetWorkspacesCurrentPluginPreferencesFetchResponse - = GetWorkspacesCurrentPluginPreferencesFetchResponses[keyof GetWorkspacesCurrentPluginPreferencesFetchResponses] - export type GetWorkspacesCurrentPluginReadmeData = { body?: never path?: never @@ -2154,9 +2602,7 @@ export type GetWorkspacesCurrentPluginReadmeData = { } export type GetWorkspacesCurrentPluginReadmeResponses = { - 200: { - [key: string]: unknown - } + 200: PluginReadmeResponse } export type GetWorkspacesCurrentPluginReadmeResponse @@ -2173,9 +2619,7 @@ export type GetWorkspacesCurrentPluginTasksData = { } export type GetWorkspacesCurrentPluginTasksResponses = { - 200: { - [key: string]: unknown - } + 200: PluginTasksResponse } export type GetWorkspacesCurrentPluginTasksResponse @@ -2205,9 +2649,7 @@ export type GetWorkspacesCurrentPluginTasksByTaskIdData = { } export type GetWorkspacesCurrentPluginTasksByTaskIdResponses = { - 200: { - [key: string]: unknown - } + 200: PluginTaskResponse } export type GetWorkspacesCurrentPluginTasksByTaskIdResponse @@ -2268,9 +2710,7 @@ export type PostWorkspacesCurrentPluginUpgradeGithubData = { } export type PostWorkspacesCurrentPluginUpgradeGithubResponses = { - 200: { - [key: string]: unknown - } + 200: PluginDaemonOperationResponse } export type PostWorkspacesCurrentPluginUpgradeGithubResponse @@ -2284,9 +2724,7 @@ export type PostWorkspacesCurrentPluginUpgradeMarketplaceData = { } export type PostWorkspacesCurrentPluginUpgradeMarketplaceResponses = { - 200: { - [key: string]: unknown - } + 200: PluginDaemonOperationResponse } export type PostWorkspacesCurrentPluginUpgradeMarketplaceResponse @@ -2300,9 +2738,7 @@ export type PostWorkspacesCurrentPluginUploadBundleData = { } export type PostWorkspacesCurrentPluginUploadBundleResponses = { - 200: { - [key: string]: unknown - } + 200: PluginDaemonOperationResponse } export type PostWorkspacesCurrentPluginUploadBundleResponse @@ -2316,9 +2752,7 @@ export type PostWorkspacesCurrentPluginUploadGithubData = { } export type PostWorkspacesCurrentPluginUploadGithubResponses = { - 200: { - [key: string]: unknown - } + 200: PluginDaemonOperationResponse } export type PostWorkspacesCurrentPluginUploadGithubResponse @@ -2332,14 +2766,31 @@ export type PostWorkspacesCurrentPluginUploadPkgData = { } export type PostWorkspacesCurrentPluginUploadPkgResponses = { - 200: { - [key: string]: unknown - } + 200: PluginDaemonOperationResponse } export type PostWorkspacesCurrentPluginUploadPkgResponse = PostWorkspacesCurrentPluginUploadPkgResponses[keyof PostWorkspacesCurrentPluginUploadPkgResponses] +export type GetWorkspacesCurrentPluginByCategoryListData = { + body?: never + path: { + category: string + } + query?: { + page?: number + page_size?: number + } + url: '/workspaces/current/plugin/{category}/list' +} + +export type GetWorkspacesCurrentPluginByCategoryListResponses = { + 200: PluginCategoryListResponse +} + +export type GetWorkspacesCurrentPluginByCategoryListResponse + = GetWorkspacesCurrentPluginByCategoryListResponses[keyof GetWorkspacesCurrentPluginByCategoryListResponses] + export type GetWorkspacesCurrentToolLabelsData = { body?: never path?: never @@ -2348,9 +2799,7 @@ export type GetWorkspacesCurrentToolLabelsData = { } export type GetWorkspacesCurrentToolLabelsResponses = { - 200: { - [key: string]: unknown - } + 200: ToolProviderOpaqueResponse } export type GetWorkspacesCurrentToolLabelsResponse @@ -2364,9 +2813,7 @@ export type PostWorkspacesCurrentToolProviderApiAddData = { } export type PostWorkspacesCurrentToolProviderApiAddResponses = { - 200: { - [key: string]: unknown - } + 200: ToolProviderOpaqueResponse } export type PostWorkspacesCurrentToolProviderApiAddResponse @@ -2380,9 +2827,7 @@ export type PostWorkspacesCurrentToolProviderApiDeleteData = { } export type PostWorkspacesCurrentToolProviderApiDeleteResponses = { - 200: { - [key: string]: unknown - } + 200: ToolProviderOpaqueResponse } export type PostWorkspacesCurrentToolProviderApiDeleteResponse @@ -2391,14 +2836,14 @@ export type PostWorkspacesCurrentToolProviderApiDeleteResponse export type GetWorkspacesCurrentToolProviderApiGetData = { body?: never path?: never - query?: never + query: { + provider: string + } url: '/workspaces/current/tool-provider/api/get' } export type GetWorkspacesCurrentToolProviderApiGetResponses = { - 200: { - [key: string]: unknown - } + 200: ToolProviderOpaqueResponse } export type GetWorkspacesCurrentToolProviderApiGetResponse @@ -2407,14 +2852,14 @@ export type GetWorkspacesCurrentToolProviderApiGetResponse export type GetWorkspacesCurrentToolProviderApiRemoteData = { body?: never path?: never - query?: never + query: { + url: string + } url: '/workspaces/current/tool-provider/api/remote' } export type GetWorkspacesCurrentToolProviderApiRemoteResponses = { - 200: { - [key: string]: unknown - } + 200: ToolProviderOpaqueResponse } export type GetWorkspacesCurrentToolProviderApiRemoteResponse @@ -2428,9 +2873,7 @@ export type PostWorkspacesCurrentToolProviderApiSchemaData = { } export type PostWorkspacesCurrentToolProviderApiSchemaResponses = { - 200: { - [key: string]: unknown - } + 200: ToolProviderOpaqueResponse } export type PostWorkspacesCurrentToolProviderApiSchemaResponse @@ -2444,9 +2887,7 @@ export type PostWorkspacesCurrentToolProviderApiTestPreData = { } export type PostWorkspacesCurrentToolProviderApiTestPreResponses = { - 200: { - [key: string]: unknown - } + 200: ToolProviderOpaqueResponse } export type PostWorkspacesCurrentToolProviderApiTestPreResponse @@ -2455,14 +2896,14 @@ export type PostWorkspacesCurrentToolProviderApiTestPreResponse export type GetWorkspacesCurrentToolProviderApiToolsData = { body?: never path?: never - query?: never + query: { + provider: string + } url: '/workspaces/current/tool-provider/api/tools' } export type GetWorkspacesCurrentToolProviderApiToolsResponses = { - 200: { - [key: string]: unknown - } + 200: ToolProviderOpaqueResponse } export type GetWorkspacesCurrentToolProviderApiToolsResponse @@ -2476,9 +2917,7 @@ export type PostWorkspacesCurrentToolProviderApiUpdateData = { } export type PostWorkspacesCurrentToolProviderApiUpdateResponses = { - 200: { - [key: string]: unknown - } + 200: ToolProviderOpaqueResponse } export type PostWorkspacesCurrentToolProviderApiUpdateResponse @@ -2494,9 +2933,7 @@ export type PostWorkspacesCurrentToolProviderBuiltinByProviderAddData = { } export type PostWorkspacesCurrentToolProviderBuiltinByProviderAddResponses = { - 200: { - [key: string]: unknown - } + 200: ToolProviderOpaqueResponse } export type PostWorkspacesCurrentToolProviderBuiltinByProviderAddResponse @@ -2507,14 +2944,14 @@ export type GetWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfoData path: { provider: string } - query?: never + query?: { + include_credential_ids?: Array + } url: '/workspaces/current/tool-provider/builtin/{provider}/credential/info' } export type GetWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfoResponses = { - 200: { - [key: string]: unknown - } + 200: ToolProviderOpaqueResponse } export type GetWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfoResponse @@ -2533,9 +2970,7 @@ export type GetWorkspacesCurrentToolProviderBuiltinByProviderCredentialSchemaByC export type GetWorkspacesCurrentToolProviderBuiltinByProviderCredentialSchemaByCredentialTypeResponses = { - 200: { - [key: string]: unknown - } + 200: ToolProviderOpaqueResponse } export type GetWorkspacesCurrentToolProviderBuiltinByProviderCredentialSchemaByCredentialTypeResponse @@ -2546,14 +2981,14 @@ export type GetWorkspacesCurrentToolProviderBuiltinByProviderCredentialsData = { path: { provider: string } - query?: never + query?: { + include_credential_ids?: Array + } url: '/workspaces/current/tool-provider/builtin/{provider}/credentials' } export type GetWorkspacesCurrentToolProviderBuiltinByProviderCredentialsResponses = { - 200: { - [key: string]: unknown - } + 200: ToolProviderOpaqueResponse } export type GetWorkspacesCurrentToolProviderBuiltinByProviderCredentialsResponse @@ -2569,9 +3004,7 @@ export type PostWorkspacesCurrentToolProviderBuiltinByProviderDefaultCredentialD } export type PostWorkspacesCurrentToolProviderBuiltinByProviderDefaultCredentialResponses = { - 200: { - [key: string]: unknown - } + 200: ToolProviderOpaqueResponse } export type PostWorkspacesCurrentToolProviderBuiltinByProviderDefaultCredentialResponse @@ -2587,9 +3020,7 @@ export type PostWorkspacesCurrentToolProviderBuiltinByProviderDeleteData = { } export type PostWorkspacesCurrentToolProviderBuiltinByProviderDeleteResponses = { - 200: { - [key: string]: unknown - } + 200: ToolProviderOpaqueResponse } export type PostWorkspacesCurrentToolProviderBuiltinByProviderDeleteResponse @@ -2605,9 +3036,7 @@ export type GetWorkspacesCurrentToolProviderBuiltinByProviderIconData = { } export type GetWorkspacesCurrentToolProviderBuiltinByProviderIconResponses = { - 200: { - [key: string]: unknown - } + 200: BinaryFileResponse } export type GetWorkspacesCurrentToolProviderBuiltinByProviderIconResponse @@ -2623,9 +3052,7 @@ export type GetWorkspacesCurrentToolProviderBuiltinByProviderInfoData = { } export type GetWorkspacesCurrentToolProviderBuiltinByProviderInfoResponses = { - 200: { - [key: string]: unknown - } + 200: ToolProviderOpaqueResponse } export type GetWorkspacesCurrentToolProviderBuiltinByProviderInfoResponse @@ -2641,9 +3068,7 @@ export type GetWorkspacesCurrentToolProviderBuiltinByProviderOauthClientSchemaDa } export type GetWorkspacesCurrentToolProviderBuiltinByProviderOauthClientSchemaResponses = { - 200: { - [key: string]: unknown - } + 200: ToolOAuthClientSchemaResponse } export type GetWorkspacesCurrentToolProviderBuiltinByProviderOauthClientSchemaResponse @@ -2659,9 +3084,7 @@ export type DeleteWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClien } export type DeleteWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponses = { - 200: { - [key: string]: unknown - } + 200: SimpleResultResponse } export type DeleteWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse @@ -2677,9 +3100,7 @@ export type GetWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientDa } export type GetWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponses = { - 200: { - [key: string]: unknown - } + 200: ToolOAuthCustomClientResponse } export type GetWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse @@ -2695,9 +3116,7 @@ export type PostWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientD } export type PostWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponses = { - 200: { - [key: string]: unknown - } + 200: SimpleResultResponse } export type PostWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse @@ -2713,9 +3132,7 @@ export type GetWorkspacesCurrentToolProviderBuiltinByProviderToolsData = { } export type GetWorkspacesCurrentToolProviderBuiltinByProviderToolsResponses = { - 200: { - [key: string]: unknown - } + 200: ToolProviderOpaqueResponse } export type GetWorkspacesCurrentToolProviderBuiltinByProviderToolsResponse @@ -2731,9 +3148,7 @@ export type PostWorkspacesCurrentToolProviderBuiltinByProviderUpdateData = { } export type PostWorkspacesCurrentToolProviderBuiltinByProviderUpdateResponses = { - 200: { - [key: string]: unknown - } + 200: ToolProviderOpaqueResponse } export type PostWorkspacesCurrentToolProviderBuiltinByProviderUpdateResponse @@ -2761,9 +3176,7 @@ export type PostWorkspacesCurrentToolProviderMcpData = { } export type PostWorkspacesCurrentToolProviderMcpResponses = { - 200: { - [key: string]: unknown - } + 200: ToolProviderOpaqueResponse } export type PostWorkspacesCurrentToolProviderMcpResponse @@ -2777,9 +3190,7 @@ export type PutWorkspacesCurrentToolProviderMcpData = { } export type PutWorkspacesCurrentToolProviderMcpResponses = { - 200: { - [key: string]: unknown - } + 200: SimpleResultResponse } export type PutWorkspacesCurrentToolProviderMcpResponse @@ -2793,9 +3204,7 @@ export type PostWorkspacesCurrentToolProviderMcpAuthData = { } export type PostWorkspacesCurrentToolProviderMcpAuthResponses = { - 200: { - [key: string]: unknown - } + 200: ToolProviderOpaqueResponse } export type PostWorkspacesCurrentToolProviderMcpAuthResponse @@ -2811,9 +3220,7 @@ export type GetWorkspacesCurrentToolProviderMcpToolsByProviderIdData = { } export type GetWorkspacesCurrentToolProviderMcpToolsByProviderIdResponses = { - 200: { - [key: string]: unknown - } + 200: ToolProviderOpaqueResponse } export type GetWorkspacesCurrentToolProviderMcpToolsByProviderIdResponse @@ -2829,9 +3236,7 @@ export type GetWorkspacesCurrentToolProviderMcpUpdateByProviderIdData = { } export type GetWorkspacesCurrentToolProviderMcpUpdateByProviderIdResponses = { - 200: { - [key: string]: unknown - } + 200: ToolProviderOpaqueResponse } export type GetWorkspacesCurrentToolProviderMcpUpdateByProviderIdResponse @@ -2845,9 +3250,7 @@ export type PostWorkspacesCurrentToolProviderWorkflowCreateData = { } export type PostWorkspacesCurrentToolProviderWorkflowCreateResponses = { - 200: { - [key: string]: unknown - } + 200: ToolProviderOpaqueResponse } export type PostWorkspacesCurrentToolProviderWorkflowCreateResponse @@ -2861,9 +3264,7 @@ export type PostWorkspacesCurrentToolProviderWorkflowDeleteData = { } export type PostWorkspacesCurrentToolProviderWorkflowDeleteResponses = { - 200: { - [key: string]: unknown - } + 200: ToolProviderOpaqueResponse } export type PostWorkspacesCurrentToolProviderWorkflowDeleteResponse @@ -2872,14 +3273,15 @@ export type PostWorkspacesCurrentToolProviderWorkflowDeleteResponse export type GetWorkspacesCurrentToolProviderWorkflowGetData = { body?: never path?: never - query?: never + query?: { + workflow_app_id?: string + workflow_tool_id?: string + } url: '/workspaces/current/tool-provider/workflow/get' } export type GetWorkspacesCurrentToolProviderWorkflowGetResponses = { - 200: { - [key: string]: unknown - } + 200: ToolProviderOpaqueResponse } export type GetWorkspacesCurrentToolProviderWorkflowGetResponse @@ -2888,14 +3290,14 @@ export type GetWorkspacesCurrentToolProviderWorkflowGetResponse export type GetWorkspacesCurrentToolProviderWorkflowToolsData = { body?: never path?: never - query?: never + query: { + workflow_tool_id: string + } url: '/workspaces/current/tool-provider/workflow/tools' } export type GetWorkspacesCurrentToolProviderWorkflowToolsResponses = { - 200: { - [key: string]: unknown - } + 200: ToolProviderOpaqueResponse } export type GetWorkspacesCurrentToolProviderWorkflowToolsResponse @@ -2909,9 +3311,7 @@ export type PostWorkspacesCurrentToolProviderWorkflowUpdateData = { } export type PostWorkspacesCurrentToolProviderWorkflowUpdateResponses = { - 200: { - [key: string]: unknown - } + 200: ToolProviderOpaqueResponse } export type PostWorkspacesCurrentToolProviderWorkflowUpdateResponse @@ -2920,14 +3320,14 @@ export type PostWorkspacesCurrentToolProviderWorkflowUpdateResponse export type GetWorkspacesCurrentToolProvidersData = { body?: never path?: never - query?: never + query?: { + type?: 'api' | 'builtin' | 'mcp' | 'model' | 'workflow' + } url: '/workspaces/current/tool-providers' } export type GetWorkspacesCurrentToolProvidersResponses = { - 200: { - [key: string]: unknown - } + 200: ToolProviderOpaqueResponse } export type GetWorkspacesCurrentToolProvidersResponse @@ -2941,9 +3341,7 @@ export type GetWorkspacesCurrentToolsApiData = { } export type GetWorkspacesCurrentToolsApiResponses = { - 200: { - [key: string]: unknown - } + 200: ToolProviderOpaqueResponse } export type GetWorkspacesCurrentToolsApiResponse @@ -2957,9 +3355,7 @@ export type GetWorkspacesCurrentToolsBuiltinData = { } export type GetWorkspacesCurrentToolsBuiltinResponses = { - 200: { - [key: string]: unknown - } + 200: ToolProviderOpaqueResponse } export type GetWorkspacesCurrentToolsBuiltinResponse @@ -2973,9 +3369,7 @@ export type GetWorkspacesCurrentToolsMcpData = { } export type GetWorkspacesCurrentToolsMcpResponses = { - 200: { - [key: string]: unknown - } + 200: ToolProviderOpaqueResponse } export type GetWorkspacesCurrentToolsMcpResponse @@ -2989,9 +3383,7 @@ export type GetWorkspacesCurrentToolsWorkflowData = { } export type GetWorkspacesCurrentToolsWorkflowResponses = { - 200: { - [key: string]: unknown - } + 200: ToolProviderOpaqueResponse } export type GetWorkspacesCurrentToolsWorkflowResponse @@ -3007,9 +3399,7 @@ export type GetWorkspacesCurrentTriggerProviderByProviderIconData = { } export type GetWorkspacesCurrentTriggerProviderByProviderIconResponses = { - 200: { - [key: string]: unknown - } + 200: BinaryFileResponse } export type GetWorkspacesCurrentTriggerProviderByProviderIconResponse @@ -3025,9 +3415,7 @@ export type GetWorkspacesCurrentTriggerProviderByProviderInfoData = { } export type GetWorkspacesCurrentTriggerProviderByProviderInfoResponses = { - 200: { - [key: string]: unknown - } + 200: TriggerProviderOpaqueResponse } export type GetWorkspacesCurrentTriggerProviderByProviderInfoResponse @@ -3043,9 +3431,7 @@ export type DeleteWorkspacesCurrentTriggerProviderByProviderOauthClientData = { } export type DeleteWorkspacesCurrentTriggerProviderByProviderOauthClientResponses = { - 200: { - [key: string]: unknown - } + 200: SimpleResultResponse } export type DeleteWorkspacesCurrentTriggerProviderByProviderOauthClientResponse @@ -3061,9 +3447,7 @@ export type GetWorkspacesCurrentTriggerProviderByProviderOauthClientData = { } export type GetWorkspacesCurrentTriggerProviderByProviderOauthClientResponses = { - 200: { - [key: string]: unknown - } + 200: TriggerOAuthClientResponse } export type GetWorkspacesCurrentTriggerProviderByProviderOauthClientResponse @@ -3079,9 +3463,7 @@ export type PostWorkspacesCurrentTriggerProviderByProviderOauthClientData = { } export type PostWorkspacesCurrentTriggerProviderByProviderOauthClientResponses = { - 200: { - [key: string]: unknown - } + 200: SimpleResultResponse } export type PostWorkspacesCurrentTriggerProviderByProviderOauthClientResponse @@ -3100,9 +3482,7 @@ export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBu export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBuildBySubscriptionBuilderIdResponses = { - 200: { - [key: string]: unknown - } + 200: TriggerProviderOpaqueResponse } export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBuildBySubscriptionBuilderIdResponse @@ -3118,9 +3498,7 @@ export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderCr } export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderCreateResponses = { - 200: { - [key: string]: unknown - } + 200: TriggerProviderOpaqueResponse } export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderCreateResponse @@ -3139,9 +3517,7 @@ export type GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderLog export type GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderLogsBySubscriptionBuilderIdResponses = { - 200: { - [key: string]: unknown - } + 200: TriggerProviderOpaqueResponse } export type GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderLogsBySubscriptionBuilderIdResponse @@ -3160,9 +3536,7 @@ export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderUp export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderUpdateBySubscriptionBuilderIdResponses = { - 200: { - [key: string]: unknown - } + 200: TriggerProviderOpaqueResponse } export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderUpdateBySubscriptionBuilderIdResponse @@ -3181,9 +3555,7 @@ export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderVe export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderVerifyAndUpdateBySubscriptionBuilderIdResponses = { - 200: { - [key: string]: unknown - } + 200: TriggerProviderOpaqueResponse } export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderVerifyAndUpdateBySubscriptionBuilderIdResponse @@ -3202,9 +3574,7 @@ export type GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderByS export type GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBySubscriptionBuilderIdResponses = { - 200: { - [key: string]: unknown - } + 200: TriggerProviderOpaqueResponse } export type GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBySubscriptionBuilderIdResponse @@ -3220,9 +3590,7 @@ export type GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListData = } export type GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListResponses = { - 200: { - [key: string]: unknown - } + 200: TriggerProviderOpaqueResponse } export type GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListResponse @@ -3238,9 +3606,7 @@ export type GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsOauthAutho } export type GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsOauthAuthorizeResponses = { - 200: { - [key: string]: unknown - } + 200: TriggerOAuthAuthorizeResponse } export type GetWorkspacesCurrentTriggerProviderByProviderSubscriptionsOauthAuthorizeResponse @@ -3259,9 +3625,7 @@ export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsVerifyByS export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsVerifyBySubscriptionIdResponses = { - 200: { - [key: string]: unknown - } + 200: TriggerProviderOpaqueResponse } export type PostWorkspacesCurrentTriggerProviderByProviderSubscriptionsVerifyBySubscriptionIdResponse @@ -3293,9 +3657,7 @@ export type PostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsUpd } export type PostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsUpdateResponses = { - 200: { - [key: string]: unknown - } + 200: TriggerProviderOpaqueResponse } export type PostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsUpdateResponse @@ -3309,9 +3671,7 @@ export type GetWorkspacesCurrentTriggersData = { } export type GetWorkspacesCurrentTriggersResponses = { - 200: { - [key: string]: unknown - } + 200: TriggerProviderOpaqueResponse } export type GetWorkspacesCurrentTriggersResponse @@ -3325,9 +3685,7 @@ export type PostWorkspacesCustomConfigData = { } export type PostWorkspacesCustomConfigResponses = { - 200: { - [key: string]: unknown - } + 200: WorkspaceMutationResponse } export type PostWorkspacesCustomConfigResponse @@ -3341,9 +3699,7 @@ export type PostWorkspacesCustomConfigWebappLogoUploadData = { } export type PostWorkspacesCustomConfigWebappLogoUploadResponses = { - 200: { - [key: string]: unknown - } + 201: WorkspaceLogoUploadResponse } export type PostWorkspacesCustomConfigWebappLogoUploadResponse @@ -3357,9 +3713,7 @@ export type PostWorkspacesInfoData = { } export type PostWorkspacesInfoResponses = { - 200: { - [key: string]: unknown - } + 200: WorkspaceMutationResponse } export type PostWorkspacesInfoResponse @@ -3373,9 +3727,7 @@ export type PostWorkspacesSwitchData = { } export type PostWorkspacesSwitchResponses = { - 200: { - [key: string]: unknown - } + 200: SwitchWorkspaceResponse } export type PostWorkspacesSwitchResponse @@ -3394,9 +3746,7 @@ export type GetWorkspacesByTenantIdModelProvidersByProviderByIconTypeByLangData } export type GetWorkspacesByTenantIdModelProvidersByProviderByIconTypeByLangResponses = { - 200: { - [key: string]: unknown - } + 200: BinaryFileResponse } export type GetWorkspacesByTenantIdModelProvidersByProviderByIconTypeByLangResponse diff --git a/packages/contracts/generated/api/console/workspaces/zod.gen.ts b/packages/contracts/generated/api/console/workspaces/zod.gen.ts index 7818191daba..b342c586594 100644 --- a/packages/contracts/generated/api/console/workspaces/zod.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/zod.gen.ts @@ -2,6 +2,16 @@ import * as z from 'zod' +/** + * AgentProviderResponse + */ +export const zAgentProviderResponse = z.record(z.string(), z.unknown()) + +/** + * AgentProviderListResponse + */ +export const zAgentProviderListResponse = z.array(z.record(z.string(), z.unknown())) + /** * SnippetImportPayload * @@ -16,6 +26,29 @@ export const zSnippetImportPayload = z.object({ yaml_url: z.string().nullish(), }) +/** + * SnippetImportResponse + */ +export const zSnippetImportResponse = z.record(z.string(), z.unknown()) + +/** + * SnippetDependencyCheckResponse + */ +export const zSnippetDependencyCheckResponse = z.record(z.string(), z.unknown()) + +/** + * TextFileResponse + */ +export const zTextFileResponse = z.string() + +/** + * SnippetUseCountResponse + */ +export const zSnippetUseCountResponse = z.object({ + result: z.string(), + use_count: z.int(), +}) + /** * SimpleResultResponse */ @@ -137,6 +170,14 @@ export const zSimpleResultDataResponse = z.object({ result: z.string(), }) +/** + * MemberActionTenantResponse + */ +export const zMemberActionTenantResponse = z.object({ + result: z.string(), + tenant_id: z.string(), +}) + /** * OwnerTransferPayload */ @@ -151,6 +192,13 @@ export const zMemberRoleUpdatePayload = z.object({ role: z.string(), }) +/** + * ModelProviderPaymentCheckoutUrlResponse + */ +export const zModelProviderPaymentCheckoutUrlResponse = z.object({ + payment_link: z.string(), +}) + /** * ParserCredentialDelete */ @@ -158,6 +206,13 @@ export const zParserCredentialDelete = z.object({ credential_id: z.string(), }) +/** + * ProviderCredentialResponse + */ +export const zProviderCredentialResponse = z.object({ + credentials: z.record(z.string(), z.unknown()).nullish(), +}) + /** * ParserCredentialCreate */ @@ -189,6 +244,30 @@ export const zParserCredentialValidate = z.object({ credentials: z.record(z.string(), z.unknown()), }) +/** + * ProviderCredentialValidateResponse + */ +export const zProviderCredentialValidateResponse = z.object({ + error: z.string().nullish(), + result: z.enum(['error', 'success']), +}) + +/** + * ModelCredentialValidateResponse + */ +export const zModelCredentialValidateResponse = z.object({ + error: z.string().nullish(), + result: z.string(), +}) + +/** + * LoadBalancingCredentialValidateResponse + */ +export const zLoadBalancingCredentialValidateResponse = z.object({ + error: z.string().nullish(), + result: z.string(), +}) + /** * ParserPreferredProviderType */ @@ -205,6 +284,26 @@ export const zWorkspacePermissionResponse = z.object({ workspace_id: z.string(), }) +/** + * BinaryFileResponse + */ +export const zBinaryFileResponse = z.custom() + +/** + * PluginAutoUpgradeChangeResponse + */ +export const zPluginAutoUpgradeChangeResponse = z.object({ + message: z.string().nullish(), + success: z.boolean(), +}) + +/** + * SuccessResponse + */ +export const zSuccessResponse = z.object({ + success: z.boolean(), +}) + /** * PluginDebuggingKeyResponse */ @@ -214,6 +313,13 @@ export const zPluginDebuggingKeyResponse = z.object({ port: z.int(), }) +/** + * PluginManifestResponse + */ +export const zPluginManifestResponse = z.object({ + manifest: z.unknown(), +}) + /** * ParserGithubInstall */ @@ -224,6 +330,11 @@ export const zParserGithubInstall = z.object({ version: z.string(), }) +/** + * PluginDaemonOperationResponse + */ +export const zPluginDaemonOperationResponse = z.unknown() + /** * ParserPluginIdentifiers */ @@ -231,6 +342,14 @@ export const zParserPluginIdentifiers = z.object({ plugin_unique_identifiers: z.array(z.string()), }) +/** + * PluginListResponse + */ +export const zPluginListResponse = z.object({ + plugins: z.unknown(), + total: z.int(), +}) + /** * ParserLatest */ @@ -238,6 +357,27 @@ export const zParserLatest = z.object({ plugin_ids: z.array(z.string()), }) +/** + * PluginInstallationsResponse + */ +export const zPluginInstallationsResponse = z.object({ + plugins: z.unknown(), +}) + +/** + * PluginVersionsResponse + */ +export const zPluginVersionsResponse = z.object({ + versions: z.unknown(), +}) + +/** + * PluginDynamicOptionsResponse + */ +export const zPluginDynamicOptionsResponse = z.object({ + options: z.unknown(), +}) + /** * ParserDynamicOptionsWithCredentials */ @@ -251,17 +391,24 @@ export const zParserDynamicOptionsWithCredentials = z.object({ }) /** - * SuccessResponse + * PluginReadmeResponse */ -export const zSuccessResponse = z.object({ - success: z.boolean(), +export const zPluginReadmeResponse = z.object({ + readme: z.string(), }) /** - * ParserExcludePlugin + * PluginTasksResponse */ -export const zParserExcludePlugin = z.object({ - plugin_id: z.string(), +export const zPluginTasksResponse = z.object({ + tasks: z.unknown(), +}) + +/** + * PluginTaskResponse + */ +export const zPluginTaskResponse = z.object({ + task: z.unknown(), }) /** @@ -299,6 +446,11 @@ export const zParserGithubUpload = z.object({ version: z.string(), }) +/** + * ToolProviderOpaqueResponse + */ +export const zToolProviderOpaqueResponse = z.unknown() + /** * ApiToolProviderDeletePayload */ @@ -327,6 +479,16 @@ export const zBuiltinToolCredentialDeletePayload = z.object({ credential_id: z.string(), }) +/** + * ToolOAuthClientSchemaResponse + */ +export const zToolOAuthClientSchemaResponse = z.array(z.record(z.string(), z.unknown())) + +/** + * ToolOAuthCustomClientResponse + */ +export const zToolOAuthCustomClientResponse = z.record(z.string(), z.unknown()) + /** * ToolOAuthCustomClientPayload */ @@ -366,6 +528,24 @@ export const zWorkflowToolDeletePayload = z.object({ workflow_tool_id: z.string(), }) +/** + * TriggerProviderOpaqueResponse + */ +export const zTriggerProviderOpaqueResponse = z.unknown() + +/** + * TriggerOAuthClientResponse + */ +export const zTriggerOAuthClientResponse = z.object({ + configured: z.boolean(), + custom_configured: z.boolean(), + custom_enabled: z.boolean(), + oauth_client_schema: z.unknown(), + params: z.record(z.string(), z.unknown()), + redirect_uri: z.string(), + system_configured: z.boolean(), +}) + /** * TriggerOAuthClientPayload */ @@ -398,6 +578,15 @@ export const zTriggerSubscriptionBuilderVerifyPayload = z.object({ credentials: z.record(z.string(), z.unknown()), }) +/** + * TriggerOAuthAuthorizeResponse + */ +export const zTriggerOAuthAuthorizeResponse = z.object({ + authorization_url: z.string(), + subscription_builder: z.unknown(), + subscription_builder_id: z.string(), +}) + /** * WorkspaceCustomConfigPayload */ @@ -406,6 +595,13 @@ export const zWorkspaceCustomConfigPayload = z.object({ replace_webapp_logo: z.string().nullish(), }) +/** + * WorkspaceLogoUploadResponse + */ +export const zWorkspaceLogoUploadResponse = z.object({ + id: z.string(), +}) + /** * WorkspaceInfoPayload */ @@ -420,6 +616,25 @@ export const zSwitchWorkspacePayload = z.object({ tenant_id: z.string(), }) +/** + * TenantListItemResponse + */ +export const zTenantListItemResponse = z.object({ + created_at: z.int().nullish(), + current: z.boolean(), + id: z.string(), + name: z.string().nullish(), + plan: z.string().nullish(), + status: z.string().nullish(), +}) + +/** + * TenantListResponse + */ +export const zTenantListResponse = z.object({ + workspaces: z.array(zTenantListItemResponse), +}) + /** * WorkspaceCustomConfigResponse */ @@ -433,7 +648,7 @@ export const zWorkspaceCustomConfigResponse = z.object({ */ export const zTenantInfoResponse = z.object({ created_at: z.int().nullish(), - custom_config: zWorkspaceCustomConfigResponse.optional(), + custom_config: zWorkspaceCustomConfigResponse.nullish(), id: z.string(), in_trial: z.boolean().nullish(), name: z.string().nullish(), @@ -446,6 +661,22 @@ export const zTenantInfoResponse = z.object({ trial_end_reason: z.string().nullish(), }) +/** + * WorkspaceMutationResponse + */ +export const zWorkspaceMutationResponse = z.object({ + result: z.string(), + tenant: zTenantInfoResponse, +}) + +/** + * SwitchWorkspaceResponse + */ +export const zSwitchWorkspaceResponse = z.object({ + new_tenant: zTenantInfoResponse, + result: z.string(), +}) + /** * IconInfo * @@ -465,7 +696,7 @@ export const zIconInfo = z.object({ */ export const zUpdateSnippetPayload = z.object({ description: z.string().max(2000).nullish(), - icon_info: zIconInfo.optional(), + icon_info: zIconInfo.nullish(), name: z.string().min(1).max(255).nullish(), }) @@ -493,7 +724,7 @@ export const zInputFieldDefinition = z.object({ export const zCreateSnippetPayload = z.object({ description: z.string().max(2000).nullish(), graph: z.record(z.string(), z.unknown()).nullish(), - icon_info: zIconInfo.optional(), + icon_info: zIconInfo.nullish(), input_fields: z.array(zInputFieldDefinition).nullish(), name: z.string().min(1).max(255), type: z.enum(['group', 'node']).optional().default('node'), @@ -512,7 +743,15 @@ export const zAnonymousInlineModel7B8B49Ca164e = z.object({ }) export const zSnippet = z.object({ - created_at: z.record(z.string(), z.unknown()).optional(), + created_at: z.coerce + .bigint() + .min(BigInt('-9223372036854775808'), { + error: 'Invalid value: Expected int64 to be >= -9223372036854775808', + }) + .max(BigInt('9223372036854775807'), { + error: 'Invalid value: Expected int64 to be <= 9223372036854775807', + }) + .optional(), created_by: zAnonymousInlineModelB0Fd3F86D9D5.optional(), description: z.string().optional(), graph: z.record(z.string(), z.unknown()).optional(), @@ -523,15 +762,31 @@ export const zSnippet = z.object({ name: z.string().optional(), tags: z.array(zAnonymousInlineModel7B8B49Ca164e).optional(), type: z.string().optional(), - updated_at: z.record(z.string(), z.unknown()).optional(), + updated_at: z.coerce + .bigint() + .min(BigInt('-9223372036854775808'), { + error: 'Invalid value: Expected int64 to be >= -9223372036854775808', + }) + .max(BigInt('9223372036854775807'), { + error: 'Invalid value: Expected int64 to be <= 9223372036854775807', + }) + .optional(), updated_by: zAnonymousInlineModelB0Fd3F86D9D5.optional(), use_count: z.int().optional(), version: z.int().optional(), }) -export const zAnonymousInlineModel7B67Ac8A4Db8 = z.object({ +export const zAnonymousInlineModelEfd591151Ea9 = z.object({ author_name: z.string().optional(), - created_at: z.record(z.string(), z.unknown()).optional(), + created_at: z.coerce + .bigint() + .min(BigInt('-9223372036854775808'), { + error: 'Invalid value: Expected int64 to be >= -9223372036854775808', + }) + .max(BigInt('9223372036854775807'), { + error: 'Invalid value: Expected int64 to be <= 9223372036854775807', + }) + .optional(), created_by: z.string().optional(), description: z.string().optional(), icon_info: z.record(z.string(), z.unknown()).optional(), @@ -540,14 +795,22 @@ export const zAnonymousInlineModel7B67Ac8A4Db8 = z.object({ name: z.string().optional(), tags: z.array(zAnonymousInlineModel7B8B49Ca164e).optional(), type: z.string().optional(), - updated_at: z.record(z.string(), z.unknown()).optional(), + updated_at: z.coerce + .bigint() + .min(BigInt('-9223372036854775808'), { + error: 'Invalid value: Expected int64 to be >= -9223372036854775808', + }) + .max(BigInt('9223372036854775807'), { + error: 'Invalid value: Expected int64 to be <= 9223372036854775807', + }) + .optional(), updated_by: z.string().optional(), use_count: z.int().optional(), version: z.int().optional(), }) export const zSnippetPagination = z.object({ - data: z.array(zAnonymousInlineModel7B67Ac8A4Db8).optional(), + data: z.array(zAnonymousInlineModelEfd591151Ea9).optional(), has_more: z.boolean().optional(), limit: z.int().optional(), page: z.int().optional(), @@ -590,6 +853,25 @@ export const zMemberInvitePayload = z.object({ role: zTenantAccountRole, }) +/** + * MemberInviteResultResponse + */ +export const zMemberInviteResultResponse = z.object({ + email: z.string(), + message: z.string().nullish(), + status: z.string(), + url: z.string().nullish(), +}) + +/** + * MemberInviteResponse + */ +export const zMemberInviteResponse = z.object({ + invitation_results: z.array(zMemberInviteResultResponse), + result: z.string(), + tenant_id: z.string(), +}) + /** * ModelType * @@ -699,11 +981,60 @@ export const zLoadBalancingPayload = z.object({ export const zParserPostModels = z.object({ config_from: z.string().nullish(), credential_id: z.string().nullish(), - load_balancing: zLoadBalancingPayload.optional(), + load_balancing: zLoadBalancingPayload.nullish(), model: z.string(), model_type: zModelType, }) +/** + * CredentialConfiguration + * + * Model class for credential configuration. + */ +export const zCredentialConfiguration = z.object({ + credential_id: z.string(), + credential_name: z.string(), +}) + +/** + * ModelCredentialLoadBalancingResponse + */ +export const zModelCredentialLoadBalancingResponse = z.object({ + configs: z.array(z.record(z.string(), z.unknown())).optional(), + enabled: z.boolean(), +}) + +/** + * ModelCredentialResponse + */ +export const zModelCredentialResponse = z.object({ + available_credentials: z.array(zCredentialConfiguration), + credentials: z.record(z.string(), z.unknown()).optional(), + current_credential_id: z.string().nullish(), + current_credential_name: z.string().nullish(), + load_balancing: zModelCredentialLoadBalancingResponse, +}) + +/** + * PluginCategory + */ +export const zPluginCategory = z.enum([ + 'agent-strategy', + 'datasource', + 'extension', + 'model', + 'tool', + 'trigger', +]) + +/** + * ParserExcludePlugin + */ +export const zParserExcludePlugin = z.object({ + category: zPluginCategory, + plugin_id: z.string(), +}) + /** * DebugPermission */ @@ -718,16 +1049,16 @@ export const zInstallPermission = z.enum(['admins', 'everyone', 'noone']) * ParserPermissionChange */ export const zParserPermissionChange = z.object({ - debug_permission: zDebugPermission, - install_permission: zInstallPermission, + debug_permission: zDebugPermission.optional().default('everyone'), + install_permission: zInstallPermission.optional().default('everyone'), }) /** - * PluginPermissionSettingsPayload + * PluginPermissionResponse */ -export const zPluginPermissionSettingsPayload = z.object({ - debug_permission: zDebugPermission.optional(), - install_permission: zInstallPermission.optional(), +export const zPluginPermissionResponse = z.object({ + debug_permission: zDebugPermission, + install_permission: zInstallPermission, }) /** @@ -815,7 +1146,7 @@ export const zMcpProviderCreatePayload = z.object({ icon: z.string(), icon_background: z.string().optional().default(''), icon_type: z.string(), - identity_mode: zIdentityMode.optional(), + identity_mode: zIdentityMode.nullish(), name: z.string(), server_identifier: z.string(), server_url: z.string(), @@ -831,13 +1162,195 @@ export const zMcpProviderUpdatePayload = z.object({ icon: z.string(), icon_background: z.string().optional().default(''), icon_type: z.string(), - identity_mode: zIdentityMode.optional(), + identity_mode: zIdentityMode.nullish(), name: z.string(), provider_id: z.string(), server_identifier: z.string(), server_url: z.string(), }) +/** + * ConfigurateMethod + * + * Enum class for configurate method of provider model. + */ +export const zConfigurateMethod = z.enum(['customizable-model', 'predefined-model']) + +/** + * I18nObject + * + * Model class for i18n object. + */ +export const zI18nObject = z.object({ + en_US: z.string(), + ja_JP: z.string().nullish(), + pt_BR: z.string().nullish(), + zh_Hans: z.string().nullish(), +}) + +/** + * ProviderType + */ +export const zProviderType = z.enum(['custom', 'system']) + +/** + * ModelFeature + * + * Enum class for llm feature. + */ +export const zModelFeature = z.enum([ + 'agent-thought', + 'audio', + 'document', + 'multi-tool-call', + 'polling', + 'stream-tool-call', + 'structured-output', + 'tool-call', + 'video', + 'vision', +]) + +/** + * FetchFrom + * + * Enum class for fetch from. + */ +export const zFetchFrom = z.enum(['customizable-model', 'predefined-model']) + +/** + * ModelPropertyKey + * + * Enum class for model property key. + */ +export const zModelPropertyKey = z.enum([ + 'audio_type', + 'context_size', + 'default_voice', + 'file_upload_limit', + 'max_characters_per_chunk', + 'max_chunks', + 'max_workers', + 'mode', + 'supported_file_extensions', + 'voices', + 'word_limit', +]) + +/** + * ModelStatus + * + * Enum class for model status. + */ +export const zModelStatus = z.enum([ + 'active', + 'credential-removed', + 'disabled', + 'no-configure', + 'no-permission', + 'quota-exceeded', +]) + +/** + * I18nObject + * + * Model class for i18n object. + */ +export const zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject = z.object({ + en_US: z.string(), + zh_Hans: z.string().nullish(), +}) + +/** + * ProviderHelpEntity + * + * Model class for provider help. + */ +export const zProviderHelpEntity = z.object({ + title: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject, + url: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject, +}) + +/** + * ParameterType + * + * Enum class for parameter type. + */ +export const zParameterType = z.enum(['boolean', 'float', 'int', 'string', 'text']) + +/** + * ParameterRule + * + * Model class for parameter rule. + */ +export const zParameterRule = z.object({ + default: z.unknown().nullish(), + help: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject.nullish(), + label: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject, + max: z.number().nullish(), + min: z.number().nullish(), + name: z.string(), + options: z.array(z.string()).optional().default([]), + precision: z.int().nullish(), + required: z.boolean().optional().default(false), + type: zParameterType, + use_template: z.string().nullish(), +}) + +/** + * ModelParameterRulesResponse + */ +export const zModelParameterRulesResponse = z.object({ + data: z.array(zParameterRule), +}) + +/** + * ProviderModelWithStatusEntity + * + * Model class for model response. + */ +export const zProviderModelWithStatusEntity = z.object({ + deprecated: z.boolean().optional().default(false), + features: z.array(zModelFeature).nullish(), + fetch_from: zFetchFrom, + has_invalid_load_balancing_configs: z.boolean().optional().default(false), + label: zI18nObject, + load_balancing_enabled: z.boolean().optional().default(false), + model: z.string(), + model_properties: z.record(z.string(), z.unknown()), + model_type: zModelType, + status: zModelStatus, +}) + +/** + * CustomConfigurationStatus + * + * Enum class for custom configuration status. + */ +export const zCustomConfigurationStatus = z.enum(['active', 'no-configure']) + +/** + * ProviderWithModelsResponse + * + * Model class for provider with models response. + */ +export const zProviderWithModelsResponse = z.object({ + icon_small: zI18nObject.nullish(), + icon_small_dark: zI18nObject.nullish(), + label: zI18nObject, + models: z.array(zProviderModelWithStatusEntity), + provider: z.string(), + status: zCustomConfigurationStatus, + tenant_id: z.string(), +}) + +/** + * ProviderWithModelsDataResponse + */ +export const zProviderWithModelsDataResponse = z.object({ + data: z.array(zProviderWithModelsResponse), +}) + /** * StrategySetting */ @@ -854,19 +1367,104 @@ export const zUpgradeMode = z.enum(['all', 'exclude', 'partial']) export const zPluginAutoUpgradeSettingsPayload = z.object({ exclude_plugins: z.array(z.string()).optional(), include_plugins: z.array(z.string()).optional(), - strategy_setting: zStrategySetting.optional(), - upgrade_mode: zUpgradeMode.optional(), + strategy_setting: zStrategySetting.optional().default('fix_only'), + upgrade_mode: zUpgradeMode.optional().default('exclude'), upgrade_time_of_day: z.int().optional().default(0), }) /** - * ParserPreferencesChange + * ParserAutoUpgradeChange */ -export const zParserPreferencesChange = z.object({ +export const zParserAutoUpgradeChange = z.object({ auto_upgrade: zPluginAutoUpgradeSettingsPayload, - permission: zPluginPermissionSettingsPayload, + category: zPluginCategory, }) +/** + * PluginAutoUpgradeSettingsResponseModel + */ +export const zPluginAutoUpgradeSettingsResponseModel = z.object({ + exclude_plugins: z.array(z.string()), + include_plugins: z.array(z.string()), + strategy_setting: zStrategySetting, + upgrade_mode: zUpgradeMode, + upgrade_time_of_day: z.int(), +}) + +/** + * PluginAutoUpgradeFetchResponse + */ +export const zPluginAutoUpgradeFetchResponse = z.object({ + auto_upgrade: zPluginAutoUpgradeSettingsResponseModel, + category: zPluginCategory, +}) + +/** + * I18nObject + * + * Model class for i18n object. + */ +export const zCoreToolsEntitiesCommonEntitiesI18nObject = z.object({ + en_US: z.string(), + ja_JP: z.string().nullish(), + pt_BR: z.string().nullish(), + zh_Hans: z.string().nullish(), +}) + +/** + * PluginCategoryBuiltinToolResponse + */ +export const zPluginCategoryBuiltinToolResponse = z.object({ + author: z.string(), + description: zCoreToolsEntitiesCommonEntitiesI18nObject, + label: zCoreToolsEntitiesCommonEntitiesI18nObject, + labels: z.array(z.string()), + name: z.string(), + output_schema: z.record(z.string(), z.unknown()), + parameters: z.array(z.record(z.string(), z.unknown())).nullish(), +}) + +/** + * ToolProviderType + * + * Enum class for tool provider + */ +export const zToolProviderType = z.enum([ + 'api', + 'app', + 'builtin', + 'dataset-retrieval', + 'mcp', + 'plugin', + 'workflow', +]) + +/** + * PluginCategoryBuiltinToolProviderResponse + */ +export const zPluginCategoryBuiltinToolProviderResponse = z.object({ + allow_delete: z.boolean(), + author: z.string(), + description: zCoreToolsEntitiesCommonEntitiesI18nObject, + icon: z.union([z.string(), z.record(z.string(), z.string())]), + icon_dark: z.union([z.string(), z.record(z.string(), z.string())]).nullable(), + id: z.string(), + is_team_authorization: z.boolean(), + label: zCoreToolsEntitiesCommonEntitiesI18nObject, + labels: z.array(z.string()), + name: z.string(), + plugin_id: z.string().nullable(), + plugin_unique_identifier: z.string().nullable(), + team_credentials: z.record(z.string(), z.unknown()), + tools: z.array(zPluginCategoryBuiltinToolResponse), + type: zToolProviderType, +}) + +/** + * PluginInstallationSource + */ +export const zPluginInstallationSource = z.enum(['github', 'marketplace', 'package', 'remote']) + /** * ToolParameterForm */ @@ -911,10 +1509,365 @@ export const zWorkflowToolUpdatePayload = z.object({ workflow_tool_id: z.string(), }) +/** + * UnaddedModelConfiguration + * + * Model class for provider unadded model configuration. + */ +export const zUnaddedModelConfiguration = z.object({ + model: z.string(), + model_type: zModelType, +}) + +/** + * CustomModelConfiguration + * + * Model class for provider custom model configuration. + */ +export const zCustomModelConfiguration = z.object({ + available_model_credentials: z.array(zCredentialConfiguration).optional().default([]), + credentials: z.record(z.string(), z.unknown()).nullable(), + current_credential_id: z.string().nullish(), + current_credential_name: z.string().nullish(), + model: z.string(), + model_type: zModelType, + unadded_to_model_list: z.boolean().nullish().default(false), +}) + +/** + * CustomConfigurationResponse + * + * Model class for provider custom configuration response. + */ +export const zCustomConfigurationResponse = z.object({ + available_credentials: z.array(zCredentialConfiguration).nullish(), + can_added_models: z.array(zUnaddedModelConfiguration).nullish(), + current_credential_id: z.string().nullish(), + current_credential_name: z.string().nullish(), + custom_models: z.array(zCustomModelConfiguration).nullish(), + status: zCustomConfigurationStatus, +}) + +/** + * FieldModelSchema + */ +export const zFieldModelSchema = z.object({ + label: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject, + placeholder: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject.nullish(), +}) + +/** + * ProviderQuotaType + */ +export const zProviderQuotaType = z.enum(['free', 'paid', 'trial']) + +/** + * PriceConfigResponse + * + * Serialized pricing info with codegen-safe decimal string patterns. + */ +export const zPriceConfigResponse = z.object({ + currency: z.string(), + input: z.string().regex(/^(?![-+.]*$)[+-]?\d*(?:\.\d*)?$/), + output: z + .string() + .regex(/^(?![-+.]*$)[+-]?\d*(?:\.\d*)?$/) + .nullish(), + unit: z.string().regex(/^(?![-+.]*$)[+-]?\d*(?:\.\d*)?$/), +}) + +/** + * AIModelEntityResponse + */ +export const zAiModelEntityResponse = z.object({ + deprecated: z.boolean().optional().default(false), + features: z.array(zModelFeature).nullish(), + fetch_from: zFetchFrom, + label: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject, + model: z.string(), + model_properties: z.record(z.string(), z.unknown()), + model_type: zModelType, + parameter_rules: z.array(zParameterRule).optional().default([]), + pricing: zPriceConfigResponse.nullish(), +}) + +/** + * SimpleProviderEntityResponse + * + * Simple provider entity response. + */ +export const zSimpleProviderEntityResponse = z.object({ + icon_small: zI18nObject.nullish(), + icon_small_dark: zI18nObject.nullish(), + label: zI18nObject, + models: z.array(zAiModelEntityResponse).optional().default([]), + provider: z.string(), + provider_name: z.string().optional().default(''), + supported_model_types: z.array(zModelType), + tenant_id: z.string(), +}) + +/** + * DefaultModelResponse + * + * Default model entity. + */ +export const zDefaultModelResponse = z.object({ + model: z.string(), + model_type: zModelType, + provider: zSimpleProviderEntityResponse, +}) + +/** + * DefaultModelDataResponse + */ +export const zDefaultModelDataResponse = z.object({ + data: zDefaultModelResponse.nullish(), +}) + +/** + * ModelWithProviderEntityResponse + * + * Model with provider entity. + */ +export const zModelWithProviderEntityResponse = z.object({ + deprecated: z.boolean().optional().default(false), + features: z.array(zModelFeature).nullish(), + fetch_from: zFetchFrom, + has_invalid_load_balancing_configs: z.boolean().optional().default(false), + label: zI18nObject, + load_balancing_enabled: z.boolean().optional().default(false), + model: z.string(), + model_properties: z.record(z.string(), z.unknown()), + model_type: zModelType, + provider: zSimpleProviderEntityResponse, + status: zModelStatus, +}) + +/** + * ModelWithProviderListResponse + */ +export const zModelWithProviderListResponse = z.object({ + data: z.array(zModelWithProviderEntityResponse), +}) + +/** + * FormShowOnObject + * + * Model class for form show on. + */ +export const zFormShowOnObject = z.object({ + value: z.string(), + variable: z.string(), +}) + +/** + * FormOption + * + * Model class for form option. + */ +export const zFormOption = z.object({ + label: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject, + show_on: z.array(zFormShowOnObject).optional().default([]), + value: z.string(), +}) + +/** + * FormType + * + * Enum class for form type. + */ +export const zFormType = z.enum(['radio', 'secret-input', 'select', 'switch', 'text-input']) + +/** + * CredentialFormSchema + * + * Model class for credential form schema. + */ +export const zCredentialFormSchema = z.object({ + default: z.string().nullish(), + label: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject, + max_length: z.int().optional().default(0), + options: z.array(zFormOption).nullish(), + placeholder: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject.nullish(), + required: z.boolean().optional().default(true), + show_on: z.array(zFormShowOnObject).optional().default([]), + type: zFormType, + variable: z.string(), +}) + +/** + * ModelCredentialSchema + * + * Model class for model credential schema. + */ +export const zModelCredentialSchema = z.object({ + credential_form_schemas: z.array(zCredentialFormSchema), + model: zFieldModelSchema, +}) + +/** + * ProviderCredentialSchema + * + * Model class for provider credential schema. + */ +export const zProviderCredentialSchema = z.object({ + credential_form_schemas: z.array(zCredentialFormSchema), +}) + +/** + * ProviderEntityResponse + * + * Runtime provider response with codegen-safe model pricing schemas. + */ +export const zProviderEntityResponse = z.object({ + background: z.string().nullish(), + configurate_methods: z.array(zConfigurateMethod), + description: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject.nullish(), + help: zProviderHelpEntity.nullish(), + icon_small: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject.nullish(), + icon_small_dark: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject.nullish(), + label: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject, + model_credential_schema: zModelCredentialSchema.nullish(), + models: z.array(zAiModelEntityResponse).optional().default([]), + position: z.record(z.string(), z.array(z.string())).nullish().default({}), + provider: z.string(), + provider_credential_schema: zProviderCredentialSchema.nullish(), + provider_name: z.string().optional().default(''), + supported_model_types: z.array(zModelType), +}) + +/** + * PluginDeclarationResponse + */ +export const zPluginDeclarationResponse = z.object({ + agent_strategy: z.record(z.string(), z.unknown()).nullish(), + author: z.string().nullable(), + category: zPluginCategory, + created_at: z.iso.datetime(), + datasource: z.record(z.string(), z.unknown()).nullish(), + description: zCoreToolsEntitiesCommonEntitiesI18nObject, + endpoint: z.record(z.string(), z.unknown()).nullish(), + icon: z.string(), + icon_dark: z.string().nullish(), + label: zCoreToolsEntitiesCommonEntitiesI18nObject, + meta: z.record(z.string(), z.unknown()), + model: zProviderEntityResponse.nullish(), + name: z.string(), + plugins: z.record(z.string(), z.array(z.string()).nullable()), + repo: z.string().nullish(), + resource: z.record(z.string(), z.unknown()), + tags: z.array(z.string()).optional(), + tool: z.record(z.string(), z.unknown()).nullish(), + trigger: z.record(z.string(), z.unknown()).nullish(), + verified: z.boolean().optional().default(false), + version: z.string(), +}) + +/** + * PluginCategoryInstalledPluginResponse + */ +export const zPluginCategoryInstalledPluginResponse = z.object({ + checksum: z.string(), + created_at: z.iso.datetime(), + declaration: zPluginDeclarationResponse, + endpoints_active: z.int(), + endpoints_setups: z.int(), + id: z.string(), + installation_id: z.string(), + meta: z.record(z.string(), z.unknown()), + name: z.string(), + plugin_id: z.string(), + plugin_unique_identifier: z.string(), + runtime_type: z.string(), + source: zPluginInstallationSource, + tenant_id: z.string(), + updated_at: z.iso.datetime(), + version: z.string(), +}) + +/** + * PluginCategoryListResponse + */ +export const zPluginCategoryListResponse = z.object({ + builtin_tools: z.array(zPluginCategoryBuiltinToolProviderResponse), + has_more: z.boolean(), + plugins: z.array(zPluginCategoryInstalledPluginResponse), +}) + +/** + * QuotaUnit + */ +export const zQuotaUnit = z.enum(['credits', 'times', 'tokens']) + +/** + * RestrictModel + */ +export const zRestrictModel = z.object({ + base_model_name: z.string().nullish(), + model: z.string(), + model_type: zModelType, +}) + +/** + * QuotaConfiguration + * + * Model class for provider quota configuration. + */ +export const zQuotaConfiguration = z.object({ + is_valid: z.boolean(), + quota_limit: z.int(), + quota_type: zProviderQuotaType, + quota_unit: zQuotaUnit, + quota_used: z.int(), + restrict_models: z.array(zRestrictModel).optional().default([]), +}) + +/** + * SystemConfigurationResponse + * + * Model class for provider system configuration response. + */ +export const zSystemConfigurationResponse = z.object({ + current_quota_type: zProviderQuotaType.nullish(), + enabled: z.boolean(), + quota_configurations: z.array(zQuotaConfiguration).optional().default([]), +}) + +/** + * ProviderResponse + * + * Model class for provider response. + */ +export const zProviderResponse = z.object({ + background: z.string().nullish(), + configurate_methods: z.array(zConfigurateMethod), + custom_configuration: zCustomConfigurationResponse, + description: zI18nObject.nullish(), + help: zProviderHelpEntity.nullish(), + icon_small: zI18nObject.nullish(), + icon_small_dark: zI18nObject.nullish(), + label: zI18nObject, + model_credential_schema: zModelCredentialSchema.nullish(), + preferred_provider_type: zProviderType, + provider: z.string(), + provider_credential_schema: zProviderCredentialSchema.nullish(), + supported_model_types: z.array(zModelType), + system_configuration: zSystemConfigurationResponse, + tenant_id: z.string(), +}) + +/** + * ModelProviderListResponse + */ +export const zModelProviderListResponse = z.object({ + data: z.array(zProviderResponse), +}) + /** * Success */ -export const zGetWorkspacesResponse = z.record(z.string(), z.unknown()) +export const zGetWorkspacesResponse = zTenantListResponse /** * Success @@ -926,27 +1879,22 @@ export const zGetWorkspacesCurrentAgentProviderByProviderNamePath = z.object({ }) /** - * Agent provider details + * Success */ -export const zGetWorkspacesCurrentAgentProviderByProviderNameResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetWorkspacesCurrentAgentProviderByProviderNameResponse = zAgentProviderResponse /** * Success */ -export const zGetWorkspacesCurrentAgentProvidersResponse = z.array( - z.record(z.string(), z.unknown()), -) +export const zGetWorkspacesCurrentAgentProvidersResponse = zAgentProviderListResponse export const zGetWorkspacesCurrentCustomizedSnippetsQuery = z.object({ - creators: z.array(z.string()).nullish(), - is_published: z.boolean().nullish(), - keyword: z.string().nullish(), + creators: z.array(z.string()).optional(), + is_published: z.boolean().optional(), + keyword: z.string().optional(), limit: z.int().gte(1).lte(100).optional().default(20), page: z.int().gte(1).lte(99999).optional().default(1), - tag_ids: z.array(z.string()).nullish(), + tag_ids: z.array(z.string()).optional(), }) /** @@ -963,10 +1911,10 @@ export const zPostWorkspacesCurrentCustomizedSnippetsResponse = zSnippet export const zPostWorkspacesCurrentCustomizedSnippetsImportsBody = zSnippetImportPayload -export const zPostWorkspacesCurrentCustomizedSnippetsImportsResponse = z.union([ - z.record(z.string(), z.unknown()), - z.record(z.string(), z.unknown()), -]) +/** + * Snippet imported successfully + */ +export const zPostWorkspacesCurrentCustomizedSnippetsImportsResponse = zSnippetImportResponse export const zPostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmPath = z.object({ import_id: z.string(), @@ -975,10 +1923,8 @@ export const zPostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmPat /** * Import confirmed successfully */ -export const zPostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmResponse + = zSnippetImportResponse export const zDeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdPath = z.object({ snippet_id: z.string(), @@ -987,10 +1933,7 @@ export const zDeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdPath = z.objec /** * Snippet deleted successfully */ -export const zDeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdResponse = z.record( - z.string(), - z.never(), -) +export const zDeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdResponse = z.void() export const zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdPath = z.object({ snippet_id: z.string(), @@ -1019,22 +1962,21 @@ export const zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependencies /** * Dependencies checked successfully */ -export const zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesResponse + = zSnippetDependencyCheckResponse export const zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportPath = z.object({ snippet_id: z.string(), }) +export const zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportQuery = z.object({ + include_secret: z.string().optional().default('false'), +}) + /** * Snippet exported successfully */ -export const zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportResponse = zTextFileResponse export const zPostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementPath = z.object({ snippet_id: z.string(), @@ -1044,7 +1986,7 @@ export const zPostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncremen * Use count incremented successfully */ export const zPostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementResponse - = z.record(z.string(), z.unknown()) + = zSnippetUseCountResponse /** * Success @@ -1052,13 +1994,13 @@ export const zPostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncremen export const zGetWorkspacesCurrentDatasetOperatorsResponse = zAccountWithRoleList export const zGetWorkspacesCurrentDefaultModelQuery = z.object({ - model_type: z.string(), + model_type: z.enum(['llm', 'moderation', 'rerank', 'speech2text', 'text-embedding', 'tts']), }) /** * Success */ -export const zGetWorkspacesCurrentDefaultModelResponse = z.record(z.string(), z.unknown()) +export const zGetWorkspacesCurrentDefaultModelResponse = zDefaultModelDataResponse export const zPostWorkspacesCurrentDefaultModelBody = zParserPostDefault @@ -1104,7 +2046,7 @@ export const zPostWorkspacesCurrentEndpointsEnableResponse = zEndpointEnableResp export const zGetWorkspacesCurrentEndpointsListQuery = z.object({ page: z.int().gte(1), - page_size: z.int(), + page_size: z.int().gt(0), }) /** @@ -1114,7 +2056,7 @@ export const zGetWorkspacesCurrentEndpointsListResponse = zEndpointListResponse export const zGetWorkspacesCurrentEndpointsListPluginQuery = z.object({ page: z.int().gte(1), - page_size: z.int(), + page_size: z.int().gt(0), plugin_id: z.string(), }) @@ -1160,7 +2102,7 @@ export const zPostWorkspacesCurrentMembersInviteEmailBody = zMemberInvitePayload /** * Success */ -export const zPostWorkspacesCurrentMembersInviteEmailResponse = z.record(z.string(), z.unknown()) +export const zPostWorkspacesCurrentMembersInviteEmailResponse = zMemberInviteResponse export const zPostWorkspacesCurrentMembersOwnerTransferCheckBody = zOwnerTransferCheckPayload @@ -1185,7 +2127,7 @@ export const zDeleteWorkspacesCurrentMembersByMemberIdPath = z.object({ /** * Success */ -export const zDeleteWorkspacesCurrentMembersByMemberIdResponse = z.record(z.string(), z.unknown()) +export const zDeleteWorkspacesCurrentMembersByMemberIdResponse = zMemberActionTenantResponse export const zPostWorkspacesCurrentMembersByMemberIdOwnerTransferBody = zOwnerTransferPayload @@ -1196,10 +2138,7 @@ export const zPostWorkspacesCurrentMembersByMemberIdOwnerTransferPath = z.object /** * Success */ -export const zPostWorkspacesCurrentMembersByMemberIdOwnerTransferResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostWorkspacesCurrentMembersByMemberIdOwnerTransferResponse = zSimpleResultResponse export const zPutWorkspacesCurrentMembersByMemberIdUpdateRoleBody = zMemberRoleUpdatePayload @@ -1210,19 +2149,18 @@ export const zPutWorkspacesCurrentMembersByMemberIdUpdateRolePath = z.object({ /** * Success */ -export const zPutWorkspacesCurrentMembersByMemberIdUpdateRoleResponse = z.record( - z.string(), - z.unknown(), -) +export const zPutWorkspacesCurrentMembersByMemberIdUpdateRoleResponse = zSimpleResultResponse export const zGetWorkspacesCurrentModelProvidersQuery = z.object({ - model_type: z.string().nullish(), + model_type: z + .enum(['llm', 'moderation', 'rerank', 'speech2text', 'text-embedding', 'tts']) + .optional(), }) /** * Success */ -export const zGetWorkspacesCurrentModelProvidersResponse = z.record(z.string(), z.unknown()) +export const zGetWorkspacesCurrentModelProvidersResponse = zModelProviderListResponse export const zGetWorkspacesCurrentModelProvidersByProviderCheckoutUrlPath = z.object({ provider: z.string(), @@ -1231,10 +2169,8 @@ export const zGetWorkspacesCurrentModelProvidersByProviderCheckoutUrlPath = z.ob /** * Success */ -export const zGetWorkspacesCurrentModelProvidersByProviderCheckoutUrlResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetWorkspacesCurrentModelProvidersByProviderCheckoutUrlResponse + = zModelProviderPaymentCheckoutUrlResponse export const zDeleteWorkspacesCurrentModelProvidersByProviderCredentialsBody = zParserCredentialDelete @@ -1246,26 +2182,21 @@ export const zDeleteWorkspacesCurrentModelProvidersByProviderCredentialsPath = z /** * Credential deleted successfully */ -export const zDeleteWorkspacesCurrentModelProvidersByProviderCredentialsResponse = z.record( - z.string(), - z.never(), -) +export const zDeleteWorkspacesCurrentModelProvidersByProviderCredentialsResponse = z.void() export const zGetWorkspacesCurrentModelProvidersByProviderCredentialsPath = z.object({ provider: z.string(), }) export const zGetWorkspacesCurrentModelProvidersByProviderCredentialsQuery = z.object({ - credential_id: z.string().nullish(), + credential_id: z.string().optional(), }) /** * Success */ -export const zGetWorkspacesCurrentModelProvidersByProviderCredentialsResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetWorkspacesCurrentModelProvidersByProviderCredentialsResponse + = zProviderCredentialResponse export const zPostWorkspacesCurrentModelProvidersByProviderCredentialsBody = zParserCredentialCreate @@ -1274,12 +2205,10 @@ export const zPostWorkspacesCurrentModelProvidersByProviderCredentialsPath = z.o }) /** - * Success + * Credential created successfully */ -export const zPostWorkspacesCurrentModelProvidersByProviderCredentialsResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostWorkspacesCurrentModelProvidersByProviderCredentialsResponse + = zSimpleResultResponse export const zPutWorkspacesCurrentModelProvidersByProviderCredentialsBody = zParserCredentialUpdate @@ -1288,12 +2217,10 @@ export const zPutWorkspacesCurrentModelProvidersByProviderCredentialsPath = z.ob }) /** - * Success + * Credential updated successfully */ -export const zPutWorkspacesCurrentModelProvidersByProviderCredentialsResponse = z.record( - z.string(), - z.unknown(), -) +export const zPutWorkspacesCurrentModelProvidersByProviderCredentialsResponse + = zSimpleResultResponse export const zPostWorkspacesCurrentModelProvidersByProviderCredentialsSwitchBody = zParserCredentialSwitch @@ -1316,12 +2243,10 @@ export const zPostWorkspacesCurrentModelProvidersByProviderCredentialsValidatePa }) /** - * Success + * Credential validation result */ -export const zPostWorkspacesCurrentModelProvidersByProviderCredentialsValidateResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostWorkspacesCurrentModelProvidersByProviderCredentialsValidateResponse + = zProviderCredentialValidateResponse export const zDeleteWorkspacesCurrentModelProvidersByProviderModelsBody = zParserDeleteModels @@ -1332,10 +2257,7 @@ export const zDeleteWorkspacesCurrentModelProvidersByProviderModelsPath = z.obje /** * Model deleted successfully */ -export const zDeleteWorkspacesCurrentModelProvidersByProviderModelsResponse = z.record( - z.string(), - z.never(), -) +export const zDeleteWorkspacesCurrentModelProvidersByProviderModelsResponse = z.void() export const zGetWorkspacesCurrentModelProvidersByProviderModelsPath = z.object({ provider: z.string(), @@ -1344,10 +2266,8 @@ export const zGetWorkspacesCurrentModelProvidersByProviderModelsPath = z.object( /** * Success */ -export const zGetWorkspacesCurrentModelProvidersByProviderModelsResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetWorkspacesCurrentModelProvidersByProviderModelsResponse + = zModelWithProviderListResponse export const zPostWorkspacesCurrentModelProvidersByProviderModelsBody = zParserPostModels @@ -1358,10 +2278,7 @@ export const zPostWorkspacesCurrentModelProvidersByProviderModelsPath = z.object /** * Success */ -export const zPostWorkspacesCurrentModelProvidersByProviderModelsResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostWorkspacesCurrentModelProvidersByProviderModelsResponse = zSimpleResultResponse export const zDeleteWorkspacesCurrentModelProvidersByProviderModelsCredentialsBody = zParserDeleteCredential @@ -1373,29 +2290,24 @@ export const zDeleteWorkspacesCurrentModelProvidersByProviderModelsCredentialsPa /** * Credential deleted successfully */ -export const zDeleteWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse = z.record( - z.string(), - z.never(), -) +export const zDeleteWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse = z.void() export const zGetWorkspacesCurrentModelProvidersByProviderModelsCredentialsPath = z.object({ provider: z.string(), }) export const zGetWorkspacesCurrentModelProvidersByProviderModelsCredentialsQuery = z.object({ - config_from: z.string().nullish(), - credential_id: z.string().nullish(), + config_from: z.string().optional(), + credential_id: z.string().optional(), model: z.string(), - model_type: z.string(), + model_type: z.enum(['llm', 'moderation', 'rerank', 'speech2text', 'text-embedding', 'tts']), }) /** * Success */ -export const zGetWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse + = zModelCredentialResponse export const zPostWorkspacesCurrentModelProvidersByProviderModelsCredentialsBody = zParserCreateCredential @@ -1405,12 +2317,10 @@ export const zPostWorkspacesCurrentModelProvidersByProviderModelsCredentialsPath }) /** - * Success + * Credential created successfully */ -export const zPostWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse + = zSimpleResultResponse export const zPutWorkspacesCurrentModelProvidersByProviderModelsCredentialsBody = zParserUpdateCredential @@ -1420,12 +2330,10 @@ export const zPutWorkspacesCurrentModelProvidersByProviderModelsCredentialsPath }) /** - * Success + * Credential updated successfully */ -export const zPutWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse = z.record( - z.string(), - z.unknown(), -) +export const zPutWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse + = zSimpleResultResponse export const zPostWorkspacesCurrentModelProvidersByProviderModelsCredentialsSwitchBody = zParserSwitch @@ -1450,10 +2358,10 @@ export const zPostWorkspacesCurrentModelProvidersByProviderModelsCredentialsVali ) /** - * Success + * Credential validation result */ export const zPostWorkspacesCurrentModelProvidersByProviderModelsCredentialsValidateResponse - = z.record(z.string(), z.unknown()) + = zModelCredentialValidateResponse export const zPatchWorkspacesCurrentModelProvidersByProviderModelsDisableBody = zParserDeleteModels @@ -1488,10 +2396,10 @@ export const zPostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingCo }) /** - * Success + * Credential validation result */ export const zPostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsCredentialsValidateResponse - = z.record(z.string(), z.unknown()) + = zLoadBalancingCredentialValidateResponse export const zPostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsByConfigIdCredentialsValidateBody = zLoadBalancingCredentialPayload @@ -1503,10 +2411,10 @@ export const zPostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingCo }) /** - * Success + * Credential validation result */ export const zPostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsByConfigIdCredentialsValidateResponse - = z.record(z.string(), z.unknown()) + = zLoadBalancingCredentialValidateResponse export const zGetWorkspacesCurrentModelProvidersByProviderModelsParameterRulesPath = z.object({ provider: z.string(), @@ -1519,10 +2427,8 @@ export const zGetWorkspacesCurrentModelProvidersByProviderModelsParameterRulesQu /** * Success */ -export const zGetWorkspacesCurrentModelProvidersByProviderModelsParameterRulesResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetWorkspacesCurrentModelProvidersByProviderModelsParameterRulesResponse + = zModelParameterRulesResponse export const zPostWorkspacesCurrentModelProvidersByProviderPreferredProviderTypeBody = zParserPreferredProviderType @@ -1544,10 +2450,8 @@ export const zGetWorkspacesCurrentModelsModelTypesByModelTypePath = z.object({ /** * Success */ -export const zGetWorkspacesCurrentModelsModelTypesByModelTypeResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetWorkspacesCurrentModelsModelTypesByModelTypeResponse + = zProviderWithModelsDataResponse /** * Success @@ -1562,7 +2466,31 @@ export const zGetWorkspacesCurrentPluginAssetQuery = z.object({ /** * Success */ -export const zGetWorkspacesCurrentPluginAssetResponse = z.record(z.string(), z.unknown()) +export const zGetWorkspacesCurrentPluginAssetResponse = zBinaryFileResponse + +export const zPostWorkspacesCurrentPluginAutoUpgradeChangeBody = zParserAutoUpgradeChange + +/** + * Success + */ +export const zPostWorkspacesCurrentPluginAutoUpgradeChangeResponse + = zPluginAutoUpgradeChangeResponse + +export const zPostWorkspacesCurrentPluginAutoUpgradeExcludeBody = zParserExcludePlugin + +/** + * Success + */ +export const zPostWorkspacesCurrentPluginAutoUpgradeExcludeResponse = zSuccessResponse + +export const zGetWorkspacesCurrentPluginAutoUpgradeFetchQuery = z.object({ + category: z.enum(['agent-strategy', 'datasource', 'extension', 'model', 'tool', 'trigger']), +}) + +/** + * Success + */ +export const zGetWorkspacesCurrentPluginAutoUpgradeFetchResponse = zPluginAutoUpgradeFetchResponse /** * Success @@ -1576,7 +2504,7 @@ export const zGetWorkspacesCurrentPluginFetchManifestQuery = z.object({ /** * Success */ -export const zGetWorkspacesCurrentPluginFetchManifestResponse = z.record(z.string(), z.unknown()) +export const zGetWorkspacesCurrentPluginFetchManifestResponse = zPluginManifestResponse export const zGetWorkspacesCurrentPluginIconQuery = z.object({ filename: z.string(), @@ -1586,31 +2514,28 @@ export const zGetWorkspacesCurrentPluginIconQuery = z.object({ /** * Success */ -export const zGetWorkspacesCurrentPluginIconResponse = z.record(z.string(), z.unknown()) +export const zGetWorkspacesCurrentPluginIconResponse = zBinaryFileResponse export const zPostWorkspacesCurrentPluginInstallGithubBody = zParserGithubInstall /** * Success */ -export const zPostWorkspacesCurrentPluginInstallGithubResponse = z.record(z.string(), z.unknown()) +export const zPostWorkspacesCurrentPluginInstallGithubResponse = zPluginDaemonOperationResponse export const zPostWorkspacesCurrentPluginInstallMarketplaceBody = zParserPluginIdentifiers /** * Success */ -export const zPostWorkspacesCurrentPluginInstallMarketplaceResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostWorkspacesCurrentPluginInstallMarketplaceResponse = zPluginDaemonOperationResponse export const zPostWorkspacesCurrentPluginInstallPkgBody = zParserPluginIdentifiers /** * Success */ -export const zPostWorkspacesCurrentPluginInstallPkgResponse = z.record(z.string(), z.unknown()) +export const zPostWorkspacesCurrentPluginInstallPkgResponse = zPluginDaemonOperationResponse export const zGetWorkspacesCurrentPluginListQuery = z.object({ page: z.int().gte(1).optional().default(1), @@ -1620,27 +2545,21 @@ export const zGetWorkspacesCurrentPluginListQuery = z.object({ /** * Success */ -export const zGetWorkspacesCurrentPluginListResponse = z.record(z.string(), z.unknown()) +export const zGetWorkspacesCurrentPluginListResponse = zPluginListResponse export const zPostWorkspacesCurrentPluginListInstallationsIdsBody = zParserLatest /** * Success */ -export const zPostWorkspacesCurrentPluginListInstallationsIdsResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostWorkspacesCurrentPluginListInstallationsIdsResponse = zPluginInstallationsResponse export const zPostWorkspacesCurrentPluginListLatestVersionsBody = zParserLatest /** * Success */ -export const zPostWorkspacesCurrentPluginListLatestVersionsResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostWorkspacesCurrentPluginListLatestVersionsResponse = zPluginVersionsResponse export const zGetWorkspacesCurrentPluginMarketplacePkgQuery = z.object({ plugin_unique_identifier: z.string(), @@ -1649,11 +2568,11 @@ export const zGetWorkspacesCurrentPluginMarketplacePkgQuery = z.object({ /** * Success */ -export const zGetWorkspacesCurrentPluginMarketplacePkgResponse = z.record(z.string(), z.unknown()) +export const zGetWorkspacesCurrentPluginMarketplacePkgResponse = zPluginManifestResponse export const zGetWorkspacesCurrentPluginParametersDynamicOptionsQuery = z.object({ action: z.string(), - credential_id: z.string().nullish(), + credential_id: z.string().optional(), parameter: z.string(), plugin_id: z.string(), provider: z.string(), @@ -1663,10 +2582,8 @@ export const zGetWorkspacesCurrentPluginParametersDynamicOptionsQuery = z.object /** * Success */ -export const zGetWorkspacesCurrentPluginParametersDynamicOptionsResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetWorkspacesCurrentPluginParametersDynamicOptionsResponse + = zPluginDynamicOptionsResponse export const zPostWorkspacesCurrentPluginParametersDynamicOptionsWithCredentialsBody = zParserDynamicOptionsWithCredentials @@ -1674,10 +2591,8 @@ export const zPostWorkspacesCurrentPluginParametersDynamicOptionsWithCredentials /** * Success */ -export const zPostWorkspacesCurrentPluginParametersDynamicOptionsWithCredentialsResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostWorkspacesCurrentPluginParametersDynamicOptionsWithCredentialsResponse + = zPluginDynamicOptionsResponse export const zPostWorkspacesCurrentPluginPermissionChangeBody = zParserPermissionChange @@ -1689,32 +2604,7 @@ export const zPostWorkspacesCurrentPluginPermissionChangeResponse = zSuccessResp /** * Success */ -export const zGetWorkspacesCurrentPluginPermissionFetchResponse = z.record(z.string(), z.unknown()) - -export const zPostWorkspacesCurrentPluginPreferencesAutoupgradeExcludeBody = zParserExcludePlugin - -/** - * Success - */ -export const zPostWorkspacesCurrentPluginPreferencesAutoupgradeExcludeResponse = z.record( - z.string(), - z.unknown(), -) - -export const zPostWorkspacesCurrentPluginPreferencesChangeBody = zParserPreferencesChange - -/** - * Success - */ -export const zPostWorkspacesCurrentPluginPreferencesChangeResponse = z.record( - z.string(), - z.unknown(), -) - -/** - * Success - */ -export const zGetWorkspacesCurrentPluginPreferencesFetchResponse = z.record(z.string(), z.unknown()) +export const zGetWorkspacesCurrentPluginPermissionFetchResponse = zPluginPermissionResponse export const zGetWorkspacesCurrentPluginReadmeQuery = z.object({ language: z.string().optional().default('en-US'), @@ -1724,7 +2614,7 @@ export const zGetWorkspacesCurrentPluginReadmeQuery = z.object({ /** * Success */ -export const zGetWorkspacesCurrentPluginReadmeResponse = z.record(z.string(), z.unknown()) +export const zGetWorkspacesCurrentPluginReadmeResponse = zPluginReadmeResponse export const zGetWorkspacesCurrentPluginTasksQuery = z.object({ page: z.int().gte(1).optional().default(1), @@ -1734,7 +2624,7 @@ export const zGetWorkspacesCurrentPluginTasksQuery = z.object({ /** * Success */ -export const zGetWorkspacesCurrentPluginTasksResponse = z.record(z.string(), z.unknown()) +export const zGetWorkspacesCurrentPluginTasksResponse = zPluginTasksResponse /** * Success @@ -1748,7 +2638,7 @@ export const zGetWorkspacesCurrentPluginTasksByTaskIdPath = z.object({ /** * Success */ -export const zGetWorkspacesCurrentPluginTasksByTaskIdResponse = z.record(z.string(), z.unknown()) +export const zGetWorkspacesCurrentPluginTasksByTaskIdResponse = zPluginTaskResponse export const zPostWorkspacesCurrentPluginTasksByTaskIdDeletePath = z.object({ task_id: z.string(), @@ -1781,92 +2671,112 @@ export const zPostWorkspacesCurrentPluginUpgradeGithubBody = zParserGithubUpgrad /** * Success */ -export const zPostWorkspacesCurrentPluginUpgradeGithubResponse = z.record(z.string(), z.unknown()) +export const zPostWorkspacesCurrentPluginUpgradeGithubResponse = zPluginDaemonOperationResponse export const zPostWorkspacesCurrentPluginUpgradeMarketplaceBody = zParserMarketplaceUpgrade /** * Success */ -export const zPostWorkspacesCurrentPluginUpgradeMarketplaceResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostWorkspacesCurrentPluginUpgradeMarketplaceResponse = zPluginDaemonOperationResponse /** * Success */ -export const zPostWorkspacesCurrentPluginUploadBundleResponse = z.record(z.string(), z.unknown()) +export const zPostWorkspacesCurrentPluginUploadBundleResponse = zPluginDaemonOperationResponse export const zPostWorkspacesCurrentPluginUploadGithubBody = zParserGithubUpload /** * Success */ -export const zPostWorkspacesCurrentPluginUploadGithubResponse = z.record(z.string(), z.unknown()) +export const zPostWorkspacesCurrentPluginUploadGithubResponse = zPluginDaemonOperationResponse /** * Success */ -export const zPostWorkspacesCurrentPluginUploadPkgResponse = z.record(z.string(), z.unknown()) +export const zPostWorkspacesCurrentPluginUploadPkgResponse = zPluginDaemonOperationResponse + +export const zGetWorkspacesCurrentPluginByCategoryListPath = z.object({ + category: z.string(), +}) + +export const zGetWorkspacesCurrentPluginByCategoryListQuery = z.object({ + page: z.int().gte(1).optional().default(1), + page_size: z.int().gte(1).lte(256).optional().default(256), +}) /** * Success */ -export const zGetWorkspacesCurrentToolLabelsResponse = z.record(z.string(), z.unknown()) +export const zGetWorkspacesCurrentPluginByCategoryListResponse = zPluginCategoryListResponse + +/** + * Success + */ +export const zGetWorkspacesCurrentToolLabelsResponse = zToolProviderOpaqueResponse export const zPostWorkspacesCurrentToolProviderApiAddBody = zApiToolProviderAddPayload /** * Success */ -export const zPostWorkspacesCurrentToolProviderApiAddResponse = z.record(z.string(), z.unknown()) +export const zPostWorkspacesCurrentToolProviderApiAddResponse = zToolProviderOpaqueResponse export const zPostWorkspacesCurrentToolProviderApiDeleteBody = zApiToolProviderDeletePayload /** * Success */ -export const zPostWorkspacesCurrentToolProviderApiDeleteResponse = z.record(z.string(), z.unknown()) +export const zPostWorkspacesCurrentToolProviderApiDeleteResponse = zToolProviderOpaqueResponse + +export const zGetWorkspacesCurrentToolProviderApiGetQuery = z.object({ + provider: z.string(), +}) /** * Success */ -export const zGetWorkspacesCurrentToolProviderApiGetResponse = z.record(z.string(), z.unknown()) +export const zGetWorkspacesCurrentToolProviderApiGetResponse = zToolProviderOpaqueResponse + +export const zGetWorkspacesCurrentToolProviderApiRemoteQuery = z.object({ + url: z.url().min(1).max(2083), +}) /** * Success */ -export const zGetWorkspacesCurrentToolProviderApiRemoteResponse = z.record(z.string(), z.unknown()) +export const zGetWorkspacesCurrentToolProviderApiRemoteResponse = zToolProviderOpaqueResponse export const zPostWorkspacesCurrentToolProviderApiSchemaBody = zApiToolSchemaPayload /** * Success */ -export const zPostWorkspacesCurrentToolProviderApiSchemaResponse = z.record(z.string(), z.unknown()) +export const zPostWorkspacesCurrentToolProviderApiSchemaResponse = zToolProviderOpaqueResponse export const zPostWorkspacesCurrentToolProviderApiTestPreBody = zApiToolTestPayload /** * Success */ -export const zPostWorkspacesCurrentToolProviderApiTestPreResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostWorkspacesCurrentToolProviderApiTestPreResponse = zToolProviderOpaqueResponse + +export const zGetWorkspacesCurrentToolProviderApiToolsQuery = z.object({ + provider: z.string(), +}) /** * Success */ -export const zGetWorkspacesCurrentToolProviderApiToolsResponse = z.record(z.string(), z.unknown()) +export const zGetWorkspacesCurrentToolProviderApiToolsResponse = zToolProviderOpaqueResponse export const zPostWorkspacesCurrentToolProviderApiUpdateBody = zApiToolProviderUpdatePayload /** * Success */ -export const zPostWorkspacesCurrentToolProviderApiUpdateResponse = z.record(z.string(), z.unknown()) +export const zPostWorkspacesCurrentToolProviderApiUpdateResponse = zToolProviderOpaqueResponse export const zPostWorkspacesCurrentToolProviderBuiltinByProviderAddBody = zBuiltinToolAddPayload @@ -1877,22 +2787,22 @@ export const zPostWorkspacesCurrentToolProviderBuiltinByProviderAddPath = z.obje /** * Success */ -export const zPostWorkspacesCurrentToolProviderBuiltinByProviderAddResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostWorkspacesCurrentToolProviderBuiltinByProviderAddResponse + = zToolProviderOpaqueResponse export const zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfoPath = z.object({ provider: z.string(), }) +export const zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfoQuery = z.object({ + include_credential_ids: z.array(z.string()).optional(), +}) + /** * Success */ -export const zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfoResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfoResponse + = zToolProviderOpaqueResponse export const zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialSchemaByCredentialTypePath = z.object({ @@ -1904,19 +2814,21 @@ export const zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialSchemaB * Success */ export const zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialSchemaByCredentialTypeResponse - = z.record(z.string(), z.unknown()) + = zToolProviderOpaqueResponse export const zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialsPath = z.object({ provider: z.string(), }) +export const zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialsQuery = z.object({ + include_credential_ids: z.array(z.string()).optional(), +}) + /** * Success */ -export const zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialsResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialsResponse + = zToolProviderOpaqueResponse export const zPostWorkspacesCurrentToolProviderBuiltinByProviderDefaultCredentialBody = zBuiltinProviderDefaultCredentialPayload @@ -1929,7 +2841,7 @@ export const zPostWorkspacesCurrentToolProviderBuiltinByProviderDefaultCredentia * Success */ export const zPostWorkspacesCurrentToolProviderBuiltinByProviderDefaultCredentialResponse - = z.record(z.string(), z.unknown()) + = zToolProviderOpaqueResponse export const zPostWorkspacesCurrentToolProviderBuiltinByProviderDeleteBody = zBuiltinToolCredentialDeletePayload @@ -1941,10 +2853,8 @@ export const zPostWorkspacesCurrentToolProviderBuiltinByProviderDeletePath = z.o /** * Success */ -export const zPostWorkspacesCurrentToolProviderBuiltinByProviderDeleteResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostWorkspacesCurrentToolProviderBuiltinByProviderDeleteResponse + = zToolProviderOpaqueResponse export const zGetWorkspacesCurrentToolProviderBuiltinByProviderIconPath = z.object({ provider: z.string(), @@ -1953,10 +2863,7 @@ export const zGetWorkspacesCurrentToolProviderBuiltinByProviderIconPath = z.obje /** * Success */ -export const zGetWorkspacesCurrentToolProviderBuiltinByProviderIconResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetWorkspacesCurrentToolProviderBuiltinByProviderIconResponse = zBinaryFileResponse export const zGetWorkspacesCurrentToolProviderBuiltinByProviderInfoPath = z.object({ provider: z.string(), @@ -1965,10 +2872,8 @@ export const zGetWorkspacesCurrentToolProviderBuiltinByProviderInfoPath = z.obje /** * Success */ -export const zGetWorkspacesCurrentToolProviderBuiltinByProviderInfoResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetWorkspacesCurrentToolProviderBuiltinByProviderInfoResponse + = zToolProviderOpaqueResponse export const zGetWorkspacesCurrentToolProviderBuiltinByProviderOauthClientSchemaPath = z.object({ provider: z.string(), @@ -1977,10 +2882,8 @@ export const zGetWorkspacesCurrentToolProviderBuiltinByProviderOauthClientSchema /** * Success */ -export const zGetWorkspacesCurrentToolProviderBuiltinByProviderOauthClientSchemaResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetWorkspacesCurrentToolProviderBuiltinByProviderOauthClientSchemaResponse + = zToolOAuthClientSchemaResponse export const zDeleteWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientPath = z.object({ provider: z.string(), @@ -1990,7 +2893,7 @@ export const zDeleteWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomCli * Success */ export const zDeleteWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse - = z.record(z.string(), z.unknown()) + = zSimpleResultResponse export const zGetWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientPath = z.object({ provider: z.string(), @@ -1999,10 +2902,8 @@ export const zGetWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClient /** * Success */ -export const zGetWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse + = zToolOAuthCustomClientResponse export const zPostWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientBody = zToolOAuthCustomClientPayload @@ -2015,7 +2916,7 @@ export const zPostWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClien * Success */ export const zPostWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse - = z.record(z.string(), z.unknown()) + = zSimpleResultResponse export const zGetWorkspacesCurrentToolProviderBuiltinByProviderToolsPath = z.object({ provider: z.string(), @@ -2024,10 +2925,8 @@ export const zGetWorkspacesCurrentToolProviderBuiltinByProviderToolsPath = z.obj /** * Success */ -export const zGetWorkspacesCurrentToolProviderBuiltinByProviderToolsResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetWorkspacesCurrentToolProviderBuiltinByProviderToolsResponse + = zToolProviderOpaqueResponse export const zPostWorkspacesCurrentToolProviderBuiltinByProviderUpdateBody = zBuiltinToolUpdatePayload @@ -2039,10 +2938,8 @@ export const zPostWorkspacesCurrentToolProviderBuiltinByProviderUpdatePath = z.o /** * Success */ -export const zPostWorkspacesCurrentToolProviderBuiltinByProviderUpdateResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostWorkspacesCurrentToolProviderBuiltinByProviderUpdateResponse + = zToolProviderOpaqueResponse export const zDeleteWorkspacesCurrentToolProviderMcpBody = zMcpProviderDeletePayload @@ -2056,21 +2953,21 @@ export const zPostWorkspacesCurrentToolProviderMcpBody = zMcpProviderCreatePaylo /** * Success */ -export const zPostWorkspacesCurrentToolProviderMcpResponse = z.record(z.string(), z.unknown()) +export const zPostWorkspacesCurrentToolProviderMcpResponse = zToolProviderOpaqueResponse export const zPutWorkspacesCurrentToolProviderMcpBody = zMcpProviderUpdatePayload /** * Success */ -export const zPutWorkspacesCurrentToolProviderMcpResponse = z.record(z.string(), z.unknown()) +export const zPutWorkspacesCurrentToolProviderMcpResponse = zSimpleResultResponse export const zPostWorkspacesCurrentToolProviderMcpAuthBody = zMcpAuthPayload /** * Success */ -export const zPostWorkspacesCurrentToolProviderMcpAuthResponse = z.record(z.string(), z.unknown()) +export const zPostWorkspacesCurrentToolProviderMcpAuthResponse = zToolProviderOpaqueResponse export const zGetWorkspacesCurrentToolProviderMcpToolsByProviderIdPath = z.object({ provider_id: z.string(), @@ -2079,10 +2976,8 @@ export const zGetWorkspacesCurrentToolProviderMcpToolsByProviderIdPath = z.objec /** * Success */ -export const zGetWorkspacesCurrentToolProviderMcpToolsByProviderIdResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetWorkspacesCurrentToolProviderMcpToolsByProviderIdResponse + = zToolProviderOpaqueResponse export const zGetWorkspacesCurrentToolProviderMcpUpdateByProviderIdPath = z.object({ provider_id: z.string(), @@ -2091,81 +2986,77 @@ export const zGetWorkspacesCurrentToolProviderMcpUpdateByProviderIdPath = z.obje /** * Success */ -export const zGetWorkspacesCurrentToolProviderMcpUpdateByProviderIdResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetWorkspacesCurrentToolProviderMcpUpdateByProviderIdResponse + = zToolProviderOpaqueResponse export const zPostWorkspacesCurrentToolProviderWorkflowCreateBody = zWorkflowToolCreatePayload /** * Success */ -export const zPostWorkspacesCurrentToolProviderWorkflowCreateResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostWorkspacesCurrentToolProviderWorkflowCreateResponse = zToolProviderOpaqueResponse export const zPostWorkspacesCurrentToolProviderWorkflowDeleteBody = zWorkflowToolDeletePayload /** * Success */ -export const zPostWorkspacesCurrentToolProviderWorkflowDeleteResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostWorkspacesCurrentToolProviderWorkflowDeleteResponse = zToolProviderOpaqueResponse + +export const zGetWorkspacesCurrentToolProviderWorkflowGetQuery = z.object({ + workflow_app_id: z.string().optional(), + workflow_tool_id: z.string().optional(), +}) /** * Success */ -export const zGetWorkspacesCurrentToolProviderWorkflowGetResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetWorkspacesCurrentToolProviderWorkflowGetResponse = zToolProviderOpaqueResponse + +export const zGetWorkspacesCurrentToolProviderWorkflowToolsQuery = z.object({ + workflow_tool_id: z.string(), +}) /** * Success */ -export const zGetWorkspacesCurrentToolProviderWorkflowToolsResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetWorkspacesCurrentToolProviderWorkflowToolsResponse = zToolProviderOpaqueResponse export const zPostWorkspacesCurrentToolProviderWorkflowUpdateBody = zWorkflowToolUpdatePayload /** * Success */ -export const zPostWorkspacesCurrentToolProviderWorkflowUpdateResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostWorkspacesCurrentToolProviderWorkflowUpdateResponse = zToolProviderOpaqueResponse + +export const zGetWorkspacesCurrentToolProvidersQuery = z.object({ + type: z.enum(['api', 'builtin', 'mcp', 'model', 'workflow']).optional(), +}) /** * Success */ -export const zGetWorkspacesCurrentToolProvidersResponse = z.record(z.string(), z.unknown()) +export const zGetWorkspacesCurrentToolProvidersResponse = zToolProviderOpaqueResponse /** * Success */ -export const zGetWorkspacesCurrentToolsApiResponse = z.record(z.string(), z.unknown()) +export const zGetWorkspacesCurrentToolsApiResponse = zToolProviderOpaqueResponse /** * Success */ -export const zGetWorkspacesCurrentToolsBuiltinResponse = z.record(z.string(), z.unknown()) +export const zGetWorkspacesCurrentToolsBuiltinResponse = zToolProviderOpaqueResponse /** * Success */ -export const zGetWorkspacesCurrentToolsMcpResponse = z.record(z.string(), z.unknown()) +export const zGetWorkspacesCurrentToolsMcpResponse = zToolProviderOpaqueResponse /** * Success */ -export const zGetWorkspacesCurrentToolsWorkflowResponse = z.record(z.string(), z.unknown()) +export const zGetWorkspacesCurrentToolsWorkflowResponse = zToolProviderOpaqueResponse export const zGetWorkspacesCurrentTriggerProviderByProviderIconPath = z.object({ provider: z.string(), @@ -2174,10 +3065,7 @@ export const zGetWorkspacesCurrentTriggerProviderByProviderIconPath = z.object({ /** * Success */ -export const zGetWorkspacesCurrentTriggerProviderByProviderIconResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetWorkspacesCurrentTriggerProviderByProviderIconResponse = zBinaryFileResponse export const zGetWorkspacesCurrentTriggerProviderByProviderInfoPath = z.object({ provider: z.string(), @@ -2186,10 +3074,8 @@ export const zGetWorkspacesCurrentTriggerProviderByProviderInfoPath = z.object({ /** * Success */ -export const zGetWorkspacesCurrentTriggerProviderByProviderInfoResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetWorkspacesCurrentTriggerProviderByProviderInfoResponse + = zTriggerProviderOpaqueResponse export const zDeleteWorkspacesCurrentTriggerProviderByProviderOauthClientPath = z.object({ provider: z.string(), @@ -2198,10 +3084,8 @@ export const zDeleteWorkspacesCurrentTriggerProviderByProviderOauthClientPath = /** * Success */ -export const zDeleteWorkspacesCurrentTriggerProviderByProviderOauthClientResponse = z.record( - z.string(), - z.unknown(), -) +export const zDeleteWorkspacesCurrentTriggerProviderByProviderOauthClientResponse + = zSimpleResultResponse export const zGetWorkspacesCurrentTriggerProviderByProviderOauthClientPath = z.object({ provider: z.string(), @@ -2210,10 +3094,8 @@ export const zGetWorkspacesCurrentTriggerProviderByProviderOauthClientPath = z.o /** * Success */ -export const zGetWorkspacesCurrentTriggerProviderByProviderOauthClientResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetWorkspacesCurrentTriggerProviderByProviderOauthClientResponse + = zTriggerOAuthClientResponse export const zPostWorkspacesCurrentTriggerProviderByProviderOauthClientBody = zTriggerOAuthClientPayload @@ -2225,10 +3107,8 @@ export const zPostWorkspacesCurrentTriggerProviderByProviderOauthClientPath = z. /** * Success */ -export const zPostWorkspacesCurrentTriggerProviderByProviderOauthClientResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostWorkspacesCurrentTriggerProviderByProviderOauthClientResponse + = zSimpleResultResponse export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBuildBySubscriptionBuilderIdBody = zTriggerSubscriptionBuilderUpdatePayload @@ -2243,7 +3123,7 @@ export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilder * Success */ export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBuildBySubscriptionBuilderIdResponse - = z.record(z.string(), z.unknown()) + = zTriggerProviderOpaqueResponse export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderCreateBody = zTriggerSubscriptionBuilderCreatePayload @@ -2257,7 +3137,7 @@ export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilder * Success */ export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderCreateResponse - = z.record(z.string(), z.unknown()) + = zTriggerProviderOpaqueResponse export const zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderLogsBySubscriptionBuilderIdPath = z.object({ @@ -2269,7 +3149,7 @@ export const zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderL * Success */ export const zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderLogsBySubscriptionBuilderIdResponse - = z.record(z.string(), z.unknown()) + = zTriggerProviderOpaqueResponse export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderUpdateBySubscriptionBuilderIdBody = zTriggerSubscriptionBuilderUpdatePayload @@ -2284,7 +3164,7 @@ export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilder * Success */ export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderUpdateBySubscriptionBuilderIdResponse - = z.record(z.string(), z.unknown()) + = zTriggerProviderOpaqueResponse export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderVerifyAndUpdateBySubscriptionBuilderIdBody = zTriggerSubscriptionBuilderVerifyPayload @@ -2299,7 +3179,7 @@ export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilder * Success */ export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderVerifyAndUpdateBySubscriptionBuilderIdResponse - = z.record(z.string(), z.unknown()) + = zTriggerProviderOpaqueResponse export const zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBySubscriptionBuilderIdPath = z.object({ @@ -2311,7 +3191,7 @@ export const zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderB * Success */ export const zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderBySubscriptionBuilderIdResponse - = z.record(z.string(), z.unknown()) + = zTriggerProviderOpaqueResponse export const zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListPath = z.object({ provider: z.string(), @@ -2320,10 +3200,8 @@ export const zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListPath /** * Success */ -export const zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListResponse + = zTriggerProviderOpaqueResponse export const zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsOauthAuthorizePath = z.object({ @@ -2331,10 +3209,10 @@ export const zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsOauthAut }) /** - * Success + * Authorization URL retrieved successfully */ export const zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsOauthAuthorizeResponse - = z.record(z.string(), z.unknown()) + = zTriggerOAuthAuthorizeResponse export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsVerifyBySubscriptionIdBody = zTriggerSubscriptionBuilderVerifyPayload @@ -2349,7 +3227,7 @@ export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsVerifyB * Success */ export const zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsVerifyBySubscriptionIdResponse - = z.record(z.string(), z.unknown()) + = zTriggerProviderOpaqueResponse export const zPostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsDeletePath = z.object({ @@ -2374,38 +3252,38 @@ export const zPostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsU * Success */ export const zPostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsUpdateResponse - = z.record(z.string(), z.unknown()) + = zTriggerProviderOpaqueResponse /** * Success */ -export const zGetWorkspacesCurrentTriggersResponse = z.record(z.string(), z.unknown()) +export const zGetWorkspacesCurrentTriggersResponse = zTriggerProviderOpaqueResponse export const zPostWorkspacesCustomConfigBody = zWorkspaceCustomConfigPayload /** * Success */ -export const zPostWorkspacesCustomConfigResponse = z.record(z.string(), z.unknown()) +export const zPostWorkspacesCustomConfigResponse = zWorkspaceMutationResponse /** - * Success + * Logo uploaded */ -export const zPostWorkspacesCustomConfigWebappLogoUploadResponse = z.record(z.string(), z.unknown()) +export const zPostWorkspacesCustomConfigWebappLogoUploadResponse = zWorkspaceLogoUploadResponse export const zPostWorkspacesInfoBody = zWorkspaceInfoPayload /** * Success */ -export const zPostWorkspacesInfoResponse = z.record(z.string(), z.unknown()) +export const zPostWorkspacesInfoResponse = zWorkspaceMutationResponse export const zPostWorkspacesSwitchBody = zSwitchWorkspacePayload /** * Success */ -export const zPostWorkspacesSwitchResponse = z.record(z.string(), z.unknown()) +export const zPostWorkspacesSwitchResponse = zSwitchWorkspaceResponse export const zGetWorkspacesByTenantIdModelProvidersByProviderByIconTypeByLangPath = z.object({ icon_type: z.string(), @@ -2417,7 +3295,5 @@ export const zGetWorkspacesByTenantIdModelProvidersByProviderByIconTypeByLangPat /** * Success */ -export const zGetWorkspacesByTenantIdModelProvidersByProviderByIconTypeByLangResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetWorkspacesByTenantIdModelProvidersByProviderByIconTypeByLangResponse + = zBinaryFileResponse diff --git a/packages/contracts/generated/api/openapi/orpc.gen.ts b/packages/contracts/generated/api/openapi/orpc.gen.ts index 15375c33c12..bc7cbea340b 100644 --- a/packages/contracts/generated/api/openapi/orpc.gen.ts +++ b/packages/contracts/generated/api/openapi/orpc.gen.ts @@ -23,6 +23,7 @@ import { zGetAppsByAppIdFormHumanInputByFormTokenPath, zGetAppsByAppIdFormHumanInputByFormTokenResponse, zGetAppsByAppIdTasksByTaskIdEventsPath, + zGetAppsByAppIdTasksByTaskIdEventsQuery, zGetAppsByAppIdTasksByTaskIdEventsResponse, zGetAppsQuery, zGetAppsResponse, @@ -236,16 +237,8 @@ export const files = { upload, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const get8 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdFormHumanInputByFormToken', @@ -284,16 +277,8 @@ export const form = { humanInput, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post3 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdRun', @@ -307,23 +292,20 @@ export const run = { post: post3, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const get9 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdTasksByTaskIdEvents', path: '/apps/{app_id}/tasks/{task_id}/events', tags: ['openapi'], }) - .input(z.object({ params: zGetAppsByAppIdTasksByTaskIdEventsPath })) + .input( + z.object({ + params: zGetAppsByAppIdTasksByTaskIdEventsPath, + query: zGetAppsByAppIdTasksByTaskIdEventsQuery.optional(), + }), + ) .output(zGetAppsByAppIdTasksByTaskIdEventsResponse) export const events = { @@ -440,16 +422,8 @@ export const lookup = { get: get11, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const post8 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postOauthDeviceToken', diff --git a/packages/contracts/generated/api/openapi/types.gen.ts b/packages/contracts/generated/api/openapi/types.gen.ts index b957d0ad9ff..244ce92417c 100644 --- a/packages/contracts/generated/api/openapi/types.gen.ts +++ b/packages/contracts/generated/api/openapi/types.gen.ts @@ -11,7 +11,7 @@ export type AccountPayload = { } export type AccountResponse = { - account?: AccountPayload + account?: AccountPayload | null default_workspace_id?: string | null subject_email?: string | null subject_issuer?: string | null @@ -36,7 +36,7 @@ export type AppDescribeQuery = { } export type AppDescribeResponse = { - info?: AppDescribeInfo + info?: AppDescribeInfo | null input_schema?: { [key: string]: unknown } | null @@ -77,7 +77,7 @@ export type AppInfoResponse = { export type AppListQuery = { limit?: number - mode?: AppMode + mode?: AppMode | null name?: string | null page?: number tag?: string | null @@ -168,6 +168,34 @@ export type DevicePollRequest = { device_code: string } +export type DeviceTokenResponse = { + account?: AccountPayload | null + default_workspace_id?: string | null + expires_at: string + subject_email?: string | null + subject_issuer?: string | null + subject_type: 'account' | 'external_sso' + token: string + token_id: string + workspaces?: Array +} + +export type ErrorBody = { + code: string + details?: Array | null + hint?: string | null + message: string + status: number +} + +export type ErrorDetail = { + loc?: Array + msg: string + type: string +} + +export type EventStreamResponse = string + export type FileResponse = { conversation_id?: string | null created_at?: number | null @@ -201,6 +229,20 @@ export type HealthResponse = { ok: boolean } +export type HumanInputFormDefinitionResponse = { + expiration_time?: number | null + form_content: string + inputs?: Array<{ + [key: string]: unknown + }> + resolved_default_values: { + [key: string]: string + } + user_actions?: Array<{ + [key: string]: unknown + }> +} + export type HumanInputFormSubmitPayload = { action: string inputs: { @@ -228,7 +270,7 @@ export type Marketplace = { } export type MemberActionResponse = { - result?: string + result?: 'success' } export type MemberInvitePayload = { @@ -240,7 +282,7 @@ export type MemberInviteResponse = { email: string invite_url: string member_id: string - result?: string + result?: 'success' role: string tenant_id: string } @@ -275,9 +317,40 @@ export type MessageMetadata = { retriever_resources?: Array<{ [key: string]: unknown }> - usage?: UsageInfo + usage?: UsageInfo | null } +export type OpenApiErrorCode + = | 'app_unavailable' + | 'bad_gateway' + | 'bad_request' + | 'completion_request_error' + | 'conflict' + | 'conversation_completed' + | 'file_extension_blocked' + | 'file_too_large' + | 'filename_not_exists' + | 'forbidden' + | 'internal_server_error' + | 'invalid_param' + | 'member_license_exceeded' + | 'member_limit_exceeded' + | 'method_not_allowed' + | 'model_currently_not_support' + | 'no_file_uploaded' + | 'not_acceptable' + | 'not_found' + | 'provider_not_initialize' + | 'provider_quota_exceeded' + | 'rate_limit_error' + | 'request_entity_too_large' + | 'too_many_files' + | 'too_many_requests' + | 'unauthorized' + | 'unknown' + | 'unsupported_file_type' + | 'unsupported_media_type' + export type Package = { plugin_unique_identifier: string version?: string | null @@ -285,7 +358,7 @@ export type Package = { export type PermittedExternalAppsListQuery = { limit?: number - mode?: AppMode + mode?: AppMode | null name?: string | null page?: number } @@ -301,7 +374,7 @@ export type PermittedExternalAppsListResponse = { export type PluginDependency = { current_identifier?: string | null type: Type - value: unknown + value: Github | Marketplace | Package } export type RevokeResponse = { @@ -341,7 +414,7 @@ export type TagItem = { } export type TaskStopResponse = { - result: string + result: 'success' } export type Type = 'github' | 'marketplace' | 'package' @@ -401,6 +474,12 @@ export type GetHealthData = { url: '/_health' } +export type GetHealthErrors = { + default: ErrorBody +} + +export type GetHealthError = GetHealthErrors[keyof GetHealthErrors] + export type GetHealthResponses = { 200: HealthResponse } @@ -414,6 +493,12 @@ export type GetVersionData = { url: '/_version' } +export type GetVersionErrors = { + default: ErrorBody +} + +export type GetVersionError = GetVersionErrors[keyof GetVersionErrors] + export type GetVersionResponses = { 200: ServerVersionResponse } @@ -427,6 +512,12 @@ export type GetAccountData = { url: '/account' } +export type GetAccountErrors = { + default: ErrorBody +} + +export type GetAccountError = GetAccountErrors[keyof GetAccountErrors] + export type GetAccountResponses = { 200: AccountResponse } @@ -443,6 +534,13 @@ export type GetAccountSessionsData = { url: '/account/sessions' } +export type GetAccountSessionsErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type GetAccountSessionsError = GetAccountSessionsErrors[keyof GetAccountSessionsErrors] + export type GetAccountSessionsResponses = { 200: SessionListResponse } @@ -457,6 +555,13 @@ export type DeleteAccountSessionsSelfData = { url: '/account/sessions/self' } +export type DeleteAccountSessionsSelfErrors = { + default: ErrorBody +} + +export type DeleteAccountSessionsSelfError + = DeleteAccountSessionsSelfErrors[keyof DeleteAccountSessionsSelfErrors] + export type DeleteAccountSessionsSelfResponses = { 200: RevokeResponse } @@ -473,6 +578,13 @@ export type DeleteAccountSessionsBySessionIdData = { url: '/account/sessions/{session_id}' } +export type DeleteAccountSessionsBySessionIdErrors = { + default: ErrorBody +} + +export type DeleteAccountSessionsBySessionIdError + = DeleteAccountSessionsBySessionIdErrors[keyof DeleteAccountSessionsBySessionIdErrors] + export type DeleteAccountSessionsBySessionIdResponses = { 200: RevokeResponse } @@ -485,7 +597,15 @@ export type GetAppsData = { path?: never query: { limit?: number - mode?: string + mode?: + | 'advanced-chat' + | 'agent' + | 'agent-chat' + | 'channel' + | 'chat' + | 'completion' + | 'rag-pipeline' + | 'workflow' name?: string page?: number tag?: string @@ -494,6 +614,13 @@ export type GetAppsData = { url: '/apps' } +export type GetAppsErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type GetAppsError = GetAppsErrors[keyof GetAppsErrors] + export type GetAppsResponses = { 200: AppListResponse } @@ -509,6 +636,13 @@ export type GetAppsByAppIdCheckDependenciesData = { url: '/apps/{app_id}/check-dependencies' } +export type GetAppsByAppIdCheckDependenciesErrors = { + default: ErrorBody +} + +export type GetAppsByAppIdCheckDependenciesError + = GetAppsByAppIdCheckDependenciesErrors[keyof GetAppsByAppIdCheckDependenciesErrors] + export type GetAppsByAppIdCheckDependenciesResponses = { 200: CheckDependenciesResult } @@ -527,6 +661,14 @@ export type GetAppsByAppIdDescribeData = { url: '/apps/{app_id}/describe' } +export type GetAppsByAppIdDescribeErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type GetAppsByAppIdDescribeError + = GetAppsByAppIdDescribeErrors[keyof GetAppsByAppIdDescribeErrors] + export type GetAppsByAppIdDescribeResponses = { 200: AppDescribeResponse } @@ -546,6 +688,13 @@ export type GetAppsByAppIdExportData = { url: '/apps/{app_id}/export' } +export type GetAppsByAppIdExportErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type GetAppsByAppIdExportError = GetAppsByAppIdExportErrors[keyof GetAppsByAppIdExportErrors] + export type GetAppsByAppIdExportResponses = { 200: AppDslExportResponse } @@ -563,18 +712,11 @@ export type PostAppsByAppIdFilesUploadData = { } export type PostAppsByAppIdFilesUploadErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 413: { - [key: string]: unknown - } - 415: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 413: unknown + 415: unknown + default: ErrorBody } export type PostAppsByAppIdFilesUploadError @@ -598,9 +740,7 @@ export type GetAppsByAppIdFormHumanInputByFormTokenData = { } export type GetAppsByAppIdFormHumanInputByFormTokenResponses = { - 200: { - [key: string]: unknown - } + 200: HumanInputFormDefinitionResponse } export type GetAppsByAppIdFormHumanInputByFormTokenResponse @@ -616,6 +756,14 @@ export type PostAppsByAppIdFormHumanInputByFormTokenData = { url: '/apps/{app_id}/form/human_input/{form_token}' } +export type PostAppsByAppIdFormHumanInputByFormTokenErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PostAppsByAppIdFormHumanInputByFormTokenError + = PostAppsByAppIdFormHumanInputByFormTokenErrors[keyof PostAppsByAppIdFormHumanInputByFormTokenErrors] + export type PostAppsByAppIdFormHumanInputByFormTokenResponses = { 200: FormSubmitResponse } @@ -632,10 +780,14 @@ export type PostAppsByAppIdRunData = { url: '/apps/{app_id}/run' } +export type PostAppsByAppIdRunErrors = { + 422: ErrorBody +} + +export type PostAppsByAppIdRunError = PostAppsByAppIdRunErrors[keyof PostAppsByAppIdRunErrors] + export type PostAppsByAppIdRunResponses = { - 200: { - [key: string]: unknown - } + 200: EventStreamResponse } export type PostAppsByAppIdRunResponse @@ -647,14 +799,15 @@ export type GetAppsByAppIdTasksByTaskIdEventsData = { app_id: string task_id: string } - query?: never + query?: { + continue_on_pause?: boolean + include_state_snapshot?: boolean + } url: '/apps/{app_id}/tasks/{task_id}/events' } export type GetAppsByAppIdTasksByTaskIdEventsResponses = { - 200: { - [key: string]: unknown - } + 200: EventStreamResponse } export type GetAppsByAppIdTasksByTaskIdEventsResponse @@ -670,6 +823,13 @@ export type PostAppsByAppIdTasksByTaskIdStopData = { url: '/apps/{app_id}/tasks/{task_id}/stop' } +export type PostAppsByAppIdTasksByTaskIdStopErrors = { + default: ErrorBody +} + +export type PostAppsByAppIdTasksByTaskIdStopError + = PostAppsByAppIdTasksByTaskIdStopErrors[keyof PostAppsByAppIdTasksByTaskIdStopErrors] + export type PostAppsByAppIdTasksByTaskIdStopResponses = { 200: TaskStopResponse } @@ -743,9 +903,7 @@ export type PostOauthDeviceTokenData = { } export type PostOauthDeviceTokenResponses = { - 200: { - [key: string]: unknown - } + 200: DeviceTokenResponse } export type PostOauthDeviceTokenResponse @@ -756,13 +914,29 @@ export type GetPermittedExternalAppsData = { path?: never query?: { limit?: number - mode?: string + mode?: + | 'advanced-chat' + | 'agent' + | 'agent-chat' + | 'channel' + | 'chat' + | 'completion' + | 'rag-pipeline' + | 'workflow' name?: string page?: number } url: '/permitted-external-apps' } +export type GetPermittedExternalAppsErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type GetPermittedExternalAppsError + = GetPermittedExternalAppsErrors[keyof GetPermittedExternalAppsErrors] + export type GetPermittedExternalAppsResponses = { 200: PermittedExternalAppsListResponse } @@ -777,6 +951,12 @@ export type GetWorkspacesData = { url: '/workspaces' } +export type GetWorkspacesErrors = { + default: ErrorBody +} + +export type GetWorkspacesError = GetWorkspacesErrors[keyof GetWorkspacesErrors] + export type GetWorkspacesResponses = { 200: WorkspaceListResponse } @@ -792,6 +972,13 @@ export type GetWorkspacesByWorkspaceIdData = { url: '/workspaces/{workspace_id}' } +export type GetWorkspacesByWorkspaceIdErrors = { + default: ErrorBody +} + +export type GetWorkspacesByWorkspaceIdError + = GetWorkspacesByWorkspaceIdErrors[keyof GetWorkspacesByWorkspaceIdErrors] + export type GetWorkspacesByWorkspaceIdResponses = { 200: WorkspaceDetailResponse } @@ -810,6 +997,8 @@ export type PostWorkspacesByWorkspaceIdAppsImportsData = { export type PostWorkspacesByWorkspaceIdAppsImportsErrors = { 400: Import + 422: ErrorBody + default: ErrorBody } export type PostWorkspacesByWorkspaceIdAppsImportsError @@ -835,6 +1024,7 @@ export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmData = { export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmErrors = { 400: Import + default: ErrorBody } export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmError @@ -859,6 +1049,14 @@ export type GetWorkspacesByWorkspaceIdMembersData = { url: '/workspaces/{workspace_id}/members' } +export type GetWorkspacesByWorkspaceIdMembersErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type GetWorkspacesByWorkspaceIdMembersError + = GetWorkspacesByWorkspaceIdMembersErrors[keyof GetWorkspacesByWorkspaceIdMembersErrors] + export type GetWorkspacesByWorkspaceIdMembersResponses = { 200: MemberListResponse } @@ -875,6 +1073,14 @@ export type PostWorkspacesByWorkspaceIdMembersData = { url: '/workspaces/{workspace_id}/members' } +export type PostWorkspacesByWorkspaceIdMembersErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PostWorkspacesByWorkspaceIdMembersError + = PostWorkspacesByWorkspaceIdMembersErrors[keyof PostWorkspacesByWorkspaceIdMembersErrors] + export type PostWorkspacesByWorkspaceIdMembersResponses = { 201: MemberInviteResponse } @@ -892,6 +1098,13 @@ export type DeleteWorkspacesByWorkspaceIdMembersByMemberIdData = { url: '/workspaces/{workspace_id}/members/{member_id}' } +export type DeleteWorkspacesByWorkspaceIdMembersByMemberIdErrors = { + default: ErrorBody +} + +export type DeleteWorkspacesByWorkspaceIdMembersByMemberIdError + = DeleteWorkspacesByWorkspaceIdMembersByMemberIdErrors[keyof DeleteWorkspacesByWorkspaceIdMembersByMemberIdErrors] + export type DeleteWorkspacesByWorkspaceIdMembersByMemberIdResponses = { 200: MemberActionResponse } @@ -909,6 +1122,14 @@ export type PutWorkspacesByWorkspaceIdMembersByMemberIdRoleData = { url: '/workspaces/{workspace_id}/members/{member_id}/role' } +export type PutWorkspacesByWorkspaceIdMembersByMemberIdRoleErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PutWorkspacesByWorkspaceIdMembersByMemberIdRoleError + = PutWorkspacesByWorkspaceIdMembersByMemberIdRoleErrors[keyof PutWorkspacesByWorkspaceIdMembersByMemberIdRoleErrors] + export type PutWorkspacesByWorkspaceIdMembersByMemberIdRoleResponses = { 200: MemberActionResponse } @@ -925,6 +1146,13 @@ export type PostWorkspacesByWorkspaceIdSwitchData = { url: '/workspaces/{workspace_id}/switch' } +export type PostWorkspacesByWorkspaceIdSwitchErrors = { + default: ErrorBody +} + +export type PostWorkspacesByWorkspaceIdSwitchError + = PostWorkspacesByWorkspaceIdSwitchErrors[keyof PostWorkspacesByWorkspaceIdSwitchErrors] + export type PostWorkspacesByWorkspaceIdSwitchResponses = { 200: WorkspaceDetailResponse } diff --git a/packages/contracts/generated/api/openapi/zod.gen.ts b/packages/contracts/generated/api/openapi/zod.gen.ts index 2d632814d06..df0d82117a0 100644 --- a/packages/contracts/generated/api/openapi/zod.gen.ts +++ b/packages/contracts/generated/api/openapi/zod.gen.ts @@ -79,7 +79,7 @@ export const zAppMode = z.enum([ */ export const zAppListQuery = z.object({ limit: z.int().gte(1).lte(200).optional().default(20), - mode: zAppMode.optional(), + mode: zAppMode.nullish(), name: z.string().max(200).nullish(), page: z.int().gte(1).optional().default(1), tag: z.string().max(100).nullish(), @@ -156,6 +156,38 @@ export const zDevicePollRequest = z.object({ device_code: z.string(), }) +/** + * ErrorDetail + */ +export const zErrorDetail = z.object({ + loc: z + .array(z.union([z.string(), z.int()])) + .optional() + .default([]), + msg: z.string(), + type: z.string(), +}) + +/** + * ErrorBody + * + * Canonical non-2xx body. ``code`` is typed ``str`` (not the enum) so the + * generated client schema stays an open enum — old CLIs keep parsing when a + * future server adds a code. Formatter tests pin emitted values to the enum. + */ +export const zErrorBody = z.object({ + code: z.string(), + details: z.array(zErrorDetail).nullish(), + hint: z.string().nullish(), + message: z.string(), + status: z.int(), +}) + +/** + * EventStreamResponse + */ +export const zEventStreamResponse = z.string() + /** * FileResponse */ @@ -205,6 +237,17 @@ export const zHealthResponse = z.object({ ok: z.boolean(), }) +/** + * HumanInputFormDefinitionResponse + */ +export const zHumanInputFormDefinitionResponse = z.object({ + expiration_time: z.int().nullish(), + form_content: z.string(), + inputs: z.array(z.record(z.string(), z.unknown())).optional(), + resolved_default_values: z.record(z.string(), z.string()), + user_actions: z.array(z.record(z.string(), z.unknown())).optional(), +}) + /** * ImportStatus */ @@ -245,7 +288,7 @@ export const zMarketplace = z.object({ * MemberActionResponse */ export const zMemberActionResponse = z.object({ - result: z.string().optional().default('success'), + result: z.literal('success').optional().default('success'), }) /** @@ -263,7 +306,7 @@ export const zMemberInviteResponse = z.object({ email: z.string(), invite_url: z.string(), member_id: z.string(), - result: z.string().optional().default('success'), + result: z.literal('success').optional().default('success'), role: z.string(), tenant_id: z.string(), }) @@ -308,6 +351,41 @@ export const zMemberRoleUpdatePayload = z.object({ role: z.enum(['admin', 'normal']), }) +/** + * OpenApiErrorCode + */ +export const zOpenApiErrorCode = z.enum([ + 'app_unavailable', + 'bad_gateway', + 'bad_request', + 'completion_request_error', + 'conflict', + 'conversation_completed', + 'file_extension_blocked', + 'file_too_large', + 'filename_not_exists', + 'forbidden', + 'internal_server_error', + 'invalid_param', + 'member_license_exceeded', + 'member_limit_exceeded', + 'method_not_allowed', + 'model_currently_not_support', + 'no_file_uploaded', + 'not_acceptable', + 'not_found', + 'provider_not_initialize', + 'provider_quota_exceeded', + 'rate_limit_error', + 'request_entity_too_large', + 'too_many_files', + 'too_many_requests', + 'unauthorized', + 'unknown', + 'unsupported_file_type', + 'unsupported_media_type', +]) + /** * Package */ @@ -323,7 +401,7 @@ export const zPackage = z.object({ */ export const zPermittedExternalAppsListQuery = z.object({ limit: z.int().gte(1).lte(200).optional().default(20), - mode: zAppMode.optional(), + mode: zAppMode.nullish(), name: z.string().max(200).nullish(), page: z.int().gte(1).optional().default(1), }) @@ -405,7 +483,7 @@ export const zAppDescribeInfo = z.object({ * AppDescribeResponse */ export const zAppDescribeResponse = z.object({ - info: zAppDescribeInfo.optional(), + info: zAppDescribeInfo.nullish(), input_schema: z.record(z.string(), z.unknown()).nullish(), parameters: z.record(z.string(), z.unknown()).nullish(), }) @@ -467,7 +545,7 @@ export const zPermittedExternalAppsListResponse = z.object({ * types it as a required `'success'` rather than an optional field. */ export const zTaskStopResponse = z.object({ - result: z.string(), + result: z.literal('success'), }) /** @@ -481,7 +559,7 @@ export const zType = z.enum(['github', 'marketplace', 'package']) export const zPluginDependency = z.object({ current_identifier: z.string().nullish(), type: zType, - value: z.unknown(), + value: z.union([zGithub, zMarketplace, zPackage]), }) /** @@ -505,7 +583,7 @@ export const zUsageInfo = z.object({ */ export const zMessageMetadata = z.object({ retriever_resources: z.array(z.record(z.string(), z.unknown())).optional().default([]), - usage: zUsageInfo.optional(), + usage: zUsageInfo.nullish(), }) /** @@ -549,7 +627,7 @@ export const zWorkspacePayload = z.object({ * AccountResponse */ export const zAccountResponse = z.object({ - account: zAccountPayload.optional(), + account: zAccountPayload.nullish(), default_workspace_id: z.string().nullish(), subject_email: z.string().nullish(), subject_issuer: z.string().nullish(), @@ -557,6 +635,21 @@ export const zAccountResponse = z.object({ workspaces: z.array(zWorkspacePayload).optional().default([]), }) +/** + * DeviceTokenResponse + */ +export const zDeviceTokenResponse = z.object({ + account: zAccountPayload.nullish(), + default_workspace_id: z.string().nullish(), + expires_at: z.string(), + subject_email: z.string().nullish(), + subject_issuer: z.string().nullish(), + subject_type: z.enum(['account', 'external_sso']), + token: z.string(), + token_id: z.string(), + workspaces: z.array(zWorkspacePayload).optional().default([]), +}) + /** * WorkspaceSummaryResponse */ @@ -616,7 +709,18 @@ export const zDeleteAccountSessionsBySessionIdResponse = zRevokeResponse export const zGetAppsQuery = z.object({ limit: z.int().gte(1).lte(200).optional().default(20), - mode: z.string().optional(), + mode: z + .enum([ + 'advanced-chat', + 'agent', + 'agent-chat', + 'channel', + 'chat', + 'completion', + 'rag-pipeline', + 'workflow', + ]) + .optional(), name: z.string().max(200).optional(), page: z.int().gte(1).optional().default(1), tag: z.string().max(100).optional(), @@ -681,7 +785,7 @@ export const zGetAppsByAppIdFormHumanInputByFormTokenPath = z.object({ /** * Form definition */ -export const zGetAppsByAppIdFormHumanInputByFormTokenResponse = z.record(z.string(), z.unknown()) +export const zGetAppsByAppIdFormHumanInputByFormTokenResponse = zHumanInputFormDefinitionResponse export const zPostAppsByAppIdFormHumanInputByFormTokenBody = zHumanInputFormSubmitPayload @@ -704,17 +808,22 @@ export const zPostAppsByAppIdRunPath = z.object({ /** * Run result (SSE stream) */ -export const zPostAppsByAppIdRunResponse = z.record(z.string(), z.unknown()) +export const zPostAppsByAppIdRunResponse = zEventStreamResponse export const zGetAppsByAppIdTasksByTaskIdEventsPath = z.object({ app_id: z.string(), task_id: z.string(), }) +export const zGetAppsByAppIdTasksByTaskIdEventsQuery = z.object({ + continue_on_pause: z.boolean().optional().default(false), + include_state_snapshot: z.boolean().optional().default(false), +}) + /** * SSE event stream */ -export const zGetAppsByAppIdTasksByTaskIdEventsResponse = z.record(z.string(), z.unknown()) +export const zGetAppsByAppIdTasksByTaskIdEventsResponse = zEventStreamResponse export const zPostAppsByAppIdTasksByTaskIdStopPath = z.object({ app_id: z.string(), @@ -759,13 +868,24 @@ export const zGetOauthDeviceLookupResponse = zDeviceLookupResponse export const zPostOauthDeviceTokenBody = zDevicePollRequest /** - * Success + * Device token */ -export const zPostOauthDeviceTokenResponse = z.record(z.string(), z.unknown()) +export const zPostOauthDeviceTokenResponse = zDeviceTokenResponse export const zGetPermittedExternalAppsQuery = z.object({ limit: z.int().gte(1).lte(200).optional().default(20), - mode: z.string().optional(), + mode: z + .enum([ + 'advanced-chat', + 'agent', + 'agent-chat', + 'channel', + 'chat', + 'completion', + 'rag-pipeline', + 'workflow', + ]) + .optional(), name: z.string().max(200).optional(), page: z.int().gte(1).optional().default(1), }) diff --git a/packages/contracts/generated/api/service/orpc.gen.ts b/packages/contracts/generated/api/service/orpc.gen.ts index 940b844d25e..518b44e06ea 100644 --- a/packages/contracts/generated/api/service/orpc.gen.ts +++ b/packages/contracts/generated/api/service/orpc.gen.ts @@ -36,6 +36,7 @@ import { zGetDatasetsByDatasetIdDocumentsByDocumentIdDownloadPath, zGetDatasetsByDatasetIdDocumentsByDocumentIdDownloadResponse, zGetDatasetsByDatasetIdDocumentsByDocumentIdPath, + zGetDatasetsByDatasetIdDocumentsByDocumentIdQuery, zGetDatasetsByDatasetIdDocumentsByDocumentIdResponse, zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksPath, zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksQuery, @@ -94,6 +95,7 @@ import { zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdBody, zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdPath, zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponse, + zPatchDatasetsByDatasetIdDocumentsStatusByActionBody, zPatchDatasetsByDatasetIdDocumentsStatusByActionPath, zPatchDatasetsByDatasetIdDocumentsStatusByActionResponse, zPatchDatasetsByDatasetIdMetadataByMetadataIdBody, @@ -168,8 +170,10 @@ import { zPostDatasetsByDatasetIdMetadataBuiltInByActionResponse, zPostDatasetsByDatasetIdMetadataPath, zPostDatasetsByDatasetIdMetadataResponse, + zPostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunBody, zPostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunPath, zPostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunResponse, + zPostDatasetsByDatasetIdPipelineRunBody, zPostDatasetsByDatasetIdPipelineRunPath, zPostDatasetsByDatasetIdPipelineRunResponse, zPostDatasetsByDatasetIdRetrieveBody, @@ -226,16 +230,11 @@ export const root = { * * Get all feedbacks for the application * Returns paginated list of all feedback submitted for messages in this app. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get2 = oc .route({ - deprecated: true, description: - 'Get all feedbacks for the application\nReturns paginated list of all feedback submitted for messages in this app.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + 'Get all feedbacks for the application\nReturns paginated list of all feedback submitted for messages in this app.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppFeedbacks', @@ -258,16 +257,10 @@ export const app = { * Get the status of an annotation reply action job * * Get the status of an annotation reply action job - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get3 = oc .route({ - deprecated: true, - description: - 'Get the status of an annotation reply action job\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get the status of an annotation reply action job', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsAnnotationReplyByActionStatusByJobId', @@ -290,16 +283,10 @@ export const status = { * Enable or disable annotation reply feature * * Enable or disable annotation reply feature - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post = oc .route({ - deprecated: true, - description: - 'Enable or disable annotation reply feature\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Enable or disable annotation reply feature', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsAnnotationReplyByAction', @@ -424,16 +411,11 @@ export const apps = { * * Convert audio to text using speech-to-text * Accepts an audio file upload and returns the transcribed text. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post3 = oc .route({ - deprecated: true, description: - 'Convert audio to text using speech-to-text\nAccepts an audio file upload and returns the transcribed text.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + 'Convert audio to text using speech-to-text\nAccepts an audio file upload and returns the transcribed text.', inputStructure: 'detailed', method: 'POST', operationId: 'postAudioToText', @@ -479,16 +461,11 @@ export const byTaskId = { * Send a message in a chat conversation * This endpoint handles chat messages for chat, agent chat, and advanced chat applications. * Supports conversation management and both blocking and streaming response modes. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post5 = oc .route({ - deprecated: true, description: - 'Send a message in a chat conversation\nThis endpoint handles chat messages for chat, agent chat, and advanced chat applications.\nSupports conversation management and both blocking and streaming response modes.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + 'Send a message in a chat conversation\nThis endpoint handles chat messages for chat, agent chat, and advanced chat applications.\nSupports conversation management and both blocking and streaming response modes.', inputStructure: 'detailed', method: 'POST', operationId: 'postChatMessages', @@ -536,16 +513,11 @@ export const byTaskId2 = { * Create a completion for the given prompt * This endpoint generates a completion based on the provided inputs and query. * Supports both blocking and streaming response modes. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post7 = oc .route({ - deprecated: true, description: - 'Create a completion for the given prompt\nThis endpoint generates a completion based on the provided inputs and query.\nSupports both blocking and streaming response modes.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + 'Create a completion for the given prompt\nThis endpoint generates a completion based on the provided inputs and query.\nSupports both blocking and streaming response modes.', inputStructure: 'detailed', method: 'POST', operationId: 'postCompletionMessages', @@ -565,16 +537,10 @@ export const completionMessages = { * Rename a conversation or auto-generate a name * * Rename a conversation or auto-generate a name - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post8 = oc .route({ - deprecated: true, - description: - 'Rename a conversation or auto-generate a name\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Rename a conversation or auto-generate a name', inputStructure: 'detailed', method: 'POST', operationId: 'postConversationsByCIdName', @@ -681,16 +647,11 @@ export const byCId = { * * List all conversations for the current user * Supports pagination using last_id and limit parameters. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get6 = oc .route({ - deprecated: true, description: - 'List all conversations for the current user\nSupports pagination using last_id and limit parameters.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + 'List all conversations for the current user\nSupports pagination using last_id and limit parameters.', inputStructure: 'detailed', method: 'GET', operationId: 'getConversations', @@ -711,16 +672,11 @@ export const conversations = { * * Upload a file to a knowledgebase pipeline * Accepts a single file upload via multipart/form-data. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post9 = oc .route({ - deprecated: true, description: - 'Upload a file to a knowledgebase pipeline\nAccepts a single file upload via multipart/form-data.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + 'Upload a file to a knowledgebase pipeline\nAccepts a single file upload via multipart/form-data.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsPipelineFileUpload', @@ -956,16 +912,10 @@ export const document_ = { /** * Download selected uploaded documents as a single ZIP archive - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post17 = oc .route({ - deprecated: true, - description: - 'Download selected uploaded documents as a single ZIP archive\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Download selected uploaded documents as a single ZIP archive', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdDocumentsDownloadZip', @@ -1028,16 +978,11 @@ export const metadata = { * NotFound: If the dataset with the given ID does not exist. * Forbidden: If the user does not have permission. * InvalidActionError: If the action is invalid or cannot be performed. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const patch2 = oc .route({ - deprecated: true, description: - 'Batch update document status\nArgs:\n tenant_id: tenant id\n dataset_id: dataset id\n action: action to perform (Literal["enable", "disable", "archive", "un_archive"])\n\nReturns:\n dict: A dictionary with a key \'result\' and a value \'success\'\n int: HTTP status code 200 indicating that the operation was successful.\n\nRaises:\n NotFound: If the dataset with the given ID does not exist.\n Forbidden: If the user does not have permission.\n InvalidActionError: If the action is invalid or cannot be performed.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + 'Batch update document status\nArgs:\n tenant_id: tenant id\n dataset_id: dataset id\n action: action to perform (Literal["enable", "disable", "archive", "un_archive"])\n\nReturns:\n dict: A dictionary with a key \'result\' and a value \'success\'\n int: HTTP status code 200 indicating that the operation was successful.\n\nRaises:\n NotFound: If the dataset with the given ID does not exist.\n Forbidden: If the user does not have permission.\n InvalidActionError: If the action is invalid or cannot be performed.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchDatasetsByDatasetIdDocumentsStatusByAction', @@ -1045,7 +990,12 @@ export const patch2 = oc summary: 'Batch update document status', tags: ['service_api'], }) - .input(z.object({ params: zPatchDatasetsByDatasetIdDocumentsStatusByActionPath })) + .input( + z.object({ + body: zPatchDatasetsByDatasetIdDocumentsStatusByActionBody, + params: zPatchDatasetsByDatasetIdDocumentsStatusByActionPath, + }), + ) .output(zPatchDatasetsByDatasetIdDocumentsStatusByActionResponse) export const byAction2 = { @@ -1425,23 +1375,22 @@ export const delete6 = oc /** * Get a specific document by ID - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get13 = oc .route({ - deprecated: true, - description: - 'Get a specific document by ID\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get a specific document by ID', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdDocumentsByDocumentId', path: '/datasets/{dataset_id}/documents/{document_id}', tags: ['service_api'], }) - .input(z.object({ params: zGetDatasetsByDatasetIdDocumentsByDocumentIdPath })) + .input( + z.object({ + params: zGetDatasetsByDatasetIdDocumentsByDocumentIdPath, + query: zGetDatasetsByDatasetIdDocumentsByDocumentIdQuery.optional(), + }), + ) .output(zGetDatasetsByDatasetIdDocumentsByDocumentIdResponse) /** @@ -1508,16 +1457,11 @@ export const documents = { * * Perform hit testing on a dataset * Tests retrieval performance for the specified dataset. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post26 = oc .route({ - deprecated: true, description: - 'Perform hit testing on a dataset\nTests retrieval performance for the specified dataset.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + 'Perform hit testing on a dataset\nTests retrieval performance for the specified dataset.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdHitTesting', @@ -1682,16 +1626,10 @@ export const metadata2 = { * Resource for getting datasource plugins * * List all datasource plugins for a rag pipeline - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get17 = oc .route({ - deprecated: true, - description: - 'List all datasource plugins for a rag pipeline\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'List all datasource plugins for a rag pipeline', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdPipelineDatasourcePlugins', @@ -1715,16 +1653,10 @@ export const datasourcePlugins = { * Resource for getting datasource plugins * * Run a datasource node for a rag pipeline - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post29 = oc .route({ - deprecated: true, - description: - 'Run a datasource node for a rag pipeline\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Run a datasource node for a rag pipeline', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRun', @@ -1732,7 +1664,12 @@ export const post29 = oc summary: 'Resource for getting datasource plugins', tags: ['service_api'], }) - .input(z.object({ params: zPostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunPath })) + .input( + z.object({ + body: zPostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunBody, + params: zPostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunPath, + }), + ) .output(zPostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunResponse) export const run = { @@ -1755,16 +1692,10 @@ export const datasource = { * Resource for running a rag pipeline * * Run a datasource node for a rag pipeline - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post30 = oc .route({ - deprecated: true, - description: - 'Run a datasource node for a rag pipeline\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Run a datasource node for a rag pipeline', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdPipelineRun', @@ -1772,7 +1703,12 @@ export const post30 = oc summary: 'Resource for running a rag pipeline', tags: ['service_api'], }) - .input(z.object({ params: zPostDatasetsByDatasetIdPipelineRunPath })) + .input( + z.object({ + body: zPostDatasetsByDatasetIdPipelineRunBody, + params: zPostDatasetsByDatasetIdPipelineRunPath, + }), + ) .output(zPostDatasetsByDatasetIdPipelineRunResponse) export const run2 = { @@ -1790,16 +1726,11 @@ export const pipeline2 = { * * Perform hit testing on a dataset * Tests retrieval performance for the specified dataset. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post31 = oc .route({ - deprecated: true, description: - 'Perform hit testing on a dataset\nTests retrieval performance for the specified dataset.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + 'Perform hit testing on a dataset\nTests retrieval performance for the specified dataset.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdRetrieve', @@ -1889,16 +1820,10 @@ export const get19 = oc /** * Update an existing dataset - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const patch6 = oc .route({ - deprecated: true, - description: - 'Update an existing dataset\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Update an existing dataset', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchDatasetsByDatasetId', @@ -1943,16 +1868,10 @@ export const get20 = oc * Resource for creating datasets * * Create a new dataset - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post32 = oc .route({ - deprecated: true, - description: - 'Create a new dataset\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Create a new dataset', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasets', @@ -2030,16 +1949,11 @@ export const upload = { * Preview or download a file uploaded via Service API * Provides secure file preview/download functionality. * Files can only be accessed if they belong to messages within the requesting app's context. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get22 = oc .route({ - deprecated: true, description: - 'Preview or download a file uploaded via Service API\nProvides secure file preview/download functionality.\nFiles can only be accessed if they belong to messages within the requesting app\'s context.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + 'Preview or download a file uploaded via Service API\nProvides secure file preview/download functionality.\nFiles can only be accessed if they belong to messages within the requesting app\'s context.', inputStructure: 'detailed', method: 'GET', operationId: 'getFilesByFileIdPreview', @@ -2070,16 +1984,10 @@ export const files = { /** * Get a paused human input form by token - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get23 = oc .route({ - deprecated: true, - description: - 'Get a paused human input form by token\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get a paused human input form by token', inputStructure: 'detailed', method: 'GET', operationId: 'getFormHumanInputByFormToken', @@ -2091,16 +1999,10 @@ export const get23 = oc /** * Submit a paused human input form by token - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post34 = oc .route({ - deprecated: true, - description: - 'Submit a paused human input form by token\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Submit a paused human input form by token', inputStructure: 'detailed', method: 'POST', operationId: 'postFormHumanInputByFormToken', @@ -2214,16 +2116,11 @@ export const byMessageId = { * * List messages in a conversation * Retrieves messages with pagination support using first_id. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get26 = oc .route({ - deprecated: true, description: - 'List messages in a conversation\nRetrieves messages with pagination support using first_id.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + 'List messages in a conversation\nRetrieves messages with pagination support using first_id.', inputStructure: 'detailed', method: 'GET', operationId: 'getMessages', @@ -2244,16 +2141,11 @@ export const messages = { * * Get application metadata * Returns metadata about the application including configuration and settings. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get27 = oc .route({ - deprecated: true, description: - 'Get application metadata\nReturns metadata about the application including configuration and settings.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + 'Get application metadata\nReturns metadata about the application including configuration and settings.', inputStructure: 'detailed', method: 'GET', operationId: 'getMeta', @@ -2272,16 +2164,11 @@ export const meta = { * * Retrieve application input parameters and configuration * Returns the input form parameters and configuration for the application. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get28 = oc .route({ - deprecated: true, description: - 'Retrieve application input parameters and configuration\nReturns the input form parameters and configuration for the application.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + 'Retrieve application input parameters and configuration\nReturns the input form parameters and configuration for the application.', inputStructure: 'detailed', method: 'GET', operationId: 'getParameters', @@ -2323,16 +2210,11 @@ export const site = { * * Convert text to audio using text-to-speech * Converts the provided text to audio using the specified voice. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post36 = oc .route({ - deprecated: true, description: - 'Convert text to audio using text-to-speech\nConverts the provided text to audio using the specified voice.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + 'Convert text to audio using text-to-speech\nConverts the provided text to audio using the specified voice.', inputStructure: 'detailed', method: 'POST', operationId: 'postTextToAudio', @@ -2349,16 +2231,10 @@ export const textToAudio = { /** * Get workflow execution events stream after resume - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get30 = oc .route({ - deprecated: true, - description: - 'Get workflow execution events stream after resume\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get workflow execution events stream after resume', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkflowByTaskIdEvents', @@ -2366,10 +2242,7 @@ export const get30 = oc tags: ['service_api'], }) .input( - z.object({ - params: zGetWorkflowByTaskIdEventsPath, - query: zGetWorkflowByTaskIdEventsQuery.optional(), - }), + z.object({ params: zGetWorkflowByTaskIdEventsPath, query: zGetWorkflowByTaskIdEventsQuery }), ) .output(zGetWorkflowByTaskIdEventsResponse) @@ -2390,16 +2263,11 @@ export const workflow = { * * Get workflow execution logs * Returns paginated workflow execution logs with filtering options. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get31 = oc .route({ - deprecated: true, description: - 'Get workflow execution logs\nReturns paginated workflow execution logs with filtering options.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + 'Get workflow execution logs\nReturns paginated workflow execution logs with filtering options.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkflowsLogs', @@ -2419,16 +2287,11 @@ export const logs = { * * Get workflow run details * Returns detailed information about a specific workflow run. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get32 = oc .route({ - deprecated: true, description: - 'Get workflow run details\nReturns detailed information about a specific workflow run.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + 'Get workflow run details\nReturns detailed information about a specific workflow run.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkflowsRunByWorkflowRunId', @@ -2449,16 +2312,11 @@ export const byWorkflowRunId = { * Execute a workflow * Runs a workflow with the provided inputs and returns the results. * Supports both blocking and streaming response modes. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post37 = oc .route({ - deprecated: true, description: - 'Execute a workflow\nRuns a workflow with the provided inputs and returns the results.\nSupports both blocking and streaming response modes.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + 'Execute a workflow\nRuns a workflow with the provided inputs and returns the results.\nSupports both blocking and streaming response modes.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkflowsRun', @@ -2509,16 +2367,11 @@ export const tasks = { * * Execute a specific workflow by ID * Executes a specific workflow version identified by its ID. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post39 = oc .route({ - deprecated: true, description: - 'Execute a specific workflow by ID\nExecutes a specific workflow version identified by its ID.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + 'Execute a specific workflow by ID\nExecutes a specific workflow version identified by its ID.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkflowsByWorkflowIdRun', @@ -2554,16 +2407,11 @@ export const workflows = { * * Get available models by model type * Returns a list of available models for the specified model type. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get33 = oc .route({ - deprecated: true, description: - 'Get available models by model type\nReturns a list of available models for the specified model type.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + 'Get available models by model type\nReturns a list of available models for the specified model type.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentModelsModelTypesByModelType', diff --git a/packages/contracts/generated/api/service/types.gen.ts b/packages/contracts/generated/api/service/types.gen.ts index a702afe5498..a3cea1127da 100644 --- a/packages/contracts/generated/api/service/types.gen.ts +++ b/packages/contracts/generated/api/service/types.gen.ts @@ -4,6 +4,20 @@ export type ClientOptions = { baseUrl: `${string}://${string}/v1` | (string & {}) } +export type AgentThought = { + chain_id?: string | null + created_at?: number | null + files: Array + id: string + message_id: string + observation?: string | null + position: number + thought?: string | null + tool?: string | null + tool_input?: string | null + tool_labels: JsonValue +} + export type Annotation = { content?: string | null created_at?: number | null @@ -17,6 +31,12 @@ export type AnnotationCreatePayload = { question: string } +export type AnnotationJobStatusResponse = { + error_msg?: string | null + job_id: string + job_status: string +} + export type AnnotationList = { data: Array has_more: boolean @@ -37,6 +57,24 @@ export type AnnotationReplyActionPayload = { score_threshold: number } +export type AppFeedbackListResponse = { + data: Array +} + +export type AppFeedbackResponse = { + app_id: string + content?: string | null + conversation_id: string + created_at: string + from_account_id?: string | null + from_end_user_id?: string | null + from_source: string + id: string + message_id: string + rating: string + updated_at: string +} + export type AppInfoResponse = { author_name: string | null description: string | null @@ -45,6 +83,22 @@ export type AppInfoResponse = { tags: Array } +export type AppMetaResponse = { + tool_icons?: { + [key: string]: unknown + } +} + +export type AudioBinaryResponse = Blob | File + +export type AudioTranscriptResponse = { + text: string +} + +export type BinaryFileResponse = Blob | File + +export type ButtonStyle = 'accent' | 'default' | 'ghost' | 'primary' + export type ChatRequestPayload = { auto_generate_name?: boolean conversation_id?: string | null @@ -132,7 +186,13 @@ export type Condition = { | '≤' | '≥' name: string - value?: unknown + value?: string | Array | number | number | null +} + +export type ConversationInfiniteScrollPagination = { + data: Array + has_more: boolean + limit: number } export type ConversationListQuery = { @@ -172,6 +232,8 @@ export type ConversationVariablesQuery = { variable_name?: string | null } +export type CustomConfigurationStatus = 'active' | 'no-configure' + export type DatasetBoundTagListResponse = { data: Array total: number @@ -190,9 +252,9 @@ export type DatasetCreatePayload = { external_knowledge_id?: string | null indexing_technique?: 'economy' | 'high_quality' | null name: string - permission?: PermissionEnum + permission?: PermissionEnum | null provider?: string - retrieval_model?: RetrievalModel + retrieval_model?: RetrievalModel | null summary_index_setting?: { [key: string]: unknown } | null @@ -215,7 +277,7 @@ export type DatasetDetailResponse = { embedding_model_provider: string | null enable_api: boolean external_knowledge_info?: DatasetExternalKnowledgeInfoResponse - external_retrieval_model: DatasetExternalRetrievalModelResponse + external_retrieval_model: DatasetExternalRetrievalModelResponse | null icon_info?: DatasetIconInfoResponse id: string indexing_technique: string | null @@ -253,7 +315,7 @@ export type DatasetDetailWithPartialMembersResponse = { embedding_model_provider: string | null enable_api: boolean external_knowledge_info?: DatasetExternalKnowledgeInfoResponse - external_retrieval_model: DatasetExternalRetrievalModelResponse + external_retrieval_model: DatasetExternalRetrievalModelResponse | null icon_info?: DatasetIconInfoResponse id: string indexing_technique: string | null @@ -365,7 +427,7 @@ export type DatasetRetrievalModelResponse = { score_threshold_enabled: boolean search_method: string top_k: number - weights?: DatasetWeightedScoreResponse + weights?: DatasetWeightedScoreResponse | null } export type DatasetSummaryIndexSettingResponse = { @@ -395,8 +457,8 @@ export type DatasetUpdatePayload = { partial_member_list?: Array<{ [key: string]: string }> | null - permission?: PermissionEnum - retrieval_model?: RetrievalModel + permission?: PermissionEnum | null + retrieval_model?: RetrievalModel | null } export type DatasetVectorSettingResponse = { @@ -411,6 +473,13 @@ export type DatasetWeightedScoreResponse = { weight_type?: string | null } +export type DatasourceCredentialInfoResponse = { + id?: string | null + is_default?: boolean | null + name?: string | null + type?: string | null +} + export type DatasourceNodeRunPayload = { credential_id?: string | null datasource_type: string @@ -420,6 +489,24 @@ export type DatasourceNodeRunPayload = { is_published: boolean } +export type DatasourcePluginListResponse = Array + +export type DatasourcePluginResponse = { + credentials: Array + datasource_type?: string | null + node_id?: string | null + plugin_id?: string | null + provider_name?: string | null + title?: string | null + user_input_variables?: Array<{ + [key: string]: unknown + }> +} + +export type DatasourcePluginsQuery = { + is_published?: boolean +} + export type DocumentAndBatchResponse = { batch: string document: DocumentResponse @@ -429,6 +516,50 @@ export type DocumentBatchDownloadZipPayload = { document_ids: Array } +export type DocumentDetailResponse = { + archived?: boolean | null + average_segment_length?: number | null + completed_at?: number | null + created_at?: number | null + created_by?: string | null + created_from?: string | null + data_source_info?: { + [key: string]: unknown + } | null + data_source_type?: string | null + dataset_process_rule?: { + [key: string]: unknown + } | null + dataset_process_rule_id?: string | null + disabled_at?: number | null + disabled_by?: string | null + display_status?: string | null + doc_form?: string | null + doc_language?: string | null + doc_metadata?: Array | null + doc_type?: string | null + document_process_rule?: { + [key: string]: unknown + } | null + enabled?: boolean | null + error?: string | null + hit_count?: number | null + id: string + indexing_latency?: number | null + indexing_status?: string | null + name?: string | null + need_summary?: boolean | null + position?: number | null + segment_count?: number | null + summary_index_status?: string | null + tokens?: number | null + updated_at?: number | null +} + +export type DocumentGetQuery = { + metadata?: 'all' | 'only' | 'without' +} + export type DocumentListQuery = { keyword?: string | null limit?: number @@ -454,7 +585,7 @@ export type DocumentMetadataResponse = { id: string name: string type: string - value?: unknown + value?: string | number | number | boolean | null } export type DocumentResponse = { @@ -488,6 +619,10 @@ export type DocumentStatusListResponse = { data: Array } +export type DocumentStatusPayload = { + document_ids?: Array +} + export type DocumentStatusResponse = { cleaning_completed_at: number | null completed_at: number | null @@ -511,8 +646,8 @@ export type DocumentTextCreatePayload = { indexing_technique?: string | null name: string original_document_id?: string | null - process_rule?: ProcessRule - retrieval_model?: RetrievalModel + process_rule?: ProcessRule | null + retrieval_model?: RetrievalModel | null text: string } @@ -520,8 +655,8 @@ export type DocumentTextUpdate = { doc_form?: string doc_language?: string name?: string | null - process_rule?: ProcessRule - retrieval_model?: RetrievalModel + process_rule?: ProcessRule | null + retrieval_model?: RetrievalModel | null text?: string | null } @@ -538,11 +673,34 @@ export type EndUserDetail = { updated_at: string } +export type EventStreamResponse = string + +export type ExecutionContentType = 'human_input' + export type FeedbackListQuery = { limit?: number page?: number } +export type FetchFrom = 'customizable-model' | 'predefined-model' + +export type FileInputConfig = { + allowed_file_extensions?: Array + allowed_file_types?: Array + allowed_file_upload_methods?: Array + output_variable_name: string + type?: 'file' +} + +export type FileListInputConfig = { + allowed_file_extensions?: Array + allowed_file_types?: Array + allowed_file_upload_methods?: Array + number_limits?: number + output_variable_name: string + type?: 'file-list' +} + export type FilePreviewQuery = { as_attachment?: boolean } @@ -565,6 +723,26 @@ export type FileResponse = { user_id?: string | null } +export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url' | 'tool_file' + +export type FileType = 'audio' | 'custom' | 'document' | 'image' | 'video' + +export type FormInputConfig + = | ({ + type: 'paragraph' + } & ParagraphInputConfig) + | ({ + type: 'select' + } & SelectInputConfig) + | ({ + type: 'file' + } & FileInputConfig) + | ({ + type: 'file-list' + } & FileListInputConfig) + +export type GeneratedAppResponse = JsonValue + export type HitTestingChildChunk = { content: string id: string @@ -574,7 +752,7 @@ export type HitTestingChildChunk = { export type HitTestingDocument = { data_source_type: string - doc_metadata: unknown + doc_metadata: unknown | null doc_type: string | null id: string name: string @@ -595,7 +773,7 @@ export type HitTestingPayload = { [key: string]: unknown } | null query: string - retrieval_model?: RetrievalModel + retrieval_model?: RetrievalModel | null } export type HitTestingQuery = { @@ -608,7 +786,7 @@ export type HitTestingRecord = { score: number | null segment: HitTestingSegment summary: string | null - tsne_position: unknown + tsne_position: unknown | null } export type HitTestingResponse = { @@ -642,20 +820,94 @@ export type HitTestingSegment = { word_count: number } +export type HumanInputContent = { + form_definition?: HumanInputFormDefinition | null + form_submission_data?: HumanInputFormSubmissionData | null + submitted: boolean + type?: ExecutionContentType + workflow_run_id: string +} + +export type HumanInputFormDefinition = { + actions?: Array + display_in_ui?: boolean + expiration_time: number + form_content: string + form_id: string + form_token?: string | null + inputs?: Array + node_id: string + node_title: string + resolved_default_values?: { + [key: string]: unknown + } +} + +export type HumanInputFormDefinitionResponse = { + expiration_time?: number | null + form_content: string + inputs?: Array<{ + [key: string]: unknown + }> + resolved_default_values: { + [key: string]: string + } + user_actions?: Array<{ + [key: string]: unknown + }> +} + +export type HumanInputFormSubmissionData = { + action_id: string + action_text: string + node_id: string + node_title: string + rendered_content: string + submitted_data?: { + [key: string]: JsonValue2 + } | null +} + export type HumanInputFormSubmitPayload = { action: string inputs: { - [key: string]: JsonValue + [key: string]: JsonValue2 } } +export type HumanInputFormSubmitResponse = { + [key: string]: never +} + +export type I18nObject = { + en_US: string + zh_Hans?: string | null +} + export type IndexInfoResponse = { api_version: string server_version: string welcome: string } -export type JsonValue = unknown +export type JsonObject = { + [key: string]: unknown +} + +export type JsonValue + = | string + | number + | number + | boolean + | { + [key: string]: unknown + } + | Array + | null + +export type JsonValueType = unknown + +export type JsonValue2 = unknown export type KnowledgeTagListResponse = Array @@ -671,6 +923,43 @@ export type MessageFeedbackPayload = { rating?: 'dislike' | 'like' | null } +export type MessageFile = { + belongs_to?: string | null + filename: string + id: string + mime_type?: string | null + size?: number | null + transfer_method: string + type: string + upload_file_id?: string | null + url?: string | null +} + +export type MessageInfiniteScrollPagination = { + data: Array + has_more: boolean + limit: number +} + +export type MessageListItem = { + agent_thoughts: Array + answer: string + conversation_id: string + created_at?: number | null + error?: string | null + extra_contents: Array + feedback?: SimpleFeedback | null + id: string + inputs: { + [key: string]: JsonValueType + } + message_files: Array + parent_message_id?: string | null + query: string + retriever_resources: Array + status: string +} + export type MessageListQuery = { conversation_id: string first_id?: string | null @@ -685,7 +974,7 @@ export type MetadataArgs = { export type MetadataDetail = { id: string name: string - value?: unknown + value?: string | number | number | null } export type MetadataFilteringCondition = { @@ -701,6 +990,62 @@ export type MetadataUpdatePayload = { name: string } +export type ModelFeature + = | 'agent-thought' + | 'audio' + | 'document' + | 'multi-tool-call' + | 'polling' + | 'stream-tool-call' + | 'structured-output' + | 'tool-call' + | 'video' + | 'vision' + +export type ModelPropertyKey + = | 'audio_type' + | 'context_size' + | 'default_voice' + | 'file_upload_limit' + | 'max_characters_per_chunk' + | 'max_chunks' + | 'max_workers' + | 'mode' + | 'supported_file_extensions' + | 'voices' + | 'word_limit' + +export type ModelStatus + = | 'active' + | 'credential-removed' + | 'disabled' + | 'no-configure' + | 'no-permission' + | 'quota-exceeded' + +export type ModelType = 'llm' | 'moderation' | 'rerank' | 'speech2text' | 'text-embedding' | 'tts' + +export type ParagraphInputConfig = { + default?: StringSource | null + output_variable_name: string + type?: 'paragraph' +} + +export type Parameters = { + annotation_reply: JsonObject + file_upload: JsonObject + more_like_this: JsonObject + opening_statement?: string | null + retriever_resource: JsonObject + sensitive_word_avoidance: JsonObject + speech_to_text: JsonObject + suggested_questions: Array + suggested_questions_after_answer: JsonObject + system_parameters: SystemParameters + text_to_speech: JsonObject + user_input_form: Array +} + export type PermissionEnum = 'all_team_members' | 'only_me' | 'partial_members' export type PipelineRunApiEntity = { @@ -716,6 +1061,16 @@ export type PipelineRunApiEntity = { start_node_id: string } +export type PipelineUploadFileResponse = { + created_at?: string | null + created_by: string + extension: string + id: string + mime_type?: string | null + name: string + size: number +} + export type PreProcessingRule = { enabled: boolean id: string @@ -723,11 +1078,40 @@ export type PreProcessingRule = { export type ProcessRule = { mode: ProcessRuleMode - rules?: Rule + rules?: Rule | null } export type ProcessRuleMode = 'automatic' | 'custom' | 'hierarchical' +export type ProviderModelWithStatusEntity = { + deprecated?: boolean + features?: Array | null + fetch_from: FetchFrom + has_invalid_load_balancing_configs?: boolean + label: I18nObject + load_balancing_enabled?: boolean + model: string + model_properties: { + [key in ModelPropertyKey]?: unknown + } + model_type: ModelType + status: ModelStatus +} + +export type ProviderWithModelsListResponse = { + data: Array +} + +export type ProviderWithModelsResponse = { + icon_small?: I18nObject | null + icon_small_dark?: I18nObject | null + label: I18nObject + models: Array + provider: string + status: CustomConfigurationStatus + tenant_id: string +} + export type RerankingModel = { reranking_model_name?: string | null reranking_provider_name?: string | null @@ -744,22 +1128,42 @@ export type RetrievalMethod | 'semantic_search' export type RetrievalModel = { - metadata_filtering_conditions?: MetadataFilteringCondition + metadata_filtering_conditions?: MetadataFilteringCondition | null reranking_enable: boolean reranking_mode?: string | null - reranking_model?: RerankingModel + reranking_model?: RerankingModel | null score_threshold?: number | null score_threshold_enabled: boolean search_method: RetrievalMethod top_k: number - weights?: WeightModel + weights?: WeightModel | null +} + +export type RetrieverResource = { + content?: string | null + created_at?: number | null + data_source_type?: string | null + dataset_id?: string | null + dataset_name?: string | null + document_id?: string | null + document_name?: string | null + hit_count?: number | null + id?: string + index_node_hash?: string | null + message_id?: string + position: number + score?: number | null + segment_id?: string | null + segment_position?: number | null + summary?: string | null + word_count?: number | null } export type Rule = { parent_mode?: 'full-doc' | 'paragraph' | null pre_processing_rules?: Array | null - segmentation?: Segmentation - subchunk_segmentation?: Segmentation + segmentation?: Segmentation | null + subchunk_segmentation?: Segmentation | null } export type SegmentAttachmentResponse = { @@ -858,12 +1262,30 @@ export type Segmentation = { separator?: string } +export type SelectInputConfig = { + option_source: StringListSource + output_variable_name: string + type?: 'select' +} + export type SimpleAccount = { email: string id: string name: string } +export type SimpleConversation = { + created_at?: number | null + id: string + inputs: { + [key: string]: JsonValue + } + introduction?: string | null + name: string + status: string + updated_at?: number | null +} + export type SimpleEndUser = { id: string is_anonymous: boolean @@ -871,6 +1293,10 @@ export type SimpleEndUser = { type: string } +export type SimpleFeedback = { + rating?: string | null +} + export type SimpleResultResponse = { result: string } @@ -897,6 +1323,26 @@ export type Site = { use_icon_as_answer_icon: boolean } +export type StringListSource = { + selector?: Array + type: ValueSourceType + value?: Array +} + +export type StringSource = { + selector?: Array + type: ValueSourceType + value?: string +} + +export type SystemParameters = { + audio_file_size_limit: number + file_size_limit: number + image_file_size_limit: number + video_file_size_limit: number + workflow_file_upload_limit: number +} + export type TagBindingPayload = { tag_ids: Array target_id: string @@ -932,13 +1378,21 @@ export type UrlResponse = { url: string } +export type UserActionConfig = { + button_style?: ButtonStyle + id: string + title: string +} + +export type ValueSourceType = 'constant' | 'variable' + export type WeightKeywordSetting = { keyword_weight: number } export type WeightModel = { - keyword_setting?: WeightKeywordSetting - vector_setting?: WeightVectorSetting + keyword_setting?: WeightKeywordSetting | null + vector_setting?: WeightVectorSetting | null weight_type?: 'customized' | 'keyword_first' | 'semantic_first' | null } @@ -958,13 +1412,28 @@ export type WorkflowAppLogPaginationResponse = { export type WorkflowAppLogPartialResponse = { created_at?: number | null - created_by_account?: SimpleAccount - created_by_end_user?: SimpleEndUser + created_by_account?: SimpleAccount | null + created_by_end_user?: SimpleEndUser | null created_by_role?: string | null created_from?: string | null - details?: unknown + details?: + | { + [key: string]: unknown + } + | Array + | string + | number + | number + | boolean + | null id: string - workflow_run?: WorkflowRunForLogResponse + workflow_run?: WorkflowRunForLogResponse | null +} + +export type WorkflowEventsQuery = { + continue_on_pause?: boolean + include_state_snapshot?: boolean + user: string } export type WorkflowLogQuery = { @@ -980,7 +1449,7 @@ export type WorkflowLogQuery = { export type WorkflowRunForLogResponse = { created_at?: number | null - elapsed_time?: unknown + elapsed_time?: number | number | null error?: string | null exceptions_count?: number | null finished_at?: number | null @@ -1005,11 +1474,20 @@ export type WorkflowRunPayload = { export type WorkflowRunResponse = { created_at?: number | null - elapsed_time?: unknown + elapsed_time?: number | number | null error?: string | null finished_at?: number | null id: string - inputs?: unknown + inputs?: + | { + [key: string]: unknown + } + | Array + | string + | number + | number + | boolean + | null outputs?: { [key: string]: unknown } @@ -1019,6 +1497,12 @@ export type WorkflowRunResponse = { workflow_id: string } +export type GeneratedAppResponseWritable = JsonValue + +export type HumanInputFormSubmitResponseWritable = { + [key: string]: never +} + export type SiteWritable = { chat_color_theme?: string | null chat_color_theme_inverted: boolean @@ -1059,17 +1543,11 @@ export type GetAppFeedbacksData = { } export type GetAppFeedbacksErrors = { - 401: { - [key: string]: unknown - } + 401: unknown } -export type GetAppFeedbacksError = GetAppFeedbacksErrors[keyof GetAppFeedbacksErrors] - export type GetAppFeedbacksResponses = { - 200: { - [key: string]: unknown - } + 200: AppFeedbackListResponse } export type GetAppFeedbacksResponse = GetAppFeedbacksResponses[keyof GetAppFeedbacksResponses] @@ -1084,18 +1562,11 @@ export type PostAppsAnnotationReplyByActionData = { } export type PostAppsAnnotationReplyByActionErrors = { - 401: { - [key: string]: unknown - } + 401: unknown } -export type PostAppsAnnotationReplyByActionError - = PostAppsAnnotationReplyByActionErrors[keyof PostAppsAnnotationReplyByActionErrors] - export type PostAppsAnnotationReplyByActionResponses = { - 200: { - [key: string]: unknown - } + 200: AnnotationJobStatusResponse } export type PostAppsAnnotationReplyByActionResponse @@ -1112,21 +1583,12 @@ export type GetAppsAnnotationReplyByActionStatusByJobIdData = { } export type GetAppsAnnotationReplyByActionStatusByJobIdErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type GetAppsAnnotationReplyByActionStatusByJobIdError - = GetAppsAnnotationReplyByActionStatusByJobIdErrors[keyof GetAppsAnnotationReplyByActionStatusByJobIdErrors] - export type GetAppsAnnotationReplyByActionStatusByJobIdResponses = { - 200: { - [key: string]: unknown - } + 200: AnnotationJobStatusResponse } export type GetAppsAnnotationReplyByActionStatusByJobIdResponse @@ -1144,13 +1606,9 @@ export type GetAppsAnnotationsData = { } export type GetAppsAnnotationsErrors = { - 401: { - [key: string]: unknown - } + 401: unknown } -export type GetAppsAnnotationsError = GetAppsAnnotationsErrors[keyof GetAppsAnnotationsErrors] - export type GetAppsAnnotationsResponses = { 200: AnnotationList } @@ -1166,13 +1624,9 @@ export type PostAppsAnnotationsData = { } export type PostAppsAnnotationsErrors = { - 401: { - [key: string]: unknown - } + 401: unknown } -export type PostAppsAnnotationsError = PostAppsAnnotationsErrors[keyof PostAppsAnnotationsErrors] - export type PostAppsAnnotationsResponses = { 201: Annotation } @@ -1190,24 +1644,13 @@ export type DeleteAppsAnnotationsByAnnotationIdData = { } export type DeleteAppsAnnotationsByAnnotationIdErrors = { - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 403: unknown + 404: unknown } -export type DeleteAppsAnnotationsByAnnotationIdError - = DeleteAppsAnnotationsByAnnotationIdErrors[keyof DeleteAppsAnnotationsByAnnotationIdErrors] - export type DeleteAppsAnnotationsByAnnotationIdResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteAppsAnnotationsByAnnotationIdResponse @@ -1223,20 +1666,11 @@ export type PutAppsAnnotationsByAnnotationIdData = { } export type PutAppsAnnotationsByAnnotationIdErrors = { - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 403: unknown + 404: unknown } -export type PutAppsAnnotationsByAnnotationIdError - = PutAppsAnnotationsByAnnotationIdErrors[keyof PutAppsAnnotationsByAnnotationIdErrors] - export type PutAppsAnnotationsByAnnotationIdResponses = { 200: Annotation } @@ -1252,29 +1686,15 @@ export type PostAudioToTextData = { } export type PostAudioToTextErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 413: { - [key: string]: unknown - } - 415: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 413: unknown + 415: unknown + 500: unknown } -export type PostAudioToTextError = PostAudioToTextErrors[keyof PostAudioToTextErrors] - export type PostAudioToTextResponses = { - 200: { - [key: string]: unknown - } + 200: AudioTranscriptResponse } export type PostAudioToTextResponse = PostAudioToTextResponses[keyof PostAudioToTextResponses] @@ -1287,29 +1707,15 @@ export type PostChatMessagesData = { } export type PostChatMessagesErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } - 429: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 404: unknown + 429: unknown + 500: unknown } -export type PostChatMessagesError = PostChatMessagesErrors[keyof PostChatMessagesErrors] - export type PostChatMessagesResponses = { - 200: { - [key: string]: unknown - } + 200: GeneratedAppResponse } export type PostChatMessagesResponse = PostChatMessagesResponses[keyof PostChatMessagesResponses] @@ -1324,17 +1730,10 @@ export type PostChatMessagesByTaskIdStopData = { } export type PostChatMessagesByTaskIdStopErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type PostChatMessagesByTaskIdStopError - = PostChatMessagesByTaskIdStopErrors[keyof PostChatMessagesByTaskIdStopErrors] - export type PostChatMessagesByTaskIdStopResponses = { 200: SimpleResultResponse } @@ -1350,27 +1749,14 @@ export type PostCompletionMessagesData = { } export type PostCompletionMessagesErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 404: unknown + 500: unknown } -export type PostCompletionMessagesError - = PostCompletionMessagesErrors[keyof PostCompletionMessagesErrors] - export type PostCompletionMessagesResponses = { - 200: { - [key: string]: unknown - } + 200: GeneratedAppResponse } export type PostCompletionMessagesResponse @@ -1386,17 +1772,10 @@ export type PostCompletionMessagesByTaskIdStopData = { } export type PostCompletionMessagesByTaskIdStopErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type PostCompletionMessagesByTaskIdStopError - = PostCompletionMessagesByTaskIdStopErrors[keyof PostCompletionMessagesByTaskIdStopErrors] - export type PostCompletionMessagesByTaskIdStopResponses = { 200: SimpleResultResponse } @@ -1408,7 +1787,7 @@ export type GetConversationsData = { body?: never path?: never query?: { - last_id?: string | null + last_id?: string limit?: number sort_by?: '-created_at' | '-updated_at' | 'created_at' | 'updated_at' } @@ -1416,20 +1795,12 @@ export type GetConversationsData = { } export type GetConversationsErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type GetConversationsError = GetConversationsErrors[keyof GetConversationsErrors] - export type GetConversationsResponses = { - 200: { - [key: string]: unknown - } + 200: ConversationInfiniteScrollPagination } export type GetConversationsResponse = GetConversationsResponses[keyof GetConversationsResponses] @@ -1444,21 +1815,12 @@ export type DeleteConversationsByCIdData = { } export type DeleteConversationsByCIdErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type DeleteConversationsByCIdError - = DeleteConversationsByCIdErrors[keyof DeleteConversationsByCIdErrors] - export type DeleteConversationsByCIdResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteConversationsByCIdResponse @@ -1474,21 +1836,12 @@ export type PostConversationsByCIdNameData = { } export type PostConversationsByCIdNameErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type PostConversationsByCIdNameError - = PostConversationsByCIdNameErrors[keyof PostConversationsByCIdNameErrors] - export type PostConversationsByCIdNameResponses = { - 200: { - [key: string]: unknown - } + 200: SimpleConversation } export type PostConversationsByCIdNameResponse @@ -1500,25 +1853,18 @@ export type GetConversationsByCIdVariablesData = { c_id: string } query?: { - last_id?: string | null + last_id?: string limit?: number - variable_name?: string | null + variable_name?: string } url: '/conversations/{c_id}/variables' } export type GetConversationsByCIdVariablesErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type GetConversationsByCIdVariablesError - = GetConversationsByCIdVariablesErrors[keyof GetConversationsByCIdVariablesErrors] - export type GetConversationsByCIdVariablesResponses = { 200: ConversationVariableInfiniteScrollPaginationResponse } @@ -1537,20 +1883,11 @@ export type PutConversationsByCIdVariablesByVariableIdData = { } export type PutConversationsByCIdVariablesByVariableIdErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 404: unknown } -export type PutConversationsByCIdVariablesByVariableIdError - = PutConversationsByCIdVariablesByVariableIdErrors[keyof PutConversationsByCIdVariablesByVariableIdErrors] - export type PutConversationsByCIdVariablesByVariableIdResponses = { 200: ConversationVariableResponse } @@ -1572,13 +1909,9 @@ export type GetDatasetsData = { } export type GetDatasetsErrors = { - 401: { - [key: string]: unknown - } + 401: unknown } -export type GetDatasetsError = GetDatasetsErrors[keyof GetDatasetsErrors] - export type GetDatasetsResponses = { 200: DatasetListResponse } @@ -1593,16 +1926,10 @@ export type PostDatasetsData = { } export type PostDatasetsErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } + 400: unknown + 401: unknown } -export type PostDatasetsError = PostDatasetsErrors[keyof PostDatasetsErrors] - export type PostDatasetsResponses = { 200: DatasetDetailResponse } @@ -1617,27 +1944,14 @@ export type PostDatasetsPipelineFileUploadData = { } export type PostDatasetsPipelineFileUploadErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 413: { - [key: string]: unknown - } - 415: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 413: unknown + 415: unknown } -export type PostDatasetsPipelineFileUploadError - = PostDatasetsPipelineFileUploadErrors[keyof PostDatasetsPipelineFileUploadErrors] - export type PostDatasetsPipelineFileUploadResponses = { - 201: { - [key: string]: unknown - } + 201: PipelineUploadFileResponse } export type PostDatasetsPipelineFileUploadResponse @@ -1651,20 +1965,12 @@ export type DeleteDatasetsTagsData = { } export type DeleteDatasetsTagsErrors = { - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } + 401: unknown + 403: unknown } -export type DeleteDatasetsTagsError = DeleteDatasetsTagsErrors[keyof DeleteDatasetsTagsErrors] - export type DeleteDatasetsTagsResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteDatasetsTagsResponse @@ -1678,13 +1984,9 @@ export type GetDatasetsTagsData = { } export type GetDatasetsTagsErrors = { - 401: { - [key: string]: unknown - } + 401: unknown } -export type GetDatasetsTagsError = GetDatasetsTagsErrors[keyof GetDatasetsTagsErrors] - export type GetDatasetsTagsResponses = { 200: KnowledgeTagListResponse } @@ -1699,16 +2001,10 @@ export type PatchDatasetsTagsData = { } export type PatchDatasetsTagsErrors = { - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } + 401: unknown + 403: unknown } -export type PatchDatasetsTagsError = PatchDatasetsTagsErrors[keyof PatchDatasetsTagsErrors] - export type PatchDatasetsTagsResponses = { 200: KnowledgeTagResponse } @@ -1723,16 +2019,10 @@ export type PostDatasetsTagsData = { } export type PostDatasetsTagsErrors = { - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } + 401: unknown + 403: unknown } -export type PostDatasetsTagsError = PostDatasetsTagsErrors[keyof PostDatasetsTagsErrors] - export type PostDatasetsTagsResponses = { 200: KnowledgeTagResponse } @@ -1747,21 +2037,12 @@ export type PostDatasetsTagsBindingData = { } export type PostDatasetsTagsBindingErrors = { - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } + 401: unknown + 403: unknown } -export type PostDatasetsTagsBindingError - = PostDatasetsTagsBindingErrors[keyof PostDatasetsTagsBindingErrors] - export type PostDatasetsTagsBindingResponses = { - 204: { - [key: string]: never - } + 204: void } export type PostDatasetsTagsBindingResponse @@ -1775,21 +2056,12 @@ export type PostDatasetsTagsUnbindingData = { } export type PostDatasetsTagsUnbindingErrors = { - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } + 401: unknown + 403: unknown } -export type PostDatasetsTagsUnbindingError - = PostDatasetsTagsUnbindingErrors[keyof PostDatasetsTagsUnbindingErrors] - export type PostDatasetsTagsUnbindingResponses = { - 204: { - [key: string]: never - } + 204: void } export type PostDatasetsTagsUnbindingResponse @@ -1805,24 +2077,13 @@ export type DeleteDatasetsByDatasetIdData = { } export type DeleteDatasetsByDatasetIdErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } - 409: { - [key: string]: unknown - } + 401: unknown + 404: unknown + 409: unknown } -export type DeleteDatasetsByDatasetIdError - = DeleteDatasetsByDatasetIdErrors[keyof DeleteDatasetsByDatasetIdErrors] - export type DeleteDatasetsByDatasetIdResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteDatasetsByDatasetIdResponse @@ -1838,20 +2099,11 @@ export type GetDatasetsByDatasetIdData = { } export type GetDatasetsByDatasetIdErrors = { - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 403: unknown + 404: unknown } -export type GetDatasetsByDatasetIdError - = GetDatasetsByDatasetIdErrors[keyof GetDatasetsByDatasetIdErrors] - export type GetDatasetsByDatasetIdResponses = { 200: DatasetDetailWithPartialMembersResponse } @@ -1869,20 +2121,11 @@ export type PatchDatasetsByDatasetIdData = { } export type PatchDatasetsByDatasetIdErrors = { - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 403: unknown + 404: unknown } -export type PatchDatasetsByDatasetIdError - = PatchDatasetsByDatasetIdErrors[keyof PatchDatasetsByDatasetIdErrors] - export type PatchDatasetsByDatasetIdResponses = { 200: DatasetDetailWithPartialMembersResponse } @@ -1903,17 +2146,10 @@ export type PostDatasetsByDatasetIdDocumentCreateByFileData = { } export type PostDatasetsByDatasetIdDocumentCreateByFileErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } + 400: unknown + 401: unknown } -export type PostDatasetsByDatasetIdDocumentCreateByFileError - = PostDatasetsByDatasetIdDocumentCreateByFileErrors[keyof PostDatasetsByDatasetIdDocumentCreateByFileErrors] - export type PostDatasetsByDatasetIdDocumentCreateByFileResponses = { 200: DocumentAndBatchResponse } @@ -1931,17 +2167,10 @@ export type PostDatasetsByDatasetIdDocumentCreateByTextData = { } export type PostDatasetsByDatasetIdDocumentCreateByTextErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } + 400: unknown + 401: unknown } -export type PostDatasetsByDatasetIdDocumentCreateByTextError - = PostDatasetsByDatasetIdDocumentCreateByTextErrors[keyof PostDatasetsByDatasetIdDocumentCreateByTextErrors] - export type PostDatasetsByDatasetIdDocumentCreateByTextResponses = { 200: DocumentAndBatchResponse } @@ -1962,17 +2191,10 @@ export type PostDatasetsByDatasetIdDocumentCreateByFile2Data = { } export type PostDatasetsByDatasetIdDocumentCreateByFile2Errors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } + 400: unknown + 401: unknown } -export type PostDatasetsByDatasetIdDocumentCreateByFile2Error - = PostDatasetsByDatasetIdDocumentCreateByFile2Errors[keyof PostDatasetsByDatasetIdDocumentCreateByFile2Errors] - export type PostDatasetsByDatasetIdDocumentCreateByFile2Responses = { 200: DocumentAndBatchResponse } @@ -1990,17 +2212,10 @@ export type PostDatasetsByDatasetIdDocumentCreateByText2Data = { } export type PostDatasetsByDatasetIdDocumentCreateByText2Errors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } + 400: unknown + 401: unknown } -export type PostDatasetsByDatasetIdDocumentCreateByText2Error - = PostDatasetsByDatasetIdDocumentCreateByText2Errors[keyof PostDatasetsByDatasetIdDocumentCreateByText2Errors] - export type PostDatasetsByDatasetIdDocumentCreateByText2Responses = { 200: DocumentAndBatchResponse } @@ -2023,17 +2238,10 @@ export type GetDatasetsByDatasetIdDocumentsData = { } export type GetDatasetsByDatasetIdDocumentsErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type GetDatasetsByDatasetIdDocumentsError - = GetDatasetsByDatasetIdDocumentsErrors[keyof GetDatasetsByDatasetIdDocumentsErrors] - export type GetDatasetsByDatasetIdDocumentsResponses = { 200: DocumentListResponse } @@ -2051,24 +2259,13 @@ export type PostDatasetsByDatasetIdDocumentsDownloadZipData = { } export type PostDatasetsByDatasetIdDocumentsDownloadZipErrors = { - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 403: unknown + 404: unknown } -export type PostDatasetsByDatasetIdDocumentsDownloadZipError - = PostDatasetsByDatasetIdDocumentsDownloadZipErrors[keyof PostDatasetsByDatasetIdDocumentsDownloadZipErrors] - export type PostDatasetsByDatasetIdDocumentsDownloadZipResponses = { - 200: { - [key: string]: unknown - } + 200: BinaryFileResponse } export type PostDatasetsByDatasetIdDocumentsDownloadZipResponse @@ -2084,17 +2281,10 @@ export type PostDatasetsByDatasetIdDocumentsMetadataData = { } export type PostDatasetsByDatasetIdDocumentsMetadataErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type PostDatasetsByDatasetIdDocumentsMetadataError - = PostDatasetsByDatasetIdDocumentsMetadataErrors[keyof PostDatasetsByDatasetIdDocumentsMetadataErrors] - export type PostDatasetsByDatasetIdDocumentsMetadataResponses = { 200: DatasetMetadataActionResponse } @@ -2103,7 +2293,7 @@ export type PostDatasetsByDatasetIdDocumentsMetadataResponse = PostDatasetsByDatasetIdDocumentsMetadataResponses[keyof PostDatasetsByDatasetIdDocumentsMetadataResponses] export type PatchDatasetsByDatasetIdDocumentsStatusByActionData = { - body?: never + body: DocumentStatusPayload path: { action: string dataset_id: string @@ -2113,23 +2303,12 @@ export type PatchDatasetsByDatasetIdDocumentsStatusByActionData = { } export type PatchDatasetsByDatasetIdDocumentsStatusByActionErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 403: unknown + 404: unknown } -export type PatchDatasetsByDatasetIdDocumentsStatusByActionError - = PatchDatasetsByDatasetIdDocumentsStatusByActionErrors[keyof PatchDatasetsByDatasetIdDocumentsStatusByActionErrors] - export type PatchDatasetsByDatasetIdDocumentsStatusByActionResponses = { 200: SimpleResultResponse } @@ -2148,17 +2327,10 @@ export type GetDatasetsByDatasetIdDocumentsByBatchIndexingStatusData = { } export type GetDatasetsByDatasetIdDocumentsByBatchIndexingStatusErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type GetDatasetsByDatasetIdDocumentsByBatchIndexingStatusError - = GetDatasetsByDatasetIdDocumentsByBatchIndexingStatusErrors[keyof GetDatasetsByDatasetIdDocumentsByBatchIndexingStatusErrors] - export type GetDatasetsByDatasetIdDocumentsByBatchIndexingStatusResponses = { 200: DocumentStatusListResponse } @@ -2177,24 +2349,13 @@ export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdData = { } export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdErrors = { - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 403: unknown + 404: unknown } -export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdError - = DeleteDatasetsByDatasetIdDocumentsByDocumentIdErrors[keyof DeleteDatasetsByDatasetIdDocumentsByDocumentIdErrors] - export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdResponse @@ -2206,29 +2367,20 @@ export type GetDatasetsByDatasetIdDocumentsByDocumentIdData = { dataset_id: string document_id: string } - query?: never + query?: { + metadata?: 'all' | 'only' | 'without' + } url: '/datasets/{dataset_id}/documents/{document_id}' } export type GetDatasetsByDatasetIdDocumentsByDocumentIdErrors = { - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 403: unknown + 404: unknown } -export type GetDatasetsByDatasetIdDocumentsByDocumentIdError - = GetDatasetsByDatasetIdDocumentsByDocumentIdErrors[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdErrors] - export type GetDatasetsByDatasetIdDocumentsByDocumentIdResponses = { - 200: { - [key: string]: unknown - } + 200: DocumentDetailResponse } export type GetDatasetsByDatasetIdDocumentsByDocumentIdResponse @@ -2248,17 +2400,10 @@ export type PatchDatasetsByDatasetIdDocumentsByDocumentIdData = { } export type PatchDatasetsByDatasetIdDocumentsByDocumentIdErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type PatchDatasetsByDatasetIdDocumentsByDocumentIdError - = PatchDatasetsByDatasetIdDocumentsByDocumentIdErrors[keyof PatchDatasetsByDatasetIdDocumentsByDocumentIdErrors] - export type PatchDatasetsByDatasetIdDocumentsByDocumentIdResponses = { 200: DocumentAndBatchResponse } @@ -2277,20 +2422,11 @@ export type GetDatasetsByDatasetIdDocumentsByDocumentIdDownloadData = { } export type GetDatasetsByDatasetIdDocumentsByDocumentIdDownloadErrors = { - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 403: unknown + 404: unknown } -export type GetDatasetsByDatasetIdDocumentsByDocumentIdDownloadError - = GetDatasetsByDatasetIdDocumentsByDocumentIdDownloadErrors[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdDownloadErrors] - export type GetDatasetsByDatasetIdDocumentsByDocumentIdDownloadResponses = { 200: UrlResponse } @@ -2314,17 +2450,10 @@ export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsData = { } export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsError - = GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsErrors[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsErrors] - export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponses = { 200: SegmentListResponse } @@ -2343,20 +2472,11 @@ export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsData = { } export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 404: unknown } -export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsError - = PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsErrors[keyof PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsErrors] - export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponses = { 200: SegmentCreateListResponse } @@ -2376,21 +2496,12 @@ export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdDat } export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdError - = DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdErrors[keyof DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdErrors] - export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponse @@ -2408,17 +2519,10 @@ export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdData = } export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdError - = GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdErrors[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdErrors] - export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponses = { 200: SegmentDetailResponse } @@ -2438,17 +2542,10 @@ export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdData } export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdError - = PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdErrors[keyof PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdErrors] - export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponses = { 200: SegmentDetailResponse } @@ -2472,17 +2569,10 @@ export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildC } export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksError - = GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksErrors[keyof GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksErrors] - export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponses = { 200: ChildChunkListResponse } @@ -2502,17 +2592,10 @@ export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChild } export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksError - = PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksErrors[keyof PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksErrors] - export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponses = { 200: ChildChunkDetailResponse } @@ -2535,22 +2618,13 @@ export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChi export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdError - = DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdErrors[keyof DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdErrors] - export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponse @@ -2571,17 +2645,10 @@ export type PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChil export type PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdError - = PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdErrors[keyof PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdErrors] - export type PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponses = { 200: ChildChunkDetailResponse @@ -2604,17 +2671,10 @@ export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFileData = { } export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFileErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFileError - = PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFileErrors[keyof PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFileErrors] - export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFileResponses = { 200: DocumentAndBatchResponse } @@ -2633,17 +2693,10 @@ export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByTextData = { } export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByTextErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByTextError - = PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByTextErrors[keyof PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByTextErrors] - export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByTextResponses = { 200: DocumentAndBatchResponse } @@ -2665,17 +2718,10 @@ export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile2Data = { } export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile2Errors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile2Error - = PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile2Errors[keyof PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile2Errors] - export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile2Responses = { 200: DocumentAndBatchResponse } @@ -2694,17 +2740,10 @@ export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByText2Data = { } export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByText2Errors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByText2Error - = PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByText2Errors[keyof PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByText2Errors] - export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByText2Responses = { 200: DocumentAndBatchResponse } @@ -2722,17 +2761,10 @@ export type PostDatasetsByDatasetIdHitTestingData = { } export type PostDatasetsByDatasetIdHitTestingErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type PostDatasetsByDatasetIdHitTestingError - = PostDatasetsByDatasetIdHitTestingErrors[keyof PostDatasetsByDatasetIdHitTestingErrors] - export type PostDatasetsByDatasetIdHitTestingResponses = { 200: HitTestingResponse } @@ -2750,17 +2782,10 @@ export type GetDatasetsByDatasetIdMetadataData = { } export type GetDatasetsByDatasetIdMetadataErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type GetDatasetsByDatasetIdMetadataError - = GetDatasetsByDatasetIdMetadataErrors[keyof GetDatasetsByDatasetIdMetadataErrors] - export type GetDatasetsByDatasetIdMetadataResponses = { 200: DatasetMetadataListResponse } @@ -2778,17 +2803,10 @@ export type PostDatasetsByDatasetIdMetadataData = { } export type PostDatasetsByDatasetIdMetadataErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type PostDatasetsByDatasetIdMetadataError - = PostDatasetsByDatasetIdMetadataErrors[keyof PostDatasetsByDatasetIdMetadataErrors] - export type PostDatasetsByDatasetIdMetadataResponses = { 201: DatasetMetadataResponse } @@ -2806,14 +2824,9 @@ export type GetDatasetsByDatasetIdMetadataBuiltInData = { } export type GetDatasetsByDatasetIdMetadataBuiltInErrors = { - 401: { - [key: string]: unknown - } + 401: unknown } -export type GetDatasetsByDatasetIdMetadataBuiltInError - = GetDatasetsByDatasetIdMetadataBuiltInErrors[keyof GetDatasetsByDatasetIdMetadataBuiltInErrors] - export type GetDatasetsByDatasetIdMetadataBuiltInResponses = { 200: DatasetMetadataBuiltInFieldsResponse } @@ -2832,17 +2845,10 @@ export type PostDatasetsByDatasetIdMetadataBuiltInByActionData = { } export type PostDatasetsByDatasetIdMetadataBuiltInByActionErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type PostDatasetsByDatasetIdMetadataBuiltInByActionError - = PostDatasetsByDatasetIdMetadataBuiltInByActionErrors[keyof PostDatasetsByDatasetIdMetadataBuiltInByActionErrors] - export type PostDatasetsByDatasetIdMetadataBuiltInByActionResponses = { 200: DatasetMetadataActionResponse } @@ -2861,21 +2867,12 @@ export type DeleteDatasetsByDatasetIdMetadataByMetadataIdData = { } export type DeleteDatasetsByDatasetIdMetadataByMetadataIdErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type DeleteDatasetsByDatasetIdMetadataByMetadataIdError - = DeleteDatasetsByDatasetIdMetadataByMetadataIdErrors[keyof DeleteDatasetsByDatasetIdMetadataByMetadataIdErrors] - export type DeleteDatasetsByDatasetIdMetadataByMetadataIdResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteDatasetsByDatasetIdMetadataByMetadataIdResponse @@ -2892,17 +2889,10 @@ export type PatchDatasetsByDatasetIdMetadataByMetadataIdData = { } export type PatchDatasetsByDatasetIdMetadataByMetadataIdErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type PatchDatasetsByDatasetIdMetadataByMetadataIdError - = PatchDatasetsByDatasetIdMetadataByMetadataIdErrors[keyof PatchDatasetsByDatasetIdMetadataByMetadataIdErrors] - export type PatchDatasetsByDatasetIdMetadataByMetadataIdResponses = { 200: DatasetMetadataResponse } @@ -2916,31 +2906,24 @@ export type GetDatasetsByDatasetIdPipelineDatasourcePluginsData = { dataset_id: string } query?: { - is_published?: string + is_published?: boolean } url: '/datasets/{dataset_id}/pipeline/datasource-plugins' } export type GetDatasetsByDatasetIdPipelineDatasourcePluginsErrors = { - 401: { - [key: string]: unknown - } + 401: unknown } -export type GetDatasetsByDatasetIdPipelineDatasourcePluginsError - = GetDatasetsByDatasetIdPipelineDatasourcePluginsErrors[keyof GetDatasetsByDatasetIdPipelineDatasourcePluginsErrors] - export type GetDatasetsByDatasetIdPipelineDatasourcePluginsResponses = { - 200: { - [key: string]: unknown - } + 200: DatasourcePluginListResponse } export type GetDatasetsByDatasetIdPipelineDatasourcePluginsResponse = GetDatasetsByDatasetIdPipelineDatasourcePluginsResponses[keyof GetDatasetsByDatasetIdPipelineDatasourcePluginsResponses] export type PostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunData = { - body?: never + body: DatasourceNodeRunPayload path: { dataset_id: string node_id: string @@ -2950,25 +2933,18 @@ export type PostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunData = { } export type PostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunErrors = { - 401: { - [key: string]: unknown - } + 401: unknown } -export type PostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunError - = PostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunErrors[keyof PostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunErrors] - export type PostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunResponses = { - 200: { - [key: string]: unknown - } + 200: GeneratedAppResponse } export type PostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunResponse = PostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunResponses[keyof PostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunResponses] export type PostDatasetsByDatasetIdPipelineRunData = { - body?: never + body: PipelineRunApiEntity path: { dataset_id: string } @@ -2977,18 +2953,11 @@ export type PostDatasetsByDatasetIdPipelineRunData = { } export type PostDatasetsByDatasetIdPipelineRunErrors = { - 401: { - [key: string]: unknown - } + 401: unknown } -export type PostDatasetsByDatasetIdPipelineRunError - = PostDatasetsByDatasetIdPipelineRunErrors[keyof PostDatasetsByDatasetIdPipelineRunErrors] - export type PostDatasetsByDatasetIdPipelineRunResponses = { - 200: { - [key: string]: unknown - } + 200: GeneratedAppResponse } export type PostDatasetsByDatasetIdPipelineRunResponse @@ -3004,17 +2973,10 @@ export type PostDatasetsByDatasetIdRetrieveData = { } export type PostDatasetsByDatasetIdRetrieveErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type PostDatasetsByDatasetIdRetrieveError - = PostDatasetsByDatasetIdRetrieveErrors[keyof PostDatasetsByDatasetIdRetrieveErrors] - export type PostDatasetsByDatasetIdRetrieveResponses = { 200: HitTestingResponse } @@ -3032,14 +2994,9 @@ export type GetDatasetsByDatasetIdTagsData = { } export type GetDatasetsByDatasetIdTagsErrors = { - 401: { - [key: string]: unknown - } + 401: unknown } -export type GetDatasetsByDatasetIdTagsError - = GetDatasetsByDatasetIdTagsErrors[keyof GetDatasetsByDatasetIdTagsErrors] - export type GetDatasetsByDatasetIdTagsResponses = { 200: DatasetBoundTagListResponse } @@ -3057,17 +3014,10 @@ export type GetEndUsersByEndUserIdData = { } export type GetEndUsersByEndUserIdErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type GetEndUsersByEndUserIdError - = GetEndUsersByEndUserIdErrors[keyof GetEndUsersByEndUserIdErrors] - export type GetEndUsersByEndUserIdResponses = { 200: EndUserDetail } @@ -3083,22 +3033,12 @@ export type PostFilesUploadData = { } export type PostFilesUploadErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 413: { - [key: string]: unknown - } - 415: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 413: unknown + 415: unknown } -export type PostFilesUploadError = PostFilesUploadErrors[keyof PostFilesUploadErrors] - export type PostFilesUploadResponses = { 201: FileResponse } @@ -3117,24 +3057,13 @@ export type GetFilesByFileIdPreviewData = { } export type GetFilesByFileIdPreviewErrors = { - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 403: unknown + 404: unknown } -export type GetFilesByFileIdPreviewError - = GetFilesByFileIdPreviewErrors[keyof GetFilesByFileIdPreviewErrors] - export type GetFilesByFileIdPreviewResponses = { - 200: { - [key: string]: unknown - } + 200: BinaryFileResponse } export type GetFilesByFileIdPreviewResponse @@ -3150,24 +3079,13 @@ export type GetFormHumanInputByFormTokenData = { } export type GetFormHumanInputByFormTokenErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } - 412: { - [key: string]: unknown - } + 401: unknown + 404: unknown + 412: unknown } -export type GetFormHumanInputByFormTokenError - = GetFormHumanInputByFormTokenErrors[keyof GetFormHumanInputByFormTokenErrors] - export type GetFormHumanInputByFormTokenResponses = { - 200: { - [key: string]: unknown - } + 200: HumanInputFormDefinitionResponse } export type GetFormHumanInputByFormTokenResponse @@ -3183,27 +3101,14 @@ export type PostFormHumanInputByFormTokenData = { } export type PostFormHumanInputByFormTokenErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } - 412: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 404: unknown + 412: unknown } -export type PostFormHumanInputByFormTokenError - = PostFormHumanInputByFormTokenErrors[keyof PostFormHumanInputByFormTokenErrors] - export type PostFormHumanInputByFormTokenResponses = { - 200: { - [key: string]: unknown - } + 200: HumanInputFormSubmitResponse } export type PostFormHumanInputByFormTokenResponse @@ -3217,16 +3122,10 @@ export type GetInfoData = { } export type GetInfoErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type GetInfoError = GetInfoErrors[keyof GetInfoErrors] - export type GetInfoResponses = { 200: AppInfoResponse } @@ -3238,27 +3137,19 @@ export type GetMessagesData = { path?: never query: { conversation_id: string - first_id?: string | null + first_id?: string limit?: number } url: '/messages' } export type GetMessagesErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type GetMessagesError = GetMessagesErrors[keyof GetMessagesErrors] - export type GetMessagesResponses = { - 200: { - [key: string]: unknown - } + 200: MessageInfiniteScrollPagination } export type GetMessagesResponse = GetMessagesResponses[keyof GetMessagesResponses] @@ -3273,17 +3164,10 @@ export type PostMessagesByMessageIdFeedbacksData = { } export type PostMessagesByMessageIdFeedbacksErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type PostMessagesByMessageIdFeedbacksError - = PostMessagesByMessageIdFeedbacksErrors[keyof PostMessagesByMessageIdFeedbacksErrors] - export type PostMessagesByMessageIdFeedbacksResponses = { 200: ResultResponse } @@ -3301,23 +3185,12 @@ export type GetMessagesByMessageIdSuggestedData = { } export type GetMessagesByMessageIdSuggestedErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 404: unknown + 500: unknown } -export type GetMessagesByMessageIdSuggestedError - = GetMessagesByMessageIdSuggestedErrors[keyof GetMessagesByMessageIdSuggestedErrors] - export type GetMessagesByMessageIdSuggestedResponses = { 200: SimpleResultStringListResponse } @@ -3333,20 +3206,12 @@ export type GetMetaData = { } export type GetMetaErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type GetMetaError = GetMetaErrors[keyof GetMetaErrors] - export type GetMetaResponses = { - 200: { - [key: string]: unknown - } + 200: AppMetaResponse } export type GetMetaResponse = GetMetaResponses[keyof GetMetaResponses] @@ -3359,20 +3224,12 @@ export type GetParametersData = { } export type GetParametersErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type GetParametersError = GetParametersErrors[keyof GetParametersErrors] - export type GetParametersResponses = { - 200: { - [key: string]: unknown - } + 200: Parameters } export type GetParametersResponse = GetParametersResponses[keyof GetParametersResponses] @@ -3385,16 +3242,10 @@ export type GetSiteData = { } export type GetSiteErrors = { - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } + 401: unknown + 403: unknown } -export type GetSiteError = GetSiteErrors[keyof GetSiteErrors] - export type GetSiteResponses = { 200: Site } @@ -3409,23 +3260,13 @@ export type PostTextToAudioData = { } export type PostTextToAudioErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 500: unknown } -export type PostTextToAudioError = PostTextToAudioErrors[keyof PostTextToAudioErrors] - export type PostTextToAudioResponses = { - 200: { - [key: string]: unknown - } + 200: AudioBinaryResponse } export type PostTextToAudioResponse = PostTextToAudioResponses[keyof PostTextToAudioResponses] @@ -3435,30 +3276,21 @@ export type GetWorkflowByTaskIdEventsData = { path: { task_id: string } - query?: { - continue_on_pause?: string - include_state_snapshot?: string - user?: string + query: { + continue_on_pause?: boolean + include_state_snapshot?: boolean + user: string } url: '/workflow/{task_id}/events' } export type GetWorkflowByTaskIdEventsErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type GetWorkflowByTaskIdEventsError - = GetWorkflowByTaskIdEventsErrors[keyof GetWorkflowByTaskIdEventsErrors] - export type GetWorkflowByTaskIdEventsResponses = { - 200: { - [key: string]: unknown - } + 200: EventStreamResponse } export type GetWorkflowByTaskIdEventsResponse @@ -3468,26 +3300,22 @@ export type GetWorkflowsLogsData = { body?: never path?: never query?: { - created_at__after?: string | null - created_at__before?: string | null - created_by_account?: string | null - created_by_end_user_session_id?: string | null - keyword?: string | null + created_at__after?: string + created_at__before?: string + created_by_account?: string + created_by_end_user_session_id?: string + keyword?: string limit?: number page?: number - status?: 'failed' | 'stopped' | 'succeeded' | null + status?: 'failed' | 'stopped' | 'succeeded' } url: '/workflows/logs' } export type GetWorkflowsLogsErrors = { - 401: { - [key: string]: unknown - } + 401: unknown } -export type GetWorkflowsLogsError = GetWorkflowsLogsErrors[keyof GetWorkflowsLogsErrors] - export type GetWorkflowsLogsResponses = { 200: WorkflowAppLogPaginationResponse } @@ -3502,29 +3330,15 @@ export type PostWorkflowsRunData = { } export type PostWorkflowsRunErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } - 429: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 404: unknown + 429: unknown + 500: unknown } -export type PostWorkflowsRunError = PostWorkflowsRunErrors[keyof PostWorkflowsRunErrors] - export type PostWorkflowsRunResponses = { - 200: { - [key: string]: unknown - } + 200: GeneratedAppResponse } export type PostWorkflowsRunResponse = PostWorkflowsRunResponses[keyof PostWorkflowsRunResponses] @@ -3539,17 +3353,10 @@ export type GetWorkflowsRunByWorkflowRunIdData = { } export type GetWorkflowsRunByWorkflowRunIdErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type GetWorkflowsRunByWorkflowRunIdError - = GetWorkflowsRunByWorkflowRunIdErrors[keyof GetWorkflowsRunByWorkflowRunIdErrors] - export type GetWorkflowsRunByWorkflowRunIdResponses = { 200: WorkflowRunResponse } @@ -3567,17 +3374,10 @@ export type PostWorkflowsTasksByTaskIdStopData = { } export type PostWorkflowsTasksByTaskIdStopErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type PostWorkflowsTasksByTaskIdStopError - = PostWorkflowsTasksByTaskIdStopErrors[keyof PostWorkflowsTasksByTaskIdStopErrors] - export type PostWorkflowsTasksByTaskIdStopResponses = { 200: SimpleResultResponse } @@ -3595,30 +3395,15 @@ export type PostWorkflowsByWorkflowIdRunData = { } export type PostWorkflowsByWorkflowIdRunErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } - 429: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 404: unknown + 429: unknown + 500: unknown } -export type PostWorkflowsByWorkflowIdRunError - = PostWorkflowsByWorkflowIdRunErrors[keyof PostWorkflowsByWorkflowIdRunErrors] - export type PostWorkflowsByWorkflowIdRunResponses = { - 200: { - [key: string]: unknown - } + 200: GeneratedAppResponse } export type PostWorkflowsByWorkflowIdRunResponse @@ -3634,18 +3419,11 @@ export type GetWorkspacesCurrentModelsModelTypesByModelTypeData = { } export type GetWorkspacesCurrentModelsModelTypesByModelTypeErrors = { - 401: { - [key: string]: unknown - } + 401: unknown } -export type GetWorkspacesCurrentModelsModelTypesByModelTypeError - = GetWorkspacesCurrentModelsModelTypesByModelTypeErrors[keyof GetWorkspacesCurrentModelsModelTypesByModelTypeErrors] - export type GetWorkspacesCurrentModelsModelTypesByModelTypeResponses = { - 200: { - [key: string]: unknown - } + 200: ProviderWithModelsListResponse } export type GetWorkspacesCurrentModelsModelTypesByModelTypeResponse diff --git a/packages/contracts/generated/api/service/zod.gen.ts b/packages/contracts/generated/api/service/zod.gen.ts index b664eec6f27..ce31666d074 100644 --- a/packages/contracts/generated/api/service/zod.gen.ts +++ b/packages/contracts/generated/api/service/zod.gen.ts @@ -21,6 +21,15 @@ export const zAnnotationCreatePayload = z.object({ question: z.string(), }) +/** + * AnnotationJobStatusResponse + */ +export const zAnnotationJobStatusResponse = z.object({ + error_msg: z.string().nullish(), + job_id: z.string(), + job_status: z.string(), +}) + /** * AnnotationList */ @@ -50,6 +59,30 @@ export const zAnnotationReplyActionPayload = z.object({ score_threshold: z.number(), }) +/** + * AppFeedbackResponse + */ +export const zAppFeedbackResponse = z.object({ + app_id: z.string(), + content: z.string().nullish(), + conversation_id: z.string(), + created_at: z.string(), + from_account_id: z.string().nullish(), + from_end_user_id: z.string().nullish(), + from_source: z.string(), + id: z.string(), + message_id: z.string(), + rating: z.string(), + updated_at: z.string(), +}) + +/** + * AppFeedbackListResponse + */ +export const zAppFeedbackListResponse = z.object({ + data: z.array(zAppFeedbackResponse), +}) + /** * AppInfoResponse */ @@ -61,6 +94,37 @@ export const zAppInfoResponse = z.object({ tags: z.array(z.string()), }) +/** + * AppMetaResponse + */ +export const zAppMetaResponse = z.object({ + tool_icons: z.record(z.string(), z.unknown()).optional(), +}) + +/** + * AudioBinaryResponse + */ +export const zAudioBinaryResponse = z.custom() + +/** + * AudioTranscriptResponse + */ +export const zAudioTranscriptResponse = z.object({ + text: z.string(), +}) + +/** + * BinaryFileResponse + */ +export const zBinaryFileResponse = z.custom() + +/** + * ButtonStyle + * + * Button styles for user actions. + */ +export const zButtonStyle = z.enum(['accent', 'default', 'ghost', 'primary']) + /** * ChatRequestPayload */ @@ -170,7 +234,7 @@ export const zCondition = z.object({ '≥', ]), name: z.string(), - value: z.unknown().optional(), + value: z.union([z.string(), z.array(z.string()), z.int(), z.number()]).nullish(), }) /** @@ -231,6 +295,13 @@ export const zConversationVariablesQuery = z.object({ variable_name: z.string().min(1).max(255).nullish(), }) +/** + * CustomConfigurationStatus + * + * Enum class for custom configuration status. + */ +export const zCustomConfigurationStatus = z.enum(['active', 'no-configure']) + /** * DatasetBoundTagResponse */ @@ -408,7 +479,7 @@ export const zDatasetRetrievalModelResponse = z.object({ score_threshold_enabled: z.boolean(), search_method: z.string(), top_k: z.int(), - weights: zDatasetWeightedScoreResponse.optional(), + weights: zDatasetWeightedScoreResponse.nullish(), }) /** @@ -431,7 +502,7 @@ export const zDatasetDetailResponse = z.object({ embedding_model_provider: z.string().nullable(), enable_api: z.boolean(), external_knowledge_info: zDatasetExternalKnowledgeInfoResponse.optional(), - external_retrieval_model: zDatasetExternalRetrievalModelResponse, + external_retrieval_model: zDatasetExternalRetrievalModelResponse.nullable(), icon_info: zDatasetIconInfoResponse.optional(), id: z.string(), indexing_technique: z.string().nullable(), @@ -472,7 +543,7 @@ export const zDatasetDetailWithPartialMembersResponse = z.object({ embedding_model_provider: z.string().nullable(), enable_api: z.boolean(), external_knowledge_info: zDatasetExternalKnowledgeInfoResponse.optional(), - external_retrieval_model: zDatasetExternalRetrievalModelResponse, + external_retrieval_model: zDatasetExternalRetrievalModelResponse.nullable(), icon_info: zDatasetIconInfoResponse.optional(), id: z.string(), indexing_technique: z.string().nullable(), @@ -505,6 +576,16 @@ export const zDatasetListResponse = z.object({ total: z.int(), }) +/** + * DatasourceCredentialInfoResponse + */ +export const zDatasourceCredentialInfoResponse = z.object({ + id: z.string().nullish(), + is_default: z.boolean().nullish(), + name: z.string().nullish(), + type: z.string().nullish(), +}) + /** * DatasourceNodeRunPayload */ @@ -515,6 +596,31 @@ export const zDatasourceNodeRunPayload = z.object({ is_published: z.boolean(), }) +/** + * DatasourcePluginResponse + */ +export const zDatasourcePluginResponse = z.object({ + credentials: z.array(zDatasourceCredentialInfoResponse), + datasource_type: z.string().nullish(), + node_id: z.string().nullish(), + plugin_id: z.string().nullish(), + provider_name: z.string().nullish(), + title: z.string().nullish(), + user_input_variables: z.array(z.record(z.string(), z.unknown())).optional(), +}) + +/** + * DatasourcePluginListResponse + */ +export const zDatasourcePluginListResponse = z.array(zDatasourcePluginResponse) + +/** + * DatasourcePluginsQuery + */ +export const zDatasourcePluginsQuery = z.object({ + is_published: z.boolean().optional().default(true), +}) + /** * DocumentBatchDownloadZipPayload * @@ -524,6 +630,13 @@ export const zDocumentBatchDownloadZipPayload = z.object({ document_ids: z.array(z.uuid()).min(1).max(100), }) +/** + * DocumentGetQuery + */ +export const zDocumentGetQuery = z.object({ + metadata: z.enum(['all', 'only', 'without']).optional().default('all'), +}) + /** * DocumentListQuery */ @@ -541,7 +654,44 @@ export const zDocumentMetadataResponse = z.object({ id: z.string(), name: z.string(), type: z.string(), - value: z.unknown().optional(), + value: z.union([z.string(), z.int(), z.number(), z.boolean()]).nullish(), +}) + +/** + * DocumentDetailResponse + */ +export const zDocumentDetailResponse = z.object({ + archived: z.boolean().nullish(), + average_segment_length: z.number().nullish(), + completed_at: z.int().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + created_from: z.string().nullish(), + data_source_info: z.record(z.string(), z.unknown()).nullish(), + data_source_type: z.string().nullish(), + dataset_process_rule: z.record(z.string(), z.unknown()).nullish(), + dataset_process_rule_id: z.string().nullish(), + disabled_at: z.int().nullish(), + disabled_by: z.string().nullish(), + display_status: z.string().nullish(), + doc_form: z.string().nullish(), + doc_language: z.string().nullish(), + doc_metadata: z.array(zDocumentMetadataResponse).nullish(), + doc_type: z.string().nullish(), + document_process_rule: z.record(z.string(), z.unknown()).nullish(), + enabled: z.boolean().nullish(), + error: z.string().nullish(), + hit_count: z.int().nullish(), + id: z.string(), + indexing_latency: z.number().nullish(), + indexing_status: z.string().nullish(), + name: z.string().nullish(), + need_summary: z.boolean().nullish(), + position: z.int().nullish(), + segment_count: z.int().nullish(), + summary_index_status: z.string().nullish(), + tokens: z.int().nullish(), + updated_at: z.int().nullish(), }) /** @@ -593,6 +743,13 @@ export const zDocumentListResponse = z.object({ total: z.int(), }) +/** + * DocumentStatusPayload + */ +export const zDocumentStatusPayload = z.object({ + document_ids: z.array(z.string()).optional(), +}) + /** * DocumentStatusResponse */ @@ -640,6 +797,16 @@ export const zEndUserDetail = z.object({ updated_at: z.iso.datetime(), }) +/** + * EventStreamResponse + */ +export const zEventStreamResponse = z.string() + +/** + * ExecutionContentType + */ +export const zExecutionContentType = z.enum(['human_input']) + /** * FeedbackListQuery */ @@ -648,6 +815,13 @@ export const zFeedbackListQuery = z.object({ page: z.int().gte(1).optional().default(1), }) +/** + * FetchFrom + * + * Enum class for fetch from. + */ +export const zFetchFrom = z.enum(['customizable-model', 'predefined-model']) + /** * FilePreviewQuery */ @@ -676,6 +850,44 @@ export const zFileResponse = z.object({ user_id: z.string().nullish(), }) +/** + * FileTransferMethod + */ +export const zFileTransferMethod = z.enum([ + 'datasource_file', + 'local_file', + 'remote_url', + 'tool_file', +]) + +/** + * FileType + */ +export const zFileType = z.enum(['audio', 'custom', 'document', 'image', 'video']) + +/** + * FileInputConfig + */ +export const zFileInputConfig = z.object({ + allowed_file_extensions: z.array(z.string()).optional(), + allowed_file_types: z.array(zFileType).optional(), + allowed_file_upload_methods: z.array(zFileTransferMethod).optional(), + output_variable_name: z.string(), + type: z.literal('file').optional().default('file'), +}) + +/** + * FileListInputConfig + */ +export const zFileListInputConfig = z.object({ + allowed_file_extensions: z.array(z.string()).optional(), + allowed_file_types: z.array(zFileType).optional(), + allowed_file_upload_methods: z.array(zFileTransferMethod).optional(), + number_limits: z.int().gte(0).optional().default(0), + output_variable_name: z.string(), + type: z.literal('file-list').optional().default('file-list'), +}) + /** * HitTestingChildChunk */ @@ -691,7 +903,7 @@ export const zHitTestingChildChunk = z.object({ */ export const zHitTestingDocument = z.object({ data_source_type: z.string(), - doc_metadata: z.unknown(), + doc_metadata: z.unknown().nullable(), doc_type: z.string().nullable(), id: z.string(), name: z.string(), @@ -754,7 +966,7 @@ export const zHitTestingRecord = z.object({ score: z.number().nullable(), segment: zHitTestingSegment, summary: z.string().nullable(), - tsne_position: z.unknown(), + tsne_position: z.unknown().nullable(), }) /** @@ -765,6 +977,32 @@ export const zHitTestingResponse = z.object({ records: z.array(zHitTestingRecord), }) +/** + * HumanInputFormDefinitionResponse + */ +export const zHumanInputFormDefinitionResponse = z.object({ + expiration_time: z.int().nullish(), + form_content: z.string(), + inputs: z.array(z.record(z.string(), z.unknown())).optional(), + resolved_default_values: z.record(z.string(), z.string()), + user_actions: z.array(z.record(z.string(), z.unknown())).optional(), +}) + +/** + * HumanInputFormSubmitResponse + */ +export const zHumanInputFormSubmitResponse = z.record(z.string(), z.never()) + +/** + * I18nObject + * + * Model class for i18n object. + */ +export const zI18nObject = z.object({ + en_US: z.string(), + zh_Hans: z.string().nullish(), +}) + /** * IndexInfoResponse */ @@ -774,14 +1012,63 @@ export const zIndexInfoResponse = z.object({ welcome: z.string(), }) -export const zJsonValue = z.unknown() +export const zJsonObject = z.record(z.string(), z.unknown()) + +export const zJsonValue = z + .union([ + z.string(), + z.int(), + z.number(), + z.boolean(), + z.record(z.string(), z.unknown()), + z.array(z.unknown()), + ]) + .nullable() + +/** + * AgentThought + */ +export const zAgentThought = z.object({ + chain_id: z.string().nullish(), + created_at: z.int().nullish(), + files: z.array(z.string()), + id: z.string(), + message_id: z.string(), + observation: z.string().nullish(), + position: z.int(), + thought: z.string().nullish(), + tool: z.string().nullish(), + tool_input: z.string().nullish(), + tool_labels: zJsonValue, +}) + +/** + * GeneratedAppResponse + */ +export const zGeneratedAppResponse = zJsonValue + +export const zJsonValueType = z.unknown() + +export const zJsonValue2 = z.unknown() + +/** + * HumanInputFormSubmissionData + */ +export const zHumanInputFormSubmissionData = z.object({ + action_id: z.string(), + action_text: z.string(), + node_id: z.string(), + node_title: z.string(), + rendered_content: z.string(), + submitted_data: z.record(z.string(), zJsonValue2).nullish(), +}) /** * HumanInputFormSubmitPayload */ export const zHumanInputFormSubmitPayload = z.object({ action: z.string(), - inputs: z.record(z.string(), zJsonValue), + inputs: z.record(z.string(), zJsonValue2), }) /** @@ -807,6 +1094,21 @@ export const zMessageFeedbackPayload = z.object({ rating: z.enum(['dislike', 'like']).nullish(), }) +/** + * MessageFile + */ +export const zMessageFile = z.object({ + belongs_to: z.string().nullish(), + filename: z.string(), + id: z.string(), + mime_type: z.string().nullish(), + size: z.int().nullish(), + transfer_method: z.string(), + type: z.string(), + upload_file_id: z.string().nullish(), + url: z.string().nullish(), +}) + /** * MessageListQuery */ @@ -830,7 +1132,7 @@ export const zMetadataArgs = z.object({ export const zMetadataDetail = z.object({ id: z.string(), name: z.string(), - value: z.unknown().optional(), + value: z.union([z.string(), z.int(), z.number()]).nullish(), }) /** @@ -868,6 +1170,71 @@ export const zMetadataUpdatePayload = z.object({ name: z.string(), }) +/** + * ModelFeature + * + * Enum class for llm feature. + */ +export const zModelFeature = z.enum([ + 'agent-thought', + 'audio', + 'document', + 'multi-tool-call', + 'polling', + 'stream-tool-call', + 'structured-output', + 'tool-call', + 'video', + 'vision', +]) + +/** + * ModelPropertyKey + * + * Enum class for model property key. + */ +export const zModelPropertyKey = z.enum([ + 'audio_type', + 'context_size', + 'default_voice', + 'file_upload_limit', + 'max_characters_per_chunk', + 'max_chunks', + 'max_workers', + 'mode', + 'supported_file_extensions', + 'voices', + 'word_limit', +]) + +/** + * ModelStatus + * + * Enum class for model status. + */ +export const zModelStatus = z.enum([ + 'active', + 'credential-removed', + 'disabled', + 'no-configure', + 'no-permission', + 'quota-exceeded', +]) + +/** + * ModelType + * + * Enum class for model type. + */ +export const zModelType = z.enum([ + 'llm', + 'moderation', + 'rerank', + 'speech2text', + 'text-embedding', + 'tts', +]) + /** * PermissionEnum * @@ -887,6 +1254,19 @@ export const zPipelineRunApiEntity = z.object({ start_node_id: z.string(), }) +/** + * PipelineUploadFileResponse + */ +export const zPipelineUploadFileResponse = z.object({ + created_at: z.string().nullish(), + created_by: z.string(), + extension: z.string(), + id: z.string(), + mime_type: z.string().nullish(), + name: z.string(), + size: z.int(), +}) + /** * PreProcessingRule */ @@ -902,6 +1282,46 @@ export const zPreProcessingRule = z.object({ */ export const zProcessRuleMode = z.enum(['automatic', 'custom', 'hierarchical']) +/** + * ProviderModelWithStatusEntity + * + * Model class for model response. + */ +export const zProviderModelWithStatusEntity = z.object({ + deprecated: z.boolean().optional().default(false), + features: z.array(zModelFeature).nullish(), + fetch_from: zFetchFrom, + has_invalid_load_balancing_configs: z.boolean().optional().default(false), + label: zI18nObject, + load_balancing_enabled: z.boolean().optional().default(false), + model: z.string(), + model_properties: z.record(z.string(), z.unknown()), + model_type: zModelType, + status: zModelStatus, +}) + +/** + * ProviderWithModelsResponse + * + * Model class for provider with models response. + */ +export const zProviderWithModelsResponse = z.object({ + icon_small: zI18nObject.nullish(), + icon_small_dark: zI18nObject.nullish(), + label: zI18nObject, + models: z.array(zProviderModelWithStatusEntity), + provider: z.string(), + status: zCustomConfigurationStatus, + tenant_id: z.string(), +}) + +/** + * ProviderWithModelsListResponse + */ +export const zProviderWithModelsListResponse = z.object({ + data: z.array(zProviderWithModelsResponse), +}) + /** * RerankingModel */ @@ -927,6 +1347,29 @@ export const zRetrievalMethod = z.enum([ 'semantic_search', ]) +/** + * RetrieverResource + */ +export const zRetrieverResource = z.object({ + content: z.string().nullish(), + created_at: z.int().nullish(), + data_source_type: z.string().nullish(), + dataset_id: z.string().nullish(), + dataset_name: z.string().nullish(), + document_id: z.string().nullish(), + document_name: z.string().nullish(), + hit_count: z.int().nullish(), + id: z.string().optional(), + index_node_hash: z.string().nullish(), + message_id: z.string().optional(), + position: z.int(), + score: z.number().nullish(), + segment_id: z.string().nullish(), + segment_position: z.int().nullish(), + summary: z.string().nullish(), + word_count: z.int().nullish(), +}) + /** * SegmentAttachmentResponse */ @@ -1062,8 +1505,8 @@ export const zSegmentation = z.object({ export const zRule = z.object({ parent_mode: z.enum(['full-doc', 'paragraph']).nullish(), pre_processing_rules: z.array(zPreProcessingRule).nullish(), - segmentation: zSegmentation.optional(), - subchunk_segmentation: zSegmentation.optional(), + segmentation: zSegmentation.nullish(), + subchunk_segmentation: zSegmentation.nullish(), }) /** @@ -1071,7 +1514,7 @@ export const zRule = z.object({ */ export const zProcessRule = z.object({ mode: zProcessRuleMode, - rules: zRule.optional(), + rules: zRule.nullish(), }) /** @@ -1083,6 +1526,28 @@ export const zSimpleAccount = z.object({ name: z.string(), }) +/** + * SimpleConversation + */ +export const zSimpleConversation = z.object({ + created_at: z.int().nullish(), + id: z.string(), + inputs: z.record(z.string(), zJsonValue), + introduction: z.string().nullish(), + name: z.string(), + status: z.string(), + updated_at: z.int().nullish(), +}) + +/** + * ConversationInfiniteScrollPagination + */ +export const zConversationInfiniteScrollPagination = z.object({ + data: z.array(zSimpleConversation), + has_more: z.boolean(), + limit: z.int(), +}) + /** * SimpleEndUser */ @@ -1093,6 +1558,13 @@ export const zSimpleEndUser = z.object({ type: z.string(), }) +/** + * SimpleFeedback + */ +export const zSimpleFeedback = z.object({ + rating: z.string().nullish(), +}) + /** * SimpleResultResponse */ @@ -1121,13 +1593,42 @@ export const zSite = z.object({ icon: z.string().nullish(), icon_background: z.string().nullish(), icon_type: z.string().nullish(), - icon_url: z.string().readonly().nullable(), + icon_url: z.string().nullable(), privacy_policy: z.string().nullish(), show_workflow_steps: z.boolean(), title: z.string(), use_icon_as_answer_icon: z.boolean(), }) +/** + * SystemParameters + */ +export const zSystemParameters = z.object({ + audio_file_size_limit: z.int(), + file_size_limit: z.int(), + image_file_size_limit: z.int(), + video_file_size_limit: z.int(), + workflow_file_upload_limit: z.int(), +}) + +/** + * Parameters + */ +export const zParameters = z.object({ + annotation_reply: zJsonObject, + file_upload: zJsonObject, + more_like_this: zJsonObject, + opening_statement: z.string().nullish(), + retriever_resource: zJsonObject, + sensitive_word_avoidance: zJsonObject, + speech_to_text: zJsonObject, + suggested_questions: z.array(z.string()), + suggested_questions_after_answer: zJsonObject, + system_parameters: zSystemParameters, + text_to_speech: zJsonObject, + user_input_form: z.array(zJsonObject), +}) + /** * TagBindingPayload */ @@ -1186,6 +1687,128 @@ export const zUrlResponse = z.object({ url: z.string(), }) +/** + * UserActionConfig + * + * User action configuration. + */ +export const zUserActionConfig = z.object({ + button_style: zButtonStyle.optional().default('default'), + id: z.string().max(20), + title: z.string().max(100), +}) + +/** + * ValueSourceType + * + * ValueSourceType records whether the value comes from a static setting + * in form definiton, or a variable while the workflow is running. + */ +export const zValueSourceType = z.enum(['constant', 'variable']) + +/** + * StringListSource + */ +export const zStringListSource = z.object({ + selector: z.array(z.string()).optional(), + type: zValueSourceType, + value: z.array(z.string()).optional(), +}) + +/** + * SelectInputConfig + */ +export const zSelectInputConfig = z.object({ + option_source: zStringListSource, + output_variable_name: z.string(), + type: z.literal('select').optional().default('select'), +}) + +/** + * StringSource + * + * Default configuration for form inputs. + */ +export const zStringSource = z.object({ + selector: z.array(z.string()).optional(), + type: zValueSourceType, + value: z.string().optional().default(''), +}) + +/** + * ParagraphInputConfig + * + * Form input definition. + */ +export const zParagraphInputConfig = z.object({ + default: zStringSource.nullish(), + output_variable_name: z.string(), + type: z.literal('paragraph').optional().default('paragraph'), +}) + +export const zFormInputConfig = z.discriminatedUnion('type', [ + zParagraphInputConfig.extend({ type: z.literal('paragraph') }), + zSelectInputConfig.extend({ type: z.literal('select') }), + zFileInputConfig.extend({ type: z.literal('file') }), + zFileListInputConfig.extend({ type: z.literal('file-list') }), +]) + +/** + * HumanInputFormDefinition + */ +export const zHumanInputFormDefinition = z.object({ + actions: z.array(zUserActionConfig).optional(), + display_in_ui: z.boolean().optional().default(false), + expiration_time: z.int(), + form_content: z.string(), + form_id: z.string(), + form_token: z.string().nullish(), + inputs: z.array(zFormInputConfig).optional(), + node_id: z.string(), + node_title: z.string(), + resolved_default_values: z.record(z.string(), z.unknown()).optional(), +}) + +/** + * HumanInputContent + */ +export const zHumanInputContent = z.object({ + form_definition: zHumanInputFormDefinition.nullish(), + form_submission_data: zHumanInputFormSubmissionData.nullish(), + submitted: z.boolean(), + type: zExecutionContentType.optional().default('human_input'), + workflow_run_id: z.string(), +}) + +/** + * MessageListItem + */ +export const zMessageListItem = z.object({ + agent_thoughts: z.array(zAgentThought), + answer: z.string(), + conversation_id: z.string(), + created_at: z.int().nullish(), + error: z.string().nullish(), + extra_contents: z.array(zHumanInputContent), + feedback: zSimpleFeedback.nullish(), + id: z.string(), + inputs: z.record(z.string(), zJsonValueType), + message_files: z.array(zMessageFile), + parent_message_id: z.string().nullish(), + query: z.string(), + retriever_resources: z.array(zRetrieverResource), + status: z.string(), +}) + +/** + * MessageInfiniteScrollPagination + */ +export const zMessageInfiniteScrollPagination = z.object({ + data: z.array(zMessageListItem), + has_more: z.boolean(), + limit: z.int(), +}) + /** * WeightKeywordSetting */ @@ -1206,8 +1829,8 @@ export const zWeightVectorSetting = z.object({ * WeightModel */ export const zWeightModel = z.object({ - keyword_setting: zWeightKeywordSetting.optional(), - vector_setting: zWeightVectorSetting.optional(), + keyword_setting: zWeightKeywordSetting.nullish(), + vector_setting: zWeightVectorSetting.nullish(), weight_type: z.enum(['customized', 'keyword_first', 'semantic_first']).nullish(), }) @@ -1215,15 +1838,15 @@ export const zWeightModel = z.object({ * RetrievalModel */ export const zRetrievalModel = z.object({ - metadata_filtering_conditions: zMetadataFilteringCondition.optional(), + metadata_filtering_conditions: zMetadataFilteringCondition.nullish(), reranking_enable: z.boolean(), reranking_mode: z.string().nullish(), - reranking_model: zRerankingModel.optional(), + reranking_model: zRerankingModel.nullish(), score_threshold: z.number().nullish(), score_threshold_enabled: z.boolean(), search_method: zRetrievalMethod, top_k: z.int(), - weights: zWeightModel.optional(), + weights: zWeightModel.nullish(), }) /** @@ -1237,9 +1860,9 @@ export const zDatasetCreatePayload = z.object({ external_knowledge_id: z.string().nullish(), indexing_technique: z.enum(['economy', 'high_quality']).nullish(), name: z.string().min(1).max(40), - permission: zPermissionEnum.optional(), + permission: zPermissionEnum.nullish().default('only_me'), provider: z.string().optional().default('vendor'), - retrieval_model: zRetrievalModel.optional(), + retrieval_model: zRetrievalModel.nullish(), summary_index_setting: z.record(z.string(), z.unknown()).nullish(), }) @@ -1256,8 +1879,8 @@ export const zDatasetUpdatePayload = z.object({ indexing_technique: z.enum(['economy', 'high_quality']).nullish(), name: z.string().min(1).max(40).nullish(), partial_member_list: z.array(z.record(z.string(), z.string())).nullish(), - permission: zPermissionEnum.optional(), - retrieval_model: zRetrievalModel.optional(), + permission: zPermissionEnum.nullish(), + retrieval_model: zRetrievalModel.nullish(), }) /** @@ -1271,8 +1894,8 @@ export const zDocumentTextCreatePayload = z.object({ indexing_technique: z.string().nullish(), name: z.string(), original_document_id: z.string().nullish(), - process_rule: zProcessRule.optional(), - retrieval_model: zRetrievalModel.optional(), + process_rule: zProcessRule.nullish(), + retrieval_model: zRetrievalModel.nullish(), text: z.string(), }) @@ -1283,8 +1906,8 @@ export const zDocumentTextUpdate = z.object({ doc_form: z.string().optional().default('text_model'), doc_language: z.string().optional().default('English'), name: z.string().nullish(), - process_rule: zProcessRule.optional(), - retrieval_model: zRetrievalModel.optional(), + process_rule: zProcessRule.nullish(), + retrieval_model: zRetrievalModel.nullish(), text: z.string().nullish(), }) @@ -1295,7 +1918,16 @@ export const zHitTestingPayload = z.object({ attachment_ids: z.array(z.string()).nullish(), external_retrieval_model: z.record(z.string(), z.unknown()).nullish(), query: z.string().max(250), - retrieval_model: zRetrievalModel.optional(), + retrieval_model: zRetrievalModel.nullish(), +}) + +/** + * WorkflowEventsQuery + */ +export const zWorkflowEventsQuery = z.object({ + continue_on_pause: z.boolean().optional().default(false), + include_state_snapshot: z.boolean().optional().default(false), + user: z.string(), }) /** @@ -1317,7 +1949,7 @@ export const zWorkflowLogQuery = z.object({ */ export const zWorkflowRunForLogResponse = z.object({ created_at: z.int().nullish(), - elapsed_time: z.unknown().optional(), + elapsed_time: z.union([z.number(), z.int()]).nullish(), error: z.string().nullish(), exceptions_count: z.int().nullish(), finished_at: z.int().nullish(), @@ -1334,13 +1966,22 @@ export const zWorkflowRunForLogResponse = z.object({ */ export const zWorkflowAppLogPartialResponse = z.object({ created_at: z.int().nullish(), - created_by_account: zSimpleAccount.optional(), - created_by_end_user: zSimpleEndUser.optional(), + created_by_account: zSimpleAccount.nullish(), + created_by_end_user: zSimpleEndUser.nullish(), created_by_role: z.string().nullish(), created_from: z.string().nullish(), - details: z.unknown().optional(), + details: z + .union([ + z.record(z.string(), z.unknown()), + z.array(z.unknown()), + z.string(), + z.int(), + z.number(), + z.boolean(), + ]) + .nullish(), id: z.string(), - workflow_run: zWorkflowRunForLogResponse.optional(), + workflow_run: zWorkflowRunForLogResponse.nullish(), }) /** @@ -1369,11 +2010,20 @@ export const zWorkflowRunPayload = z.object({ */ export const zWorkflowRunResponse = z.object({ created_at: z.int().nullish(), - elapsed_time: z.unknown().optional(), + elapsed_time: z.union([z.number(), z.int()]).nullish(), error: z.string().nullish(), finished_at: z.int().nullish(), id: z.string(), - inputs: z.unknown().optional(), + inputs: z + .union([ + z.record(z.string(), z.unknown()), + z.array(z.unknown()), + z.string(), + z.int(), + z.number(), + z.boolean(), + ]) + .nullish(), outputs: z.record(z.string(), z.unknown()).optional(), status: z.string(), total_steps: z.int().nullish(), @@ -1381,6 +2031,16 @@ export const zWorkflowRunResponse = z.object({ workflow_id: z.string(), }) +/** + * GeneratedAppResponse + */ +export const zGeneratedAppResponseWritable = zJsonValue + +/** + * HumanInputFormSubmitResponse + */ +export const zHumanInputFormSubmitResponseWritable = z.record(z.string(), z.never()) + /** * Site */ @@ -1413,7 +2073,7 @@ export const zGetAppFeedbacksQuery = z.object({ /** * Feedbacks retrieved successfully */ -export const zGetAppFeedbacksResponse = z.record(z.string(), z.unknown()) +export const zGetAppFeedbacksResponse = zAppFeedbackListResponse export const zPostAppsAnnotationReplyByActionBody = zAnnotationReplyActionPayload @@ -1424,7 +2084,7 @@ export const zPostAppsAnnotationReplyByActionPath = z.object({ /** * Action completed successfully */ -export const zPostAppsAnnotationReplyByActionResponse = z.record(z.string(), z.unknown()) +export const zPostAppsAnnotationReplyByActionResponse = zAnnotationJobStatusResponse export const zGetAppsAnnotationReplyByActionStatusByJobIdPath = z.object({ action: z.string(), @@ -1434,10 +2094,7 @@ export const zGetAppsAnnotationReplyByActionStatusByJobIdPath = z.object({ /** * Job status retrieved successfully */ -export const zGetAppsAnnotationReplyByActionStatusByJobIdResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetAppsAnnotationReplyByActionStatusByJobIdResponse = zAnnotationJobStatusResponse export const zGetAppsAnnotationsQuery = z.object({ keyword: z.string().optional().default(''), @@ -1464,7 +2121,7 @@ export const zDeleteAppsAnnotationsByAnnotationIdPath = z.object({ /** * Annotation deleted successfully */ -export const zDeleteAppsAnnotationsByAnnotationIdResponse = z.record(z.string(), z.never()) +export const zDeleteAppsAnnotationsByAnnotationIdResponse = z.void() export const zPutAppsAnnotationsByAnnotationIdBody = zAnnotationCreatePayload @@ -1480,14 +2137,14 @@ export const zPutAppsAnnotationsByAnnotationIdResponse = zAnnotation /** * Audio successfully transcribed */ -export const zPostAudioToTextResponse = z.record(z.string(), z.unknown()) +export const zPostAudioToTextResponse = zAudioTranscriptResponse export const zPostChatMessagesBody = zChatRequestPayload /** * Message sent successfully */ -export const zPostChatMessagesResponse = z.record(z.string(), z.unknown()) +export const zPostChatMessagesResponse = zGeneratedAppResponse export const zPostChatMessagesByTaskIdStopPath = z.object({ task_id: z.string(), @@ -1503,7 +2160,7 @@ export const zPostCompletionMessagesBody = zCompletionRequestPayload /** * Completion created successfully */ -export const zPostCompletionMessagesResponse = z.record(z.string(), z.unknown()) +export const zPostCompletionMessagesResponse = zGeneratedAppResponse export const zPostCompletionMessagesByTaskIdStopPath = z.object({ task_id: z.string(), @@ -1515,7 +2172,7 @@ export const zPostCompletionMessagesByTaskIdStopPath = z.object({ export const zPostCompletionMessagesByTaskIdStopResponse = zSimpleResultResponse export const zGetConversationsQuery = z.object({ - last_id: z.string().nullish(), + last_id: z.string().optional(), limit: z.int().gte(1).lte(100).optional().default(20), sort_by: z .enum(['-created_at', '-updated_at', 'created_at', 'updated_at']) @@ -1526,7 +2183,7 @@ export const zGetConversationsQuery = z.object({ /** * Conversations retrieved successfully */ -export const zGetConversationsResponse = z.record(z.string(), z.unknown()) +export const zGetConversationsResponse = zConversationInfiniteScrollPagination export const zDeleteConversationsByCIdPath = z.object({ c_id: z.string(), @@ -1535,7 +2192,7 @@ export const zDeleteConversationsByCIdPath = z.object({ /** * Conversation deleted successfully */ -export const zDeleteConversationsByCIdResponse = z.record(z.string(), z.never()) +export const zDeleteConversationsByCIdResponse = z.void() export const zPostConversationsByCIdNameBody = zConversationRenamePayload @@ -1546,16 +2203,16 @@ export const zPostConversationsByCIdNamePath = z.object({ /** * Conversation renamed successfully */ -export const zPostConversationsByCIdNameResponse = z.record(z.string(), z.unknown()) +export const zPostConversationsByCIdNameResponse = zSimpleConversation export const zGetConversationsByCIdVariablesPath = z.object({ c_id: z.string(), }) export const zGetConversationsByCIdVariablesQuery = z.object({ - last_id: z.string().nullish(), + last_id: z.string().optional(), limit: z.int().gte(1).lte(100).optional().default(20), - variable_name: z.string().min(1).max(255).nullish(), + variable_name: z.string().min(1).max(255).optional(), }) /** @@ -1599,14 +2256,14 @@ export const zPostDatasetsResponse = zDatasetDetailResponse /** * File uploaded successfully */ -export const zPostDatasetsPipelineFileUploadResponse = z.record(z.string(), z.unknown()) +export const zPostDatasetsPipelineFileUploadResponse = zPipelineUploadFileResponse export const zDeleteDatasetsTagsBody = zTagDeletePayload /** * Tag deleted successfully */ -export const zDeleteDatasetsTagsResponse = z.record(z.string(), z.never()) +export const zDeleteDatasetsTagsResponse = z.void() /** * Tags retrieved successfully @@ -1632,14 +2289,14 @@ export const zPostDatasetsTagsBindingBody = zTagBindingPayload /** * Tags bound successfully */ -export const zPostDatasetsTagsBindingResponse = z.record(z.string(), z.never()) +export const zPostDatasetsTagsBindingResponse = z.void() export const zPostDatasetsTagsUnbindingBody = zTagUnbindingPayload /** * Tags unbound successfully */ -export const zPostDatasetsTagsUnbindingResponse = z.record(z.string(), z.never()) +export const zPostDatasetsTagsUnbindingResponse = z.void() export const zDeleteDatasetsByDatasetIdPath = z.object({ dataset_id: z.string(), @@ -1648,7 +2305,7 @@ export const zDeleteDatasetsByDatasetIdPath = z.object({ /** * Dataset deleted successfully */ -export const zDeleteDatasetsByDatasetIdResponse = z.record(z.string(), z.never()) +export const zDeleteDatasetsByDatasetIdResponse = z.void() export const zGetDatasetsByDatasetIdPath = z.object({ dataset_id: z.string(), @@ -1745,10 +2402,7 @@ export const zPostDatasetsByDatasetIdDocumentsDownloadZipPath = z.object({ /** * ZIP archive generated successfully */ -export const zPostDatasetsByDatasetIdDocumentsDownloadZipResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostDatasetsByDatasetIdDocumentsDownloadZipResponse = zBinaryFileResponse export const zPostDatasetsByDatasetIdDocumentsMetadataBody = zMetadataOperationData @@ -1761,6 +2415,8 @@ export const zPostDatasetsByDatasetIdDocumentsMetadataPath = z.object({ */ export const zPostDatasetsByDatasetIdDocumentsMetadataResponse = zDatasetMetadataActionResponse +export const zPatchDatasetsByDatasetIdDocumentsStatusByActionBody = zDocumentStatusPayload + export const zPatchDatasetsByDatasetIdDocumentsStatusByActionPath = z.object({ action: z.string(), dataset_id: z.string(), @@ -1790,23 +2446,21 @@ export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdPath = z.object({ /** * Document deleted successfully */ -export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdResponse = z.record( - z.string(), - z.never(), -) +export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdResponse = z.void() export const zGetDatasetsByDatasetIdDocumentsByDocumentIdPath = z.object({ dataset_id: z.string(), document_id: z.string(), }) +export const zGetDatasetsByDatasetIdDocumentsByDocumentIdQuery = z.object({ + metadata: z.enum(['all', 'only', 'without']).optional().default('all'), +}) + /** * Document retrieved successfully */ -export const zGetDatasetsByDatasetIdDocumentsByDocumentIdResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetDatasetsByDatasetIdDocumentsByDocumentIdResponse = zDocumentDetailResponse export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdBody = z.object({ data: z.string().optional(), @@ -1872,10 +2526,7 @@ export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdP /** * Segment deleted successfully */ -export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponse = z.record( - z.string(), - z.never(), -) +export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponse = z.void() export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdPath = z.object({ dataset_id: z.string(), @@ -1952,7 +2603,7 @@ export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdC * Child chunk deleted successfully */ export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponse - = z.record(z.string(), z.never()) + = z.void() export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdBody = zChildChunkUpdatePayload @@ -2088,10 +2739,7 @@ export const zDeleteDatasetsByDatasetIdMetadataByMetadataIdPath = z.object({ /** * Metadata deleted successfully */ -export const zDeleteDatasetsByDatasetIdMetadataByMetadataIdResponse = z.record( - z.string(), - z.never(), -) +export const zDeleteDatasetsByDatasetIdMetadataByMetadataIdResponse = z.void() export const zPatchDatasetsByDatasetIdMetadataByMetadataIdBody = zMetadataUpdatePayload @@ -2110,16 +2758,17 @@ export const zGetDatasetsByDatasetIdPipelineDatasourcePluginsPath = z.object({ }) export const zGetDatasetsByDatasetIdPipelineDatasourcePluginsQuery = z.object({ - is_published: z.string().optional(), + is_published: z.boolean().optional().default(true), }) /** * Datasource plugins retrieved successfully */ -export const zGetDatasetsByDatasetIdPipelineDatasourcePluginsResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetDatasetsByDatasetIdPipelineDatasourcePluginsResponse + = zDatasourcePluginListResponse + +export const zPostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunBody + = zDatasourceNodeRunPayload export const zPostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunPath = z.object({ dataset_id: z.string(), @@ -2129,10 +2778,10 @@ export const zPostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunPath = z. /** * Datasource node run successfully */ -export const zPostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunResponse + = zGeneratedAppResponse + +export const zPostDatasetsByDatasetIdPipelineRunBody = zPipelineRunApiEntity export const zPostDatasetsByDatasetIdPipelineRunPath = z.object({ dataset_id: z.string(), @@ -2141,7 +2790,7 @@ export const zPostDatasetsByDatasetIdPipelineRunPath = z.object({ /** * Pipeline run successfully */ -export const zPostDatasetsByDatasetIdPipelineRunResponse = z.record(z.string(), z.unknown()) +export const zPostDatasetsByDatasetIdPipelineRunResponse = zGeneratedAppResponse export const zPostDatasetsByDatasetIdRetrieveBody = zHitTestingPayload @@ -2188,7 +2837,7 @@ export const zGetFilesByFileIdPreviewQuery = z.object({ /** * File retrieved successfully */ -export const zGetFilesByFileIdPreviewResponse = z.record(z.string(), z.unknown()) +export const zGetFilesByFileIdPreviewResponse = zBinaryFileResponse export const zGetFormHumanInputByFormTokenPath = z.object({ form_token: z.string(), @@ -2197,7 +2846,7 @@ export const zGetFormHumanInputByFormTokenPath = z.object({ /** * Form retrieved successfully */ -export const zGetFormHumanInputByFormTokenResponse = z.record(z.string(), z.unknown()) +export const zGetFormHumanInputByFormTokenResponse = zHumanInputFormDefinitionResponse export const zPostFormHumanInputByFormTokenBody = zHumanInputFormSubmitPayload @@ -2208,7 +2857,7 @@ export const zPostFormHumanInputByFormTokenPath = z.object({ /** * Form submitted successfully */ -export const zPostFormHumanInputByFormTokenResponse = z.record(z.string(), z.unknown()) +export const zPostFormHumanInputByFormTokenResponse = zHumanInputFormSubmitResponse /** * Application info retrieved successfully @@ -2217,14 +2866,14 @@ export const zGetInfoResponse = zAppInfoResponse export const zGetMessagesQuery = z.object({ conversation_id: z.string(), - first_id: z.string().nullish(), + first_id: z.string().optional(), limit: z.int().gte(1).lte(100).optional().default(20), }) /** * Messages retrieved successfully */ -export const zGetMessagesResponse = z.record(z.string(), z.unknown()) +export const zGetMessagesResponse = zMessageInfiniteScrollPagination export const zPostMessagesByMessageIdFeedbacksBody = zMessageFeedbackPayload @@ -2249,12 +2898,12 @@ export const zGetMessagesByMessageIdSuggestedResponse = zSimpleResultStringListR /** * Metadata retrieved successfully */ -export const zGetMetaResponse = z.record(z.string(), z.unknown()) +export const zGetMetaResponse = zAppMetaResponse /** * Parameters retrieved successfully */ -export const zGetParametersResponse = z.record(z.string(), z.unknown()) +export const zGetParametersResponse = zParameters /** * Site configuration retrieved successfully @@ -2266,32 +2915,32 @@ export const zPostTextToAudioBody = zTextToAudioPayload /** * Text successfully converted to audio */ -export const zPostTextToAudioResponse = z.record(z.string(), z.unknown()) +export const zPostTextToAudioResponse = zAudioBinaryResponse export const zGetWorkflowByTaskIdEventsPath = z.object({ task_id: z.string(), }) export const zGetWorkflowByTaskIdEventsQuery = z.object({ - continue_on_pause: z.string().optional(), - include_state_snapshot: z.string().optional(), - user: z.string().optional(), + continue_on_pause: z.boolean().optional().default(false), + include_state_snapshot: z.boolean().optional().default(false), + user: z.string(), }) /** * SSE event stream */ -export const zGetWorkflowByTaskIdEventsResponse = z.record(z.string(), z.unknown()) +export const zGetWorkflowByTaskIdEventsResponse = zEventStreamResponse export const zGetWorkflowsLogsQuery = z.object({ - created_at__after: z.string().nullish(), - created_at__before: z.string().nullish(), - created_by_account: z.string().nullish(), - created_by_end_user_session_id: z.string().nullish(), - keyword: z.string().nullish(), + created_at__after: z.string().optional(), + created_at__before: z.string().optional(), + created_by_account: z.string().optional(), + created_by_end_user_session_id: z.string().optional(), + keyword: z.string().optional(), limit: z.int().gte(1).lte(100).optional().default(20), page: z.int().gte(1).lte(99999).optional().default(1), - status: z.enum(['failed', 'stopped', 'succeeded']).nullish(), + status: z.enum(['failed', 'stopped', 'succeeded']).optional(), }) /** @@ -2304,7 +2953,7 @@ export const zPostWorkflowsRunBody = zWorkflowRunPayload /** * Workflow executed successfully */ -export const zPostWorkflowsRunResponse = z.record(z.string(), z.unknown()) +export const zPostWorkflowsRunResponse = zGeneratedAppResponse export const zGetWorkflowsRunByWorkflowRunIdPath = z.object({ workflow_run_id: z.string(), @@ -2333,7 +2982,7 @@ export const zPostWorkflowsByWorkflowIdRunPath = z.object({ /** * Workflow executed successfully */ -export const zPostWorkflowsByWorkflowIdRunResponse = z.record(z.string(), z.unknown()) +export const zPostWorkflowsByWorkflowIdRunResponse = zGeneratedAppResponse export const zGetWorkspacesCurrentModelsModelTypesByModelTypePath = z.object({ model_type: z.string(), @@ -2342,7 +2991,5 @@ export const zGetWorkspacesCurrentModelsModelTypesByModelTypePath = z.object({ /** * Models retrieved successfully */ -export const zGetWorkspacesCurrentModelsModelTypesByModelTypeResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetWorkspacesCurrentModelsModelTypesByModelTypeResponse + = zProviderWithModelsListResponse diff --git a/packages/contracts/generated/api/web/orpc.gen.ts b/packages/contracts/generated/api/web/orpc.gen.ts index ee46faf3506..f0ef39fd976 100644 --- a/packages/contracts/generated/api/web/orpc.gen.ts +++ b/packages/contracts/generated/api/web/orpc.gen.ts @@ -12,6 +12,7 @@ import { zGetConversationsResponse, zGetFormHumanInputByFormTokenPath, zGetFormHumanInputByFormTokenResponse, + zGetLoginStatusQuery, zGetLoginStatusResponse, zGetMessagesByMessageIdMoreLikeThisPath, zGetMessagesByMessageIdMoreLikeThisQuery, @@ -22,6 +23,7 @@ import { zGetMessagesResponse, zGetMetaResponse, zGetParametersResponse, + zGetPassportQuery, zGetPassportResponse, zGetRemoteFilesByUrlPath, zGetRemoteFilesByUrlResponse, @@ -48,6 +50,7 @@ import { zPostCompletionMessagesByTaskIdStopPath, zPostCompletionMessagesByTaskIdStopResponse, zPostCompletionMessagesResponse, + zPostConversationsByCIdNameBody, zPostConversationsByCIdNamePath, zPostConversationsByCIdNameQuery, zPostConversationsByCIdNameResponse, @@ -62,6 +65,7 @@ import { zPostForgotPasswordResponse, zPostForgotPasswordValidityBody, zPostForgotPasswordValidityResponse, + zPostFormHumanInputByFormTokenBody, zPostFormHumanInputByFormTokenPath, zPostFormHumanInputByFormTokenResponse, zPostFormHumanInputByFormTokenUploadTokenPath, @@ -70,10 +74,13 @@ import { zPostLoginBody, zPostLoginResponse, zPostLogoutResponse, + zPostMessagesByMessageIdFeedbacksBody, zPostMessagesByMessageIdFeedbacksPath, zPostMessagesByMessageIdFeedbacksQuery, zPostMessagesByMessageIdFeedbacksResponse, + zPostRemoteFilesUploadBody, zPostRemoteFilesUploadResponse, + zPostSavedMessagesBody, zPostSavedMessagesQuery, zPostSavedMessagesResponse, zPostTextToAudioBody, @@ -88,16 +95,10 @@ import { * Convert audio to text * * Convert audio file to text using speech-to-text service. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post = oc .route({ - deprecated: true, - description: - 'Convert audio file to text using speech-to-text service.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Convert audio file to text using speech-to-text service.', inputStructure: 'detailed', method: 'POST', operationId: 'postAudioToText', @@ -136,16 +137,10 @@ export const byTaskId = { /** * Create a chat message for conversational applications. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post3 = oc .route({ - deprecated: true, - description: - 'Create a chat message for conversational applications.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Create a chat message for conversational applications.', inputStructure: 'detailed', method: 'POST', operationId: 'postChatMessages', @@ -185,16 +180,10 @@ export const byTaskId2 = { /** * Create a completion message for text generation applications. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post5 = oc .route({ - deprecated: true, - description: - 'Create a completion message for text generation applications.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Create a completion message for text generation applications.', inputStructure: 'detailed', method: 'POST', operationId: 'postCompletionMessages', @@ -211,16 +200,10 @@ export const completionMessages = { /** * Rename a specific conversation with a custom name or auto-generate one. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post6 = oc .route({ - deprecated: true, - description: - 'Rename a specific conversation with a custom name or auto-generate one.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Rename a specific conversation with a custom name or auto-generate one.', inputStructure: 'detailed', method: 'POST', operationId: 'postConversationsByCIdName', @@ -229,6 +212,7 @@ export const post6 = oc }) .input( z.object({ + body: zPostConversationsByCIdNameBody, params: zPostConversationsByCIdNamePath, query: zPostConversationsByCIdNameQuery.optional(), }), @@ -302,16 +286,10 @@ export const byCId = { /** * Retrieve paginated list of conversations for a chat application. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get = oc .route({ - deprecated: true, - description: - 'Retrieve paginated list of conversations for a chat application.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Retrieve paginated list of conversations for a chat application.', inputStructure: 'detailed', method: 'GET', operationId: 'getConversations', @@ -476,16 +454,10 @@ export const forgotPassword = { * Issue an upload token for a human input form * * POST /api/form/human_input//upload-token - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post13 = oc .route({ - deprecated: true, - description: - 'POST /api/form/human_input//upload-token\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'POST /api/form/human_input//upload-token', inputStructure: 'detailed', method: 'POST', operationId: 'postFormHumanInputByFormTokenUploadToken', @@ -504,16 +476,10 @@ export const uploadToken = { * Get human input form definition by token * * GET /api/form/human_input/ - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get2 = oc .route({ - deprecated: true, - description: - 'GET /api/form/human_input/\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'GET /api/form/human_input/', inputStructure: 'detailed', method: 'GET', operationId: 'getFormHumanInputByFormToken', @@ -536,16 +502,11 @@ export const get2 = oc * }, * "action": "Approve" * } - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post14 = oc .route({ - deprecated: true, description: - 'POST /api/form/human_input/\n\nRequest body:\n{\n "inputs": {\n "content": "User input content"\n },\n "action": "Approve"\n}\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + 'POST /api/form/human_input/\n\nRequest body:\n{\n "inputs": {\n "content": "User input content"\n },\n "action": "Approve"\n}', inputStructure: 'detailed', method: 'POST', operationId: 'postFormHumanInputByFormToken', @@ -553,7 +514,12 @@ export const post14 = oc summary: 'Submit human input form by token', tags: ['web'], }) - .input(z.object({ params: zPostFormHumanInputByFormTokenPath })) + .input( + z.object({ + body: zPostFormHumanInputByFormTokenBody, + params: zPostFormHumanInputByFormTokenPath, + }), + ) .output(zPostFormHumanInputByFormTokenResponse) export const byFormToken = { @@ -605,6 +571,7 @@ export const get3 = oc path: '/login/status', tags: ['web'], }) + .input(z.object({ query: zGetLoginStatusQuery.optional() })) .output(zGetLoginStatusResponse) export const status = { @@ -654,16 +621,10 @@ export const logout = { /** * Submit feedback (like/dislike) for a specific message. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post18 = oc .route({ - deprecated: true, - description: - 'Submit feedback (like/dislike) for a specific message.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Submit feedback (like/dislike) for a specific message.', inputStructure: 'detailed', method: 'POST', operationId: 'postMessagesByMessageIdFeedbacks', @@ -672,6 +633,7 @@ export const post18 = oc }) .input( z.object({ + body: zPostMessagesByMessageIdFeedbacksBody, params: zPostMessagesByMessageIdFeedbacksPath, query: zPostMessagesByMessageIdFeedbacksQuery.optional(), }), @@ -684,16 +646,10 @@ export const feedbacks = { /** * Generate a new completion similar to an existing message (completion apps only). - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get4 = oc .route({ - deprecated: true, - description: - 'Generate a new completion similar to an existing message (completion apps only).\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Generate a new completion similar to an existing message (completion apps only).', inputStructure: 'detailed', method: 'GET', operationId: 'getMessagesByMessageIdMoreLikeThis', @@ -739,16 +695,10 @@ export const byMessageId = { /** * Retrieve paginated list of messages from a conversation in a chat application. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get6 = oc .route({ - deprecated: true, - description: - 'Retrieve paginated list of messages from a conversation in a chat application.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Retrieve paginated list of messages from a conversation in a chat application.', inputStructure: 'detailed', method: 'GET', operationId: 'getMessages', @@ -767,16 +717,10 @@ export const messages = { * Get app meta * * Retrieve the metadata for a specific app. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get7 = oc .route({ - deprecated: true, - description: - 'Retrieve the metadata for a specific app.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Retrieve the metadata for a specific app.', inputStructure: 'detailed', method: 'GET', operationId: 'getMeta', @@ -794,16 +738,10 @@ export const meta = { * Retrieve app parameters * * Retrieve the parameters for a specific app. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get8 = oc .route({ - deprecated: true, - description: - 'Retrieve the parameters for a specific app.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Retrieve the parameters for a specific app.', inputStructure: 'detailed', method: 'GET', operationId: 'getParameters', @@ -819,22 +757,17 @@ export const parameters = { /** * Get authentication passport for web application access - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get9 = oc .route({ - deprecated: true, - description: - 'Get authentication passport for web application access\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Get authentication passport for web application access', inputStructure: 'detailed', method: 'GET', operationId: 'getPassport', path: '/passport', tags: ['web'], }) + .input(z.object({ query: zGetPassportQuery.optional() })) .output(zGetPassportResponse) export const passport = { @@ -863,16 +796,11 @@ export const passport = { * RemoteFileUploadError: Failed to fetch file from remote URL * FileTooLargeError: File exceeds size limit * UnsupportedFileTypeError: File type not supported - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post19 = oc .route({ - deprecated: true, description: - 'Upload a file from a remote URL\nDownloads a file from the provided remote URL and uploads it\nto the platform storage for use in web applications.\n\nArgs:\n app_model: The associated application model\n end_user: The end user making the request\n\nJSON Parameters:\n url: The remote URL to download the file from (required)\n\nReturns:\n dict: File information including ID, signed URL, and metadata\n int: HTTP status code 201 for success\n\nRaises:\n RemoteFileUploadError: Failed to fetch file from remote URL\n FileTooLargeError: File exceeds size limit\n UnsupportedFileTypeError: File type not supported\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + 'Upload a file from a remote URL\nDownloads a file from the provided remote URL and uploads it\nto the platform storage for use in web applications.\n\nArgs:\n app_model: The associated application model\n end_user: The end user making the request\n\nJSON Parameters:\n url: The remote URL to download the file from (required)\n\nReturns:\n dict: File information including ID, signed URL, and metadata\n int: HTTP status code 201 for success\n\nRaises:\n RemoteFileUploadError: Failed to fetch file from remote URL\n FileTooLargeError: File exceeds size limit\n UnsupportedFileTypeError: File type not supported', inputStructure: 'detailed', method: 'POST', operationId: 'postRemoteFilesUpload', @@ -881,6 +809,7 @@ export const post19 = oc summary: 'Upload a file from a remote URL', tags: ['web'], }) + .input(z.object({ body: zPostRemoteFilesUploadBody })) .output(zPostRemoteFilesUploadResponse) export const upload2 = { @@ -950,16 +879,10 @@ export const byMessageId2 = { /** * Retrieve paginated list of saved messages for a completion application. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get11 = oc .route({ - deprecated: true, - description: - 'Retrieve paginated list of saved messages for a completion application.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Retrieve paginated list of saved messages for a completion application.', inputStructure: 'detailed', method: 'GET', operationId: 'getSavedMessages', @@ -971,23 +894,17 @@ export const get11 = oc /** * Save a specific message for later reference. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post20 = oc .route({ - deprecated: true, - description: - 'Save a specific message for later reference.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Save a specific message for later reference.', inputStructure: 'detailed', method: 'POST', operationId: 'postSavedMessages', path: '/saved-messages', tags: ['web'], }) - .input(z.object({ query: zPostSavedMessagesQuery })) + .input(z.object({ body: zPostSavedMessagesBody, query: zPostSavedMessagesQuery })) .output(zPostSavedMessagesResponse) export const savedMessages = { @@ -1000,16 +917,10 @@ export const savedMessages = { * Retrieve app site info * * Retrieve app site information and configuration. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get12 = oc .route({ - deprecated: true, - description: - 'Retrieve app site information and configuration.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Retrieve app site information and configuration.', inputStructure: 'detailed', method: 'GET', operationId: 'getSite', @@ -1064,16 +975,10 @@ export const systemFeatures = { * Convert text to audio * * Convert text to audio using text-to-speech service. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post21 = oc .route({ - deprecated: true, - description: - 'Convert text to audio using text-to-speech service.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Convert text to audio using text-to-speech service.', inputStructure: 'detailed', method: 'POST', operationId: 'postTextToAudio', @@ -1137,16 +1042,10 @@ export const webapp = { * GET /api/workflow//events * * Returns Server-Sent Events stream. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const get16 = oc .route({ - deprecated: true, - description: - 'GET /api/workflow//events\n\nReturns Server-Sent Events stream.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'GET /api/workflow//events\n\nReturns Server-Sent Events stream.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkflowByTaskIdEvents', @@ -1173,16 +1072,10 @@ export const workflow = { * Run workflow * * Execute a workflow with provided inputs and files. - * - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated */ export const post22 = oc .route({ - deprecated: true, - description: - 'Execute a workflow with provided inputs and files.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + description: 'Execute a workflow with provided inputs and files.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkflowsRun', diff --git a/packages/contracts/generated/api/web/types.gen.ts b/packages/contracts/generated/api/web/types.gen.ts index d6adf720558..9d16cb99524 100644 --- a/packages/contracts/generated/api/web/types.gen.ts +++ b/packages/contracts/generated/api/web/types.gen.ts @@ -17,11 +17,82 @@ export type AccessTokenResultResponse = { result: string } +export type AgentThought = { + chain_id?: string | null + created_at?: number | null + files: Array + id: string + message_id: string + observation?: string | null + position: number + thought?: string | null + tool?: string | null + tool_input?: string | null + tool_labels: JsonValue +} + export type AppAccessModeQuery = { appCode?: string | null appId?: string | null } +export type AppMetaResponse = { + tool_icons?: { + [key: string]: unknown + } +} + +export type AppPermissionQuery = { + appId: string +} + +export type AppSiteInfoResponse = { + app_id: string + can_replace_logo: boolean + custom_config?: { + [key: string]: unknown + } | null + enable_site: boolean + end_user_id?: string | null + model_config?: AppSiteModelConfigResponse | null + plan?: string | null + site: AppSiteResponse +} + +export type AppSiteModelConfigResponse = { + model: unknown + more_like_this: unknown + opening_statement?: string | null + pre_prompt?: string | null + suggested_questions: unknown + suggested_questions_after_answer: unknown + user_input_form: unknown +} + +export type AppSiteResponse = { + chat_color_theme?: string | null + chat_color_theme_inverted?: boolean | null + copyright?: string | null + custom_disclaimer?: string | null + default_language?: string | null + description?: string | null + icon?: string | null + icon_background?: string | null + icon_type?: string | null + icon_url?: string | null + privacy_policy?: string | null + prompt_public?: boolean | null + show_workflow_steps?: boolean | null + title?: string | null + use_icon_as_answer_icon?: boolean | null +} + +export type AudioBinaryResponse = Blob | File + +export type AudioTranscriptResponse = { + text: string +} + export type BooleanResultResponse = { result: boolean } @@ -34,6 +105,8 @@ export type BrandingModel = { workspace_logo: string } +export type ButtonStyle = 'accent' | 'default' | 'ghost' | 'primary' + export type ChatMessagePayload = { conversation_id?: string | null files?: Array<{ @@ -60,6 +133,12 @@ export type CompletionMessagePayload = { retriever_from?: string } +export type ConversationInfiniteScrollPagination = { + data: Array + has_more: boolean + limit: number +} + export type ConversationListQuery = { last_id?: string | null limit?: number @@ -83,6 +162,27 @@ export type EmailCodeLoginVerifyPayload = { token: string } +export type EventStreamResponse = string + +export type ExecutionContentType = 'human_input' + +export type FileInputConfig = { + allowed_file_extensions?: Array + allowed_file_types?: Array + allowed_file_upload_methods?: Array + output_variable_name: string + type?: 'file' +} + +export type FileListInputConfig = { + allowed_file_extensions?: Array + allowed_file_types?: Array + allowed_file_upload_methods?: Array + number_limits?: number + output_variable_name: string + type?: 'file-list' +} + export type FileResponse = { conversation_id?: string | null created_at?: number | null @@ -101,6 +201,10 @@ export type FileResponse = { user_id?: string | null } +export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url' | 'tool_file' + +export type FileType = 'audio' | 'custom' | 'document' | 'image' | 'video' + export type FileWithSignedUrl = { created_at: number | null created_by: string | null @@ -129,15 +233,108 @@ export type ForgotPasswordSendPayload = { language?: string | null } +export type FormInputConfig + = | ({ + type: 'paragraph' + } & ParagraphInputConfig) + | ({ + type: 'select' + } & SelectInputConfig) + | ({ + type: 'file' + } & FileInputConfig) + | ({ + type: 'file-list' + } & FileListInputConfig) + +export type GeneratedAppResponse = JsonValue + +export type HumanInputContent = { + form_definition?: HumanInputFormDefinition | null + form_submission_data?: HumanInputFormSubmissionData | null + submitted: boolean + type?: ExecutionContentType + workflow_run_id: string +} + export type HumanInputFileUploadFormPayload = { url?: string | null } +export type HumanInputFormDefinition = { + actions?: Array + display_in_ui?: boolean + expiration_time: number + form_content: string + form_id: string + form_token?: string | null + inputs?: Array + node_id: string + node_title: string + resolved_default_values?: { + [key: string]: unknown + } +} + +export type HumanInputFormDefinitionResponse = { + expiration_time: number + form_content: unknown + inputs: unknown + resolved_default_values: { + [key: string]: string + } + site?: { + [key: string]: unknown + } | null + user_actions: unknown +} + +export type HumanInputFormSubmissionData = { + action_id: string + action_text: string + node_id: string + node_title: string + rendered_content: string + submitted_data?: { + [key: string]: JsonValue2 + } | null +} + +export type HumanInputFormSubmitPayload = { + action: string + inputs: { + [key: string]: JsonValue2 + } +} + +export type HumanInputFormSubmitResponse = { + [key: string]: never +} + export type HumanInputUploadTokenResponse = { expires_at: number upload_token: string } +export type JsonObject = { + [key: string]: unknown +} + +export type JsonValue + = | string + | number + | number + | boolean + | { + [key: string]: unknown + } + | Array + | null + +export type JsonValueType = unknown + +export type JsonValue2 = unknown + export type LicenseLimitationModel = { enabled: boolean limit: number @@ -157,6 +354,11 @@ export type LoginPayload = { password: string } +export type LoginStatusQuery = { + app_code?: string | null + user_id?: string | null +} + export type LoginStatusResponse = { app_logged_in: boolean logged_in: boolean @@ -167,6 +369,18 @@ export type MessageFeedbackPayload = { rating?: 'dislike' | 'like' | null } +export type MessageFile = { + belongs_to?: string | null + filename: string + id: string + mime_type?: string | null + size?: number | null + transfer_method: string + type: string + upload_file_id?: string | null + url?: string | null +} + export type MessageListQuery = { conversation_id: string first_id?: string | null @@ -177,6 +391,31 @@ export type MessageMoreLikeThisQuery = { response_mode: 'blocking' | 'streaming' } +export type ParagraphInputConfig = { + default?: StringSource | null + output_variable_name: string + type?: 'paragraph' +} + +export type Parameters = { + annotation_reply: JsonObject + file_upload: JsonObject + more_like_this: JsonObject + opening_statement?: string | null + retriever_resource: JsonObject + sensitive_word_avoidance: JsonObject + speech_to_text: JsonObject + suggested_questions: Array + suggested_questions_after_answer: JsonObject + system_parameters: SystemParameters + text_to_speech: JsonObject + user_input_form: Array +} + +export type PassportQuery = { + user_id?: string | null +} + export type PluginInstallationPermissionModel = { plugin_installation_scope: PluginInstallationScope restrict_to_marketplace_only: boolean @@ -205,15 +444,75 @@ export type ResultResponse = { result: string } +export type RetrieverResource = { + content?: string | null + created_at?: number | null + data_source_type?: string | null + dataset_id?: string | null + dataset_name?: string | null + document_id?: string | null + document_name?: string | null + hit_count?: number | null + id?: string + index_node_hash?: string | null + message_id?: string + position: number + score?: number | null + segment_id?: string | null + segment_position?: number | null + summary?: string | null + word_count?: number | null +} + export type SavedMessageCreatePayload = { message_id: string } +export type SavedMessageInfiniteScrollPagination = { + data: Array + has_more: boolean + limit: number +} + +export type SavedMessageItem = { + answer: string + created_at?: number | null + feedback?: SimpleFeedback | null + id: string + inputs: { + [key: string]: JsonValueType + } + message_files: Array + query: string +} + export type SavedMessageListQuery = { last_id?: string | null limit?: number } +export type SelectInputConfig = { + option_source: StringListSource + output_variable_name: string + type?: 'select' +} + +export type SimpleConversation = { + created_at?: number | null + id: string + inputs: { + [key: string]: JsonValue + } + introduction?: string | null + name: string + status: string + updated_at?: number | null +} + +export type SimpleFeedback = { + rating?: string | null +} + export type SimpleResultDataResponse = { data: string result: string @@ -223,6 +522,18 @@ export type SimpleResultResponse = { result: string } +export type StringListSource = { + selector?: Array + type: ValueSourceType + value?: Array +} + +export type StringSource = { + selector?: Array + type: ValueSourceType + value?: string +} + export type SuggestedQuestionsResponse = { data: Array } @@ -250,6 +561,14 @@ export type SystemFeatureModel = { webapp_auth: WebAppAuthModel } +export type SystemParameters = { + audio_file_size_limit: number + file_size_limit: number + image_file_size_limit: number + video_file_size_limit: number + workflow_file_upload_limit: number +} + export type TextToAudioPayload = { message_id?: string | null streaming?: boolean | null @@ -257,6 +576,14 @@ export type TextToAudioPayload = { voice?: string | null } +export type UserActionConfig = { + button_style?: ButtonStyle + id: string + title: string +} + +export type ValueSourceType = 'constant' | 'variable' + export type VerificationTokenResponse = { email: string is_valid: boolean @@ -275,6 +602,32 @@ export type WebAppAuthSsoModel = { protocol: string } +export type WebMessageInfiniteScrollPagination = { + data: Array + has_more: boolean + limit: number +} + +export type WebMessageListItem = { + agent_thoughts: Array + answer: string + conversation_id: string + created_at?: number | null + error?: string | null + extra_contents: Array + feedback?: SimpleFeedback | null + id: string + inputs: { + [key: string]: JsonValueType + } + message_files: Array + metadata?: JsonValueType | null + parent_message_id?: string | null + query: string + retriever_resources: Array + status: string +} + export type WorkflowRunPayload = { files?: Array<{ [key: string]: unknown @@ -292,32 +645,16 @@ export type PostAudioToTextData = { } export type PostAudioToTextErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } - 413: { - [key: string]: unknown - } - 415: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 403: unknown + 413: unknown + 415: unknown + 500: unknown } -export type PostAudioToTextError = PostAudioToTextErrors[keyof PostAudioToTextErrors] - export type PostAudioToTextResponses = { - 200: { - [key: string]: unknown - } + 200: AudioTranscriptResponse } export type PostAudioToTextResponse = PostAudioToTextResponses[keyof PostAudioToTextResponses] @@ -330,29 +667,15 @@ export type PostChatMessagesData = { } export type PostChatMessagesErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 403: unknown + 404: unknown + 500: unknown } -export type PostChatMessagesError = PostChatMessagesErrors[keyof PostChatMessagesErrors] - export type PostChatMessagesResponses = { - 200: { - [key: string]: unknown - } + 200: GeneratedAppResponse } export type PostChatMessagesResponse = PostChatMessagesResponses[keyof PostChatMessagesResponses] @@ -367,26 +690,13 @@ export type PostChatMessagesByTaskIdStopData = { } export type PostChatMessagesByTaskIdStopErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 403: unknown + 404: unknown + 500: unknown } -export type PostChatMessagesByTaskIdStopError - = PostChatMessagesByTaskIdStopErrors[keyof PostChatMessagesByTaskIdStopErrors] - export type PostChatMessagesByTaskIdStopResponses = { 200: SimpleResultResponse } @@ -402,30 +712,15 @@ export type PostCompletionMessagesData = { } export type PostCompletionMessagesErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 403: unknown + 404: unknown + 500: unknown } -export type PostCompletionMessagesError - = PostCompletionMessagesErrors[keyof PostCompletionMessagesErrors] - export type PostCompletionMessagesResponses = { - 200: { - [key: string]: unknown - } + 200: GeneratedAppResponse } export type PostCompletionMessagesResponse @@ -441,26 +736,13 @@ export type PostCompletionMessagesByTaskIdStopData = { } export type PostCompletionMessagesByTaskIdStopErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 403: unknown + 404: unknown + 500: unknown } -export type PostCompletionMessagesByTaskIdStopError - = PostCompletionMessagesByTaskIdStopErrors[keyof PostCompletionMessagesByTaskIdStopErrors] - export type PostCompletionMessagesByTaskIdStopResponses = { 200: SimpleResultResponse } @@ -474,36 +756,22 @@ export type GetConversationsData = { query?: { last_id?: string limit?: number - pinned?: 'false' | 'true' + pinned?: boolean sort_by?: '-created_at' | '-updated_at' | 'created_at' | 'updated_at' } url: '/conversations' } export type GetConversationsErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 403: unknown + 404: unknown + 500: unknown } -export type GetConversationsError = GetConversationsErrors[keyof GetConversationsErrors] - export type GetConversationsResponses = { - 200: { - [key: string]: unknown - } + 200: ConversationInfiniteScrollPagination } export type GetConversationsResponse = GetConversationsResponses[keyof GetConversationsResponses] @@ -518,37 +786,22 @@ export type DeleteConversationsByCIdData = { } export type DeleteConversationsByCIdErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 403: unknown + 404: unknown + 500: unknown } -export type DeleteConversationsByCIdError - = DeleteConversationsByCIdErrors[keyof DeleteConversationsByCIdErrors] - export type DeleteConversationsByCIdResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteConversationsByCIdResponse = DeleteConversationsByCIdResponses[keyof DeleteConversationsByCIdResponses] export type PostConversationsByCIdNameData = { - body?: never + body: ConversationRenamePayload path: { c_id: string } @@ -560,30 +813,15 @@ export type PostConversationsByCIdNameData = { } export type PostConversationsByCIdNameErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 403: unknown + 404: unknown + 500: unknown } -export type PostConversationsByCIdNameError - = PostConversationsByCIdNameErrors[keyof PostConversationsByCIdNameErrors] - export type PostConversationsByCIdNameResponses = { - 200: { - [key: string]: unknown - } + 200: SimpleConversation } export type PostConversationsByCIdNameResponse @@ -599,26 +837,13 @@ export type PatchConversationsByCIdPinData = { } export type PatchConversationsByCIdPinErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 403: unknown + 404: unknown + 500: unknown } -export type PatchConversationsByCIdPinError - = PatchConversationsByCIdPinErrors[keyof PatchConversationsByCIdPinErrors] - export type PatchConversationsByCIdPinResponses = { 200: ResultResponse } @@ -636,26 +861,13 @@ export type PatchConversationsByCIdUnpinData = { } export type PatchConversationsByCIdUnpinErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 403: unknown + 404: unknown + 500: unknown } -export type PatchConversationsByCIdUnpinError - = PatchConversationsByCIdUnpinErrors[keyof PatchConversationsByCIdUnpinErrors] - export type PatchConversationsByCIdUnpinResponses = { 200: ResultResponse } @@ -671,16 +883,10 @@ export type PostEmailCodeLoginData = { } export type PostEmailCodeLoginErrors = { - 400: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 400: unknown + 404: unknown } -export type PostEmailCodeLoginError = PostEmailCodeLoginErrors[keyof PostEmailCodeLoginErrors] - export type PostEmailCodeLoginResponses = { 200: SimpleResultDataResponse } @@ -696,20 +902,11 @@ export type PostEmailCodeLoginValidityData = { } export type PostEmailCodeLoginValidityErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 404: unknown } -export type PostEmailCodeLoginValidityError - = PostEmailCodeLoginValidityErrors[keyof PostEmailCodeLoginValidityErrors] - export type PostEmailCodeLoginValidityResponses = { 200: AccessTokenResultResponse } @@ -725,19 +922,11 @@ export type PostFilesUploadData = { } export type PostFilesUploadErrors = { - 400: { - [key: string]: unknown - } - 413: { - [key: string]: unknown - } - 415: { - [key: string]: unknown - } + 400: unknown + 413: unknown + 415: unknown } -export type PostFilesUploadError = PostFilesUploadErrors[keyof PostFilesUploadErrors] - export type PostFilesUploadResponses = { 201: FileResponse } @@ -752,19 +941,11 @@ export type PostForgotPasswordData = { } export type PostForgotPasswordErrors = { - 400: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } - 429: { - [key: string]: unknown - } + 400: unknown + 404: unknown + 429: unknown } -export type PostForgotPasswordError = PostForgotPasswordErrors[keyof PostForgotPasswordErrors] - export type PostForgotPasswordResponses = { 200: SimpleResultDataResponse } @@ -780,20 +961,11 @@ export type PostForgotPasswordResetsData = { } export type PostForgotPasswordResetsErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 404: unknown } -export type PostForgotPasswordResetsError - = PostForgotPasswordResetsErrors[keyof PostForgotPasswordResetsErrors] - export type PostForgotPasswordResetsResponses = { 200: SimpleResultResponse } @@ -809,17 +981,10 @@ export type PostForgotPasswordValidityData = { } export type PostForgotPasswordValidityErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } + 400: unknown + 401: unknown } -export type PostForgotPasswordValidityError - = PostForgotPasswordValidityErrors[keyof PostForgotPasswordValidityErrors] - export type PostForgotPasswordValidityResponses = { 200: VerificationTokenResponse } @@ -837,16 +1002,14 @@ export type GetFormHumanInputByFormTokenData = { } export type GetFormHumanInputByFormTokenResponses = { - 200: { - [key: string]: unknown - } + 200: HumanInputFormDefinitionResponse } export type GetFormHumanInputByFormTokenResponse = GetFormHumanInputByFormTokenResponses[keyof GetFormHumanInputByFormTokenResponses] export type PostFormHumanInputByFormTokenData = { - body?: never + body: HumanInputFormSubmitPayload path: { form_token: string } @@ -855,9 +1018,7 @@ export type PostFormHumanInputByFormTokenData = { } export type PostFormHumanInputByFormTokenResponses = { - 200: { - [key: string]: unknown - } + 200: HumanInputFormSubmitResponse } export type PostFormHumanInputByFormTokenResponse @@ -873,9 +1034,7 @@ export type PostFormHumanInputByFormTokenUploadTokenData = { } export type PostFormHumanInputByFormTokenUploadTokenResponses = { - 200: { - [key: string]: unknown - } + 200: HumanInputUploadTokenResponse } export type PostFormHumanInputByFormTokenUploadTokenResponse @@ -903,22 +1062,12 @@ export type PostLoginData = { } export type PostLoginErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 403: unknown + 404: unknown } -export type PostLoginError = PostLoginErrors[keyof PostLoginErrors] - export type PostLoginResponses = { 200: AccessTokenResultResponse } @@ -928,18 +1077,17 @@ export type PostLoginResponse = PostLoginResponses[keyof PostLoginResponses] export type GetLoginStatusData = { body?: never path?: never - query?: never + query?: { + app_code?: string + user_id?: string + } url: '/login/status' } export type GetLoginStatusErrors = { - 401: { - [key: string]: unknown - } + 401: unknown } -export type GetLoginStatusError = GetLoginStatusErrors[keyof GetLoginStatusErrors] - export type GetLoginStatusResponses = { 200: LoginStatusResponse } @@ -971,35 +1119,21 @@ export type GetMessagesData = { } export type GetMessagesErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 403: unknown + 404: unknown + 500: unknown } -export type GetMessagesError = GetMessagesErrors[keyof GetMessagesErrors] - export type GetMessagesResponses = { - 200: { - [key: string]: unknown - } + 200: WebMessageInfiniteScrollPagination } export type GetMessagesResponse = GetMessagesResponses[keyof GetMessagesResponses] export type PostMessagesByMessageIdFeedbacksData = { - body?: never + body: MessageFeedbackPayload path: { message_id: string } @@ -1011,26 +1145,13 @@ export type PostMessagesByMessageIdFeedbacksData = { } export type PostMessagesByMessageIdFeedbacksErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 403: unknown + 404: unknown + 500: unknown } -export type PostMessagesByMessageIdFeedbacksError - = PostMessagesByMessageIdFeedbacksErrors[keyof PostMessagesByMessageIdFeedbacksErrors] - export type PostMessagesByMessageIdFeedbacksResponses = { 200: ResultResponse } @@ -1050,30 +1171,15 @@ export type GetMessagesByMessageIdMoreLikeThisData = { } export type GetMessagesByMessageIdMoreLikeThisErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 403: unknown + 404: unknown + 500: unknown } -export type GetMessagesByMessageIdMoreLikeThisError - = GetMessagesByMessageIdMoreLikeThisErrors[keyof GetMessagesByMessageIdMoreLikeThisErrors] - export type GetMessagesByMessageIdMoreLikeThisResponses = { - 200: { - [key: string]: unknown - } + 200: GeneratedAppResponse } export type GetMessagesByMessageIdMoreLikeThisResponse @@ -1089,26 +1195,13 @@ export type GetMessagesByMessageIdSuggestedQuestionsData = { } export type GetMessagesByMessageIdSuggestedQuestionsErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 403: unknown + 404: unknown + 500: unknown } -export type GetMessagesByMessageIdSuggestedQuestionsError - = GetMessagesByMessageIdSuggestedQuestionsErrors[keyof GetMessagesByMessageIdSuggestedQuestionsErrors] - export type GetMessagesByMessageIdSuggestedQuestionsResponses = { 200: SuggestedQuestionsResponse } @@ -1124,29 +1217,15 @@ export type GetMetaData = { } export type GetMetaErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 403: unknown + 404: unknown + 500: unknown } -export type GetMetaError = GetMetaErrors[keyof GetMetaErrors] - export type GetMetaResponses = { - 200: { - [key: string]: unknown - } + 200: AppMetaResponse } export type GetMetaResponse = GetMetaResponses[keyof GetMetaResponses] @@ -1159,29 +1238,15 @@ export type GetParametersData = { } export type GetParametersErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 403: unknown + 404: unknown + 500: unknown } -export type GetParametersError = GetParametersErrors[keyof GetParametersErrors] - export type GetParametersResponses = { - 200: { - [key: string]: unknown - } + 200: Parameters } export type GetParametersResponse = GetParametersResponses[keyof GetParametersResponses] @@ -1189,54 +1254,37 @@ export type GetParametersResponse = GetParametersResponses[keyof GetParametersRe export type GetPassportData = { body?: never path?: never - query?: never + query?: { + user_id?: string + } url: '/passport' } export type GetPassportErrors = { - 401: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } + 401: unknown + 404: unknown } -export type GetPassportError = GetPassportErrors[keyof GetPassportErrors] - export type GetPassportResponses = { - 200: { - [key: string]: unknown - } + 200: AccessTokenData } export type GetPassportResponse = GetPassportResponses[keyof GetPassportResponses] export type PostRemoteFilesUploadData = { - body?: never + body: RemoteFileUploadPayload path?: never query?: never url: '/remote-files/upload' } export type PostRemoteFilesUploadErrors = { - 400: { - [key: string]: unknown - } - 413: { - [key: string]: unknown - } - 415: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 400: unknown + 413: unknown + 415: unknown + 500: unknown } -export type PostRemoteFilesUploadError - = PostRemoteFilesUploadErrors[keyof PostRemoteFilesUploadErrors] - export type PostRemoteFilesUploadResponses = { 201: FileWithSignedUrl } @@ -1254,19 +1302,11 @@ export type GetRemoteFilesByUrlData = { } export type GetRemoteFilesByUrlErrors = { - 400: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 400: unknown + 404: unknown + 500: unknown } -export type GetRemoteFilesByUrlError = GetRemoteFilesByUrlErrors[keyof GetRemoteFilesByUrlErrors] - export type GetRemoteFilesByUrlResponses = { 200: RemoteFileInfo } @@ -1285,35 +1325,21 @@ export type GetSavedMessagesData = { } export type GetSavedMessagesErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 403: unknown + 404: unknown + 500: unknown } -export type GetSavedMessagesError = GetSavedMessagesErrors[keyof GetSavedMessagesErrors] - export type GetSavedMessagesResponses = { - 200: { - [key: string]: unknown - } + 200: SavedMessageInfiniteScrollPagination } export type GetSavedMessagesResponse = GetSavedMessagesResponses[keyof GetSavedMessagesResponses] export type PostSavedMessagesData = { - body?: never + body: SavedMessageCreatePayload path?: never query: { message_id: string @@ -1322,25 +1348,13 @@ export type PostSavedMessagesData = { } export type PostSavedMessagesErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 403: unknown + 404: unknown + 500: unknown } -export type PostSavedMessagesError = PostSavedMessagesErrors[keyof PostSavedMessagesErrors] - export type PostSavedMessagesResponses = { 200: ResultResponse } @@ -1357,30 +1371,15 @@ export type DeleteSavedMessagesByMessageIdData = { } export type DeleteSavedMessagesByMessageIdErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 403: unknown + 404: unknown + 500: unknown } -export type DeleteSavedMessagesByMessageIdError - = DeleteSavedMessagesByMessageIdErrors[keyof DeleteSavedMessagesByMessageIdErrors] - export type DeleteSavedMessagesByMessageIdResponses = { - 204: { - [key: string]: never - } + 204: void } export type DeleteSavedMessagesByMessageIdResponse @@ -1394,29 +1393,15 @@ export type GetSiteData = { } export type GetSiteErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 403: unknown + 404: unknown + 500: unknown } -export type GetSiteError = GetSiteErrors[keyof GetSiteErrors] - export type GetSiteResponses = { - 200: { - [key: string]: unknown - } + 200: AppSiteInfoResponse } export type GetSiteResponse = GetSiteResponses[keyof GetSiteResponses] @@ -1429,13 +1414,9 @@ export type GetSystemFeaturesData = { } export type GetSystemFeaturesErrors = { - 500: { - [key: string]: unknown - } + 500: unknown } -export type GetSystemFeaturesError = GetSystemFeaturesErrors[keyof GetSystemFeaturesErrors] - export type GetSystemFeaturesResponses = { 200: SystemFeatureModel } @@ -1450,26 +1431,14 @@ export type PostTextToAudioData = { } export type PostTextToAudioErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 403: unknown + 500: unknown } -export type PostTextToAudioError = PostTextToAudioErrors[keyof PostTextToAudioErrors] - export type PostTextToAudioResponses = { - 200: { - [key: string]: unknown - } + 200: AudioBinaryResponse } export type PostTextToAudioResponse = PostTextToAudioResponses[keyof PostTextToAudioResponses] @@ -1485,16 +1454,10 @@ export type GetWebappAccessModeData = { } export type GetWebappAccessModeErrors = { - 400: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 400: unknown + 500: unknown } -export type GetWebappAccessModeError = GetWebappAccessModeErrors[keyof GetWebappAccessModeErrors] - export type GetWebappAccessModeResponses = { 200: AccessModeResponse } @@ -1512,19 +1475,11 @@ export type GetWebappPermissionData = { } export type GetWebappPermissionErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 500: unknown } -export type GetWebappPermissionError = GetWebappPermissionErrors[keyof GetWebappPermissionErrors] - export type GetWebappPermissionResponses = { 200: BooleanResultResponse } @@ -1542,9 +1497,7 @@ export type GetWorkflowByTaskIdEventsData = { } export type GetWorkflowByTaskIdEventsResponses = { - 200: { - [key: string]: unknown - } + 200: EventStreamResponse } export type GetWorkflowByTaskIdEventsResponse @@ -1558,29 +1511,15 @@ export type PostWorkflowsRunData = { } export type PostWorkflowsRunErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 403: unknown + 404: unknown + 500: unknown } -export type PostWorkflowsRunError = PostWorkflowsRunErrors[keyof PostWorkflowsRunErrors] - export type PostWorkflowsRunResponses = { - 200: { - [key: string]: unknown - } + 200: GeneratedAppResponse } export type PostWorkflowsRunResponse = PostWorkflowsRunResponses[keyof PostWorkflowsRunResponses] @@ -1595,26 +1534,13 @@ export type PostWorkflowsTasksByTaskIdStopData = { } export type PostWorkflowsTasksByTaskIdStopErrors = { - 400: { - [key: string]: unknown - } - 401: { - [key: string]: unknown - } - 403: { - [key: string]: unknown - } - 404: { - [key: string]: unknown - } - 500: { - [key: string]: unknown - } + 400: unknown + 401: unknown + 403: unknown + 404: unknown + 500: unknown } -export type PostWorkflowsTasksByTaskIdStopError - = PostWorkflowsTasksByTaskIdStopErrors[keyof PostWorkflowsTasksByTaskIdStopErrors] - export type PostWorkflowsTasksByTaskIdStopResponses = { 200: SimpleResultResponse } diff --git a/packages/contracts/generated/api/web/zod.gen.ts b/packages/contracts/generated/api/web/zod.gen.ts index baf367eb7ae..cb731344ab6 100644 --- a/packages/contracts/generated/api/web/zod.gen.ts +++ b/packages/contracts/generated/api/web/zod.gen.ts @@ -32,6 +32,80 @@ export const zAppAccessModeQuery = z.object({ appId: z.string().nullish(), }) +/** + * AppMetaResponse + */ +export const zAppMetaResponse = z.object({ + tool_icons: z.record(z.string(), z.unknown()).optional(), +}) + +/** + * AppPermissionQuery + */ +export const zAppPermissionQuery = z.object({ + appId: z.string(), +}) + +/** + * AppSiteModelConfigResponse + */ +export const zAppSiteModelConfigResponse = z.object({ + model: z.unknown(), + more_like_this: z.unknown(), + opening_statement: z.string().nullish(), + pre_prompt: z.string().nullish(), + suggested_questions: z.unknown(), + suggested_questions_after_answer: z.unknown(), + user_input_form: z.unknown(), +}) + +/** + * AppSiteResponse + */ +export const zAppSiteResponse = z.object({ + chat_color_theme: z.string().nullish(), + chat_color_theme_inverted: z.boolean().nullish(), + copyright: z.string().nullish(), + custom_disclaimer: z.string().nullish(), + default_language: z.string().nullish(), + description: z.string().nullish(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.string().nullish(), + icon_url: z.string().nullish(), + privacy_policy: z.string().nullish(), + prompt_public: z.boolean().nullish(), + show_workflow_steps: z.boolean().nullish(), + title: z.string().nullish(), + use_icon_as_answer_icon: z.boolean().nullish(), +}) + +/** + * AppSiteInfoResponse + */ +export const zAppSiteInfoResponse = z.object({ + app_id: z.string(), + can_replace_logo: z.boolean(), + custom_config: z.record(z.string(), z.unknown()).nullish(), + enable_site: z.boolean(), + end_user_id: z.string().nullish(), + model_config: zAppSiteModelConfigResponse.nullish(), + plan: z.string().nullish(), + site: zAppSiteResponse, +}) + +/** + * AudioBinaryResponse + */ +export const zAudioBinaryResponse = z.custom() + +/** + * AudioTranscriptResponse + */ +export const zAudioTranscriptResponse = z.object({ + text: z.string(), +}) + /** * BooleanResultResponse */ @@ -50,6 +124,13 @@ export const zBrandingModel = z.object({ workspace_logo: z.string().default(''), }) +/** + * ButtonStyle + * + * Button styles for user actions. + */ +export const zButtonStyle = z.enum(['accent', 'default', 'ghost', 'primary']) + /** * ChatMessagePayload */ @@ -112,6 +193,16 @@ export const zEmailCodeLoginVerifyPayload = z.object({ token: z.string().min(1), }) +/** + * EventStreamResponse + */ +export const zEventStreamResponse = z.string() + +/** + * ExecutionContentType + */ +export const zExecutionContentType = z.enum(['human_input']) + /** * FileResponse */ @@ -133,6 +224,44 @@ export const zFileResponse = z.object({ user_id: z.string().nullish(), }) +/** + * FileTransferMethod + */ +export const zFileTransferMethod = z.enum([ + 'datasource_file', + 'local_file', + 'remote_url', + 'tool_file', +]) + +/** + * FileType + */ +export const zFileType = z.enum(['audio', 'custom', 'document', 'image', 'video']) + +/** + * FileInputConfig + */ +export const zFileInputConfig = z.object({ + allowed_file_extensions: z.array(z.string()).optional(), + allowed_file_types: z.array(zFileType).optional(), + allowed_file_upload_methods: z.array(zFileTransferMethod).optional(), + output_variable_name: z.string(), + type: z.literal('file').optional().default('file'), +}) + +/** + * FileListInputConfig + */ +export const zFileListInputConfig = z.object({ + allowed_file_extensions: z.array(z.string()).optional(), + allowed_file_types: z.array(zFileType).optional(), + allowed_file_upload_methods: z.array(zFileTransferMethod).optional(), + number_limits: z.int().gte(0).optional().default(0), + output_variable_name: z.string(), + type: z.literal('file-list').optional().default('file-list'), +}) + /** * FileWithSignedUrl */ @@ -182,6 +311,23 @@ export const zHumanInputFileUploadFormPayload = z.object({ url: z.url().min(1).max(2083).nullish(), }) +/** + * HumanInputFormDefinitionResponse + */ +export const zHumanInputFormDefinitionResponse = z.object({ + expiration_time: z.int(), + form_content: z.unknown(), + inputs: z.unknown(), + resolved_default_values: z.record(z.string(), z.string()), + site: z.record(z.string(), z.unknown()).nullish(), + user_actions: z.unknown(), +}) + +/** + * HumanInputFormSubmitResponse + */ +export const zHumanInputFormSubmitResponse = z.record(z.string(), z.never()) + /** * HumanInputUploadTokenResponse */ @@ -190,6 +336,65 @@ export const zHumanInputUploadTokenResponse = z.object({ upload_token: z.string(), }) +export const zJsonObject = z.record(z.string(), z.unknown()) + +export const zJsonValue = z + .union([ + z.string(), + z.int(), + z.number(), + z.boolean(), + z.record(z.string(), z.unknown()), + z.array(z.unknown()), + ]) + .nullable() + +/** + * AgentThought + */ +export const zAgentThought = z.object({ + chain_id: z.string().nullish(), + created_at: z.int().nullish(), + files: z.array(z.string()), + id: z.string(), + message_id: z.string(), + observation: z.string().nullish(), + position: z.int(), + thought: z.string().nullish(), + tool: z.string().nullish(), + tool_input: z.string().nullish(), + tool_labels: zJsonValue, +}) + +/** + * GeneratedAppResponse + */ +export const zGeneratedAppResponse = zJsonValue + +export const zJsonValueType = z.unknown() + +export const zJsonValue2 = z.unknown() + +/** + * HumanInputFormSubmissionData + */ +export const zHumanInputFormSubmissionData = z.object({ + action_id: z.string(), + action_text: z.string(), + node_id: z.string(), + node_title: z.string(), + rendered_content: z.string(), + submitted_data: z.record(z.string(), zJsonValue2).nullish(), +}) + +/** + * HumanInputFormSubmitPayload + */ +export const zHumanInputFormSubmitPayload = z.object({ + action: z.string(), + inputs: z.record(z.string(), zJsonValue2), +}) + /** * LicenseLimitationModel * @@ -213,8 +418,12 @@ export const zLicenseStatus = z.enum(['active', 'expired', 'expiring', 'inactive */ export const zLicenseModel = z.object({ expired_at: z.string().default(''), - status: zLicenseStatus, - workspaces: zLicenseLimitationModel, + status: zLicenseStatus.default('none'), + workspaces: zLicenseLimitationModel.default({ + enabled: false, + limit: 0, + size: 0, + }), }) /** @@ -225,6 +434,14 @@ export const zLoginPayload = z.object({ password: z.string(), }) +/** + * LoginStatusQuery + */ +export const zLoginStatusQuery = z.object({ + app_code: z.string().nullish(), + user_id: z.string().nullish(), +}) + /** * LoginStatusResponse */ @@ -241,6 +458,21 @@ export const zMessageFeedbackPayload = z.object({ rating: z.enum(['dislike', 'like']).nullish(), }) +/** + * MessageFile + */ +export const zMessageFile = z.object({ + belongs_to: z.string().nullish(), + filename: z.string(), + id: z.string(), + mime_type: z.string().nullish(), + size: z.int().nullish(), + transfer_method: z.string(), + type: z.string(), + upload_file_id: z.string().nullish(), + url: z.string().nullish(), +}) + /** * MessageListQuery */ @@ -257,6 +489,13 @@ export const zMessageMoreLikeThisQuery = z.object({ response_mode: z.enum(['blocking', 'streaming']), }) +/** + * PassportQuery + */ +export const zPassportQuery = z.object({ + user_id: z.string().nullish(), +}) + /** * PluginInstallationScope */ @@ -271,7 +510,7 @@ export const zPluginInstallationScope = z.enum([ * PluginInstallationPermissionModel */ export const zPluginInstallationPermissionModel = z.object({ - plugin_installation_scope: zPluginInstallationScope, + plugin_installation_scope: zPluginInstallationScope.default('all'), restrict_to_marketplace_only: z.boolean().default(false), }) @@ -304,6 +543,29 @@ export const zResultResponse = z.object({ result: z.string(), }) +/** + * RetrieverResource + */ +export const zRetrieverResource = z.object({ + content: z.string().nullish(), + created_at: z.int().nullish(), + data_source_type: z.string().nullish(), + dataset_id: z.string().nullish(), + dataset_name: z.string().nullish(), + document_id: z.string().nullish(), + document_name: z.string().nullish(), + hit_count: z.int().nullish(), + id: z.string().optional(), + index_node_hash: z.string().nullish(), + message_id: z.string().optional(), + position: z.int(), + score: z.number().nullish(), + segment_id: z.string().nullish(), + segment_position: z.int().nullish(), + summary: z.string().nullish(), + word_count: z.int().nullish(), +}) + /** * SavedMessageCreatePayload */ @@ -319,6 +581,57 @@ export const zSavedMessageListQuery = z.object({ limit: z.int().gte(1).lte(100).optional().default(20), }) +/** + * SimpleConversation + */ +export const zSimpleConversation = z.object({ + created_at: z.int().nullish(), + id: z.string(), + inputs: z.record(z.string(), zJsonValue), + introduction: z.string().nullish(), + name: z.string(), + status: z.string(), + updated_at: z.int().nullish(), +}) + +/** + * ConversationInfiniteScrollPagination + */ +export const zConversationInfiniteScrollPagination = z.object({ + data: z.array(zSimpleConversation), + has_more: z.boolean(), + limit: z.int(), +}) + +/** + * SimpleFeedback + */ +export const zSimpleFeedback = z.object({ + rating: z.string().nullish(), +}) + +/** + * SavedMessageItem + */ +export const zSavedMessageItem = z.object({ + answer: z.string(), + created_at: z.int().nullish(), + feedback: zSimpleFeedback.nullish(), + id: z.string(), + inputs: z.record(z.string(), zJsonValueType), + message_files: z.array(zMessageFile), + query: z.string(), +}) + +/** + * SavedMessageInfiniteScrollPagination + */ +export const zSavedMessageInfiniteScrollPagination = z.object({ + data: z.array(zSavedMessageItem), + has_more: z.boolean(), + limit: z.int(), +}) + /** * SimpleResultDataResponse */ @@ -341,6 +654,35 @@ export const zSuggestedQuestionsResponse = z.object({ data: z.array(z.string()), }) +/** + * SystemParameters + */ +export const zSystemParameters = z.object({ + audio_file_size_limit: z.int(), + file_size_limit: z.int(), + image_file_size_limit: z.int(), + video_file_size_limit: z.int(), + workflow_file_upload_limit: z.int(), +}) + +/** + * Parameters + */ +export const zParameters = z.object({ + annotation_reply: zJsonObject, + file_upload: zJsonObject, + more_like_this: zJsonObject, + opening_statement: z.string().nullish(), + retriever_resource: zJsonObject, + sensitive_word_avoidance: zJsonObject, + speech_to_text: zJsonObject, + suggested_questions: z.array(z.string()), + suggested_questions_after_answer: zJsonObject, + system_parameters: zSystemParameters, + text_to_speech: zJsonObject, + user_input_form: z.array(zJsonObject), +}) + /** * TextToAudioPayload */ @@ -351,6 +693,99 @@ export const zTextToAudioPayload = z.object({ voice: z.string().nullish(), }) +/** + * UserActionConfig + * + * User action configuration. + */ +export const zUserActionConfig = z.object({ + button_style: zButtonStyle.optional().default('default'), + id: z.string().max(20), + title: z.string().max(100), +}) + +/** + * ValueSourceType + * + * ValueSourceType records whether the value comes from a static setting + * in form definiton, or a variable while the workflow is running. + */ +export const zValueSourceType = z.enum(['constant', 'variable']) + +/** + * StringListSource + */ +export const zStringListSource = z.object({ + selector: z.array(z.string()).optional(), + type: zValueSourceType, + value: z.array(z.string()).optional(), +}) + +/** + * SelectInputConfig + */ +export const zSelectInputConfig = z.object({ + option_source: zStringListSource, + output_variable_name: z.string(), + type: z.literal('select').optional().default('select'), +}) + +/** + * StringSource + * + * Default configuration for form inputs. + */ +export const zStringSource = z.object({ + selector: z.array(z.string()).optional(), + type: zValueSourceType, + value: z.string().optional().default(''), +}) + +/** + * ParagraphInputConfig + * + * Form input definition. + */ +export const zParagraphInputConfig = z.object({ + default: zStringSource.nullish(), + output_variable_name: z.string(), + type: z.literal('paragraph').optional().default('paragraph'), +}) + +export const zFormInputConfig = z.discriminatedUnion('type', [ + zParagraphInputConfig.extend({ type: z.literal('paragraph') }), + zSelectInputConfig.extend({ type: z.literal('select') }), + zFileInputConfig.extend({ type: z.literal('file') }), + zFileListInputConfig.extend({ type: z.literal('file-list') }), +]) + +/** + * HumanInputFormDefinition + */ +export const zHumanInputFormDefinition = z.object({ + actions: z.array(zUserActionConfig).optional(), + display_in_ui: z.boolean().optional().default(false), + expiration_time: z.int(), + form_content: z.string(), + form_id: z.string(), + form_token: z.string().nullish(), + inputs: z.array(zFormInputConfig).optional(), + node_id: z.string(), + node_title: z.string(), + resolved_default_values: z.record(z.string(), z.unknown()).optional(), +}) + +/** + * HumanInputContent + */ +export const zHumanInputContent = z.object({ + form_definition: zHumanInputFormDefinition.nullish(), + form_submission_data: zHumanInputFormSubmissionData.nullish(), + submitted: z.boolean(), + type: zExecutionContentType.optional().default('human_input'), + workflow_run_id: z.string(), +}) + /** * VerificationTokenResponse */ @@ -375,14 +810,20 @@ export const zWebAppAuthModel = z.object({ allow_email_password_login: z.boolean().default(false), allow_sso: z.boolean().default(false), enabled: z.boolean().default(false), - sso_config: zWebAppAuthSsoModel, + sso_config: zWebAppAuthSsoModel.default({ protocol: '' }), }) /** * SystemFeatureModel */ export const zSystemFeatureModel = z.object({ - branding: zBrandingModel, + branding: zBrandingModel.default({ + application_title: '', + enabled: false, + favicon: '', + login_page_logo: '', + workspace_logo: '', + }), enable_change_email: z.boolean().default(true), enable_collaboration_mode: z.boolean().default(true), enable_creators_platform: z.boolean().default(false), @@ -395,13 +836,60 @@ export const zSystemFeatureModel = z.object({ is_allow_create_workspace: z.boolean().default(false), is_allow_register: z.boolean().default(false), is_email_setup: z.boolean().default(false), - license: zLicenseModel, + license: zLicenseModel.default({ + expired_at: '', + status: 'none', + workspaces: { + enabled: false, + limit: 0, + size: 0, + }, + }), max_plugin_package_size: z.int().default(15728640), - plugin_installation_permission: zPluginInstallationPermissionModel, - plugin_manager: zPluginManagerModel, + plugin_installation_permission: zPluginInstallationPermissionModel.default({ + plugin_installation_scope: 'all', + restrict_to_marketplace_only: false, + }), + plugin_manager: zPluginManagerModel.default({ enabled: false }), sso_enforced_for_signin: z.boolean().default(false), sso_enforced_for_signin_protocol: z.string().default(''), - webapp_auth: zWebAppAuthModel, + webapp_auth: zWebAppAuthModel.default({ + allow_email_code_login: false, + allow_email_password_login: false, + allow_sso: false, + enabled: false, + sso_config: { protocol: '' }, + }), +}) + +/** + * WebMessageListItem + */ +export const zWebMessageListItem = z.object({ + agent_thoughts: z.array(zAgentThought), + answer: z.string(), + conversation_id: z.string(), + created_at: z.int().nullish(), + error: z.string().nullish(), + extra_contents: z.array(zHumanInputContent), + feedback: zSimpleFeedback.nullish(), + id: z.string(), + inputs: z.record(z.string(), zJsonValueType), + message_files: z.array(zMessageFile), + metadata: zJsonValueType.nullish(), + parent_message_id: z.string().nullish(), + query: z.string(), + retriever_resources: z.array(zRetrieverResource), + status: z.string(), +}) + +/** + * WebMessageInfiniteScrollPagination + */ +export const zWebMessageInfiniteScrollPagination = z.object({ + data: z.array(zWebMessageListItem), + has_more: z.boolean(), + limit: z.int(), }) /** @@ -415,14 +903,14 @@ export const zWorkflowRunPayload = z.object({ /** * Success */ -export const zPostAudioToTextResponse = z.record(z.string(), z.unknown()) +export const zPostAudioToTextResponse = zAudioTranscriptResponse export const zPostChatMessagesBody = zChatMessagePayload /** * Success */ -export const zPostChatMessagesResponse = z.record(z.string(), z.unknown()) +export const zPostChatMessagesResponse = zGeneratedAppResponse export const zPostChatMessagesByTaskIdStopPath = z.object({ task_id: z.string(), @@ -438,7 +926,7 @@ export const zPostCompletionMessagesBody = zCompletionMessagePayload /** * Success */ -export const zPostCompletionMessagesResponse = z.record(z.string(), z.unknown()) +export const zPostCompletionMessagesResponse = zGeneratedAppResponse export const zPostCompletionMessagesByTaskIdStopPath = z.object({ task_id: z.string(), @@ -451,8 +939,8 @@ export const zPostCompletionMessagesByTaskIdStopResponse = zSimpleResultResponse export const zGetConversationsQuery = z.object({ last_id: z.string().optional(), - limit: z.int().optional().default(20), - pinned: z.enum(['false', 'true']).optional(), + limit: z.int().gte(1).lte(100).optional().default(20), + pinned: z.boolean().optional(), sort_by: z .enum(['-created_at', '-updated_at', 'created_at', 'updated_at']) .optional() @@ -462,7 +950,7 @@ export const zGetConversationsQuery = z.object({ /** * Success */ -export const zGetConversationsResponse = z.record(z.string(), z.unknown()) +export const zGetConversationsResponse = zConversationInfiniteScrollPagination export const zDeleteConversationsByCIdPath = z.object({ c_id: z.string(), @@ -471,7 +959,9 @@ export const zDeleteConversationsByCIdPath = z.object({ /** * Conversation deleted successfully */ -export const zDeleteConversationsByCIdResponse = z.record(z.string(), z.never()) +export const zDeleteConversationsByCIdResponse = z.void() + +export const zPostConversationsByCIdNameBody = zConversationRenamePayload export const zPostConversationsByCIdNamePath = z.object({ c_id: z.string(), @@ -485,7 +975,7 @@ export const zPostConversationsByCIdNameQuery = z.object({ /** * Conversation renamed successfully */ -export const zPostConversationsByCIdNameResponse = z.record(z.string(), z.unknown()) +export const zPostConversationsByCIdNameResponse = zSimpleConversation export const zPatchConversationsByCIdPinPath = z.object({ c_id: z.string(), @@ -552,7 +1042,9 @@ export const zGetFormHumanInputByFormTokenPath = z.object({ /** * Success */ -export const zGetFormHumanInputByFormTokenResponse = z.record(z.string(), z.unknown()) +export const zGetFormHumanInputByFormTokenResponse = zHumanInputFormDefinitionResponse + +export const zPostFormHumanInputByFormTokenBody = zHumanInputFormSubmitPayload export const zPostFormHumanInputByFormTokenPath = z.object({ form_token: z.string(), @@ -561,7 +1053,7 @@ export const zPostFormHumanInputByFormTokenPath = z.object({ /** * Success */ -export const zPostFormHumanInputByFormTokenResponse = z.record(z.string(), z.unknown()) +export const zPostFormHumanInputByFormTokenResponse = zHumanInputFormSubmitResponse export const zPostFormHumanInputByFormTokenUploadTokenPath = z.object({ form_token: z.string(), @@ -570,7 +1062,7 @@ export const zPostFormHumanInputByFormTokenUploadTokenPath = z.object({ /** * Success */ -export const zPostFormHumanInputByFormTokenUploadTokenResponse = z.record(z.string(), z.unknown()) +export const zPostFormHumanInputByFormTokenUploadTokenResponse = zHumanInputUploadTokenResponse /** * File uploaded successfully @@ -584,6 +1076,11 @@ export const zPostLoginBody = zLoginPayload */ export const zPostLoginResponse = zAccessTokenResultResponse +export const zGetLoginStatusQuery = z.object({ + app_code: z.string().optional(), + user_id: z.string().optional(), +}) + /** * Login status */ @@ -597,13 +1094,15 @@ export const zPostLogoutResponse = zSimpleResultResponse export const zGetMessagesQuery = z.object({ conversation_id: z.string(), first_id: z.string().optional(), - limit: z.int().optional().default(20), + limit: z.int().gte(1).lte(100).optional().default(20), }) /** * Success */ -export const zGetMessagesResponse = z.record(z.string(), z.unknown()) +export const zGetMessagesResponse = zWebMessageInfiniteScrollPagination + +export const zPostMessagesByMessageIdFeedbacksBody = zMessageFeedbackPayload export const zPostMessagesByMessageIdFeedbacksPath = z.object({ message_id: z.string(), @@ -630,7 +1129,7 @@ export const zGetMessagesByMessageIdMoreLikeThisQuery = z.object({ /** * Success */ -export const zGetMessagesByMessageIdMoreLikeThisResponse = z.record(z.string(), z.unknown()) +export const zGetMessagesByMessageIdMoreLikeThisResponse = zGeneratedAppResponse export const zGetMessagesByMessageIdSuggestedQuestionsPath = z.object({ message_id: z.string(), @@ -644,17 +1143,23 @@ export const zGetMessagesByMessageIdSuggestedQuestionsResponse = zSuggestedQuest /** * Success */ -export const zGetMetaResponse = z.record(z.string(), z.unknown()) +export const zGetMetaResponse = zAppMetaResponse /** * Success */ -export const zGetParametersResponse = z.record(z.string(), z.unknown()) +export const zGetParametersResponse = zParameters + +export const zGetPassportQuery = z.object({ + user_id: z.string().optional(), +}) /** * Passport retrieved successfully */ -export const zGetPassportResponse = z.record(z.string(), z.unknown()) +export const zGetPassportResponse = zAccessTokenData + +export const zPostRemoteFilesUploadBody = zRemoteFileUploadPayload /** * Remote file uploaded successfully @@ -672,13 +1177,15 @@ export const zGetRemoteFilesByUrlResponse = zRemoteFileInfo export const zGetSavedMessagesQuery = z.object({ last_id: z.string().optional(), - limit: z.int().optional().default(20), + limit: z.int().gte(1).lte(100).optional().default(20), }) /** * Success */ -export const zGetSavedMessagesResponse = z.record(z.string(), z.unknown()) +export const zGetSavedMessagesResponse = zSavedMessageInfiniteScrollPagination + +export const zPostSavedMessagesBody = zSavedMessageCreatePayload export const zPostSavedMessagesQuery = z.object({ message_id: z.string(), @@ -696,12 +1203,12 @@ export const zDeleteSavedMessagesByMessageIdPath = z.object({ /** * Message removed successfully */ -export const zDeleteSavedMessagesByMessageIdResponse = z.record(z.string(), z.never()) +export const zDeleteSavedMessagesByMessageIdResponse = z.void() /** * Success */ -export const zGetSiteResponse = z.record(z.string(), z.unknown()) +export const zGetSiteResponse = zAppSiteInfoResponse /** * System features retrieved successfully @@ -713,7 +1220,7 @@ export const zPostTextToAudioBody = zTextToAudioPayload /** * Success */ -export const zPostTextToAudioResponse = z.record(z.string(), z.unknown()) +export const zPostTextToAudioResponse = zAudioBinaryResponse export const zGetWebappAccessModeQuery = z.object({ appCode: z.string().optional(), @@ -739,16 +1246,16 @@ export const zGetWorkflowByTaskIdEventsPath = z.object({ }) /** - * Success + * SSE event stream */ -export const zGetWorkflowByTaskIdEventsResponse = z.record(z.string(), z.unknown()) +export const zGetWorkflowByTaskIdEventsResponse = zEventStreamResponse export const zPostWorkflowsRunBody = zWorkflowRunPayload /** * Success */ -export const zPostWorkflowsRunResponse = z.record(z.string(), z.unknown()) +export const zPostWorkflowsRunResponse = zGeneratedAppResponse export const zPostWorkflowsTasksByTaskIdStopPath = z.object({ task_id: z.string(), diff --git a/packages/contracts/non-json-openapi-responses.md b/packages/contracts/non-json-openapi-responses.md new file mode 100644 index 00000000000..9499ae8cdfc --- /dev/null +++ b/packages/contracts/non-json-openapi-responses.md @@ -0,0 +1,98 @@ +# Non-JSON OpenAPI Responses + +Scope: endpoints emitted by `api/dev/generate_swagger_specs.py` into the generated `console`, `openapi`, `service`, and `web` specs. + +The current Flask-RESTX generator still emits these response entries under `application/json`. The schema model names below record the actual runtime response intent so contract generation does not treat them as missing annotations. + +## Binary And Audio + +| Spec | Method | Path | Runtime response | Schema | +| ------- | ------ | ----------------------------------------------------------------------- | ---------------------------------------------- | --------------------- | +| console | POST | `/apps/{app_id}/text-to-audio` | Audio stream, usually `audio/mpeg` | `AudioBinaryResponse` | +| console | POST | `/installed-apps/{installed_app_id}/text-to-audio` | Audio stream, usually `audio/mpeg` | `AudioBinaryResponse` | +| console | POST | `/trial-apps/{app_id}/text-to-audio` | Audio stream, usually `audio/mpeg` | `AudioBinaryResponse` | +| web | POST | `/text-to-audio` | Audio stream, usually `audio/mpeg` | `AudioBinaryResponse` | +| service | POST | `/text-to-audio` | Audio stream, usually `audio/mpeg` | `AudioBinaryResponse` | +| console | POST | `/datasets/{dataset_id}/documents/download-zip` | `application/zip` attachment | `BinaryFileResponse` | +| service | POST | `/datasets/{dataset_id}/documents/download-zip` | `application/zip` attachment | `BinaryFileResponse` | +| service | GET | `/files/{file_id}/preview` | Original file MIME type, optionally attachment | `BinaryFileResponse` | +| console | GET | `/workspaces/current/plugin/icon` | Plugin asset MIME type | `BinaryFileResponse` | +| console | GET | `/workspaces/current/plugin/asset` | `application/octet-stream` | `BinaryFileResponse` | +| console | GET | `/workspaces/current/tool-provider/builtin/{provider}/icon` | Tool icon MIME type | `BinaryFileResponse` | +| console | GET | `/workspaces/current/trigger-provider/{provider}/icon` | Trigger icon response | `BinaryFileResponse` | +| console | GET | `/workspaces/{tenant_id}/model-providers/{provider}/{icon_type}/{lang}` | Model provider icon MIME type | `BinaryFileResponse` | + +## Text File Exports + +| Spec | Method | Path | Runtime response | Schema | +| ------- | ------ | ------------------------------------------------------------- | ----------------------------------------------------------------------- | ------------------ | +| console | GET | `/apps/{app_id}/feedbacks/export` | `text/csv` attachment by default; `format=json` returns JSON attachment | `TextFileResponse` | +| console | GET | `/workspaces/current/customized-snippets/{snippet_id}/export` | `application/x-yaml` attachment | `TextFileResponse` | + +## Fixed SSE Streams + +| Spec | Method | Path | Runtime response | Schema | +| ------- | ------ | ---------------------------------------------------------------------- | ------------------- | --------------------- | +| console | GET | `/workflow/{workflow_run_id}/events` | `text/event-stream` | `EventStreamResponse` | +| console | GET | `/apps/{app_id}/workflows/draft/runs/{run_id}/node-outputs/events` | `text/event-stream` | `EventStreamResponse` | +| console | GET | `/apps/{app_id}/workflows/published/runs/{run_id}/node-outputs/events` | `text/event-stream` | `EventStreamResponse` | +| openapi | POST | `/apps/{app_id}/run` | `text/event-stream` | `EventStreamResponse` | +| openapi | GET | `/apps/{app_id}/tasks/{task_id}/events` | `text/event-stream` | `EventStreamResponse` | +| service | GET | `/workflow/{task_id}/events` | `text/event-stream` | `EventStreamResponse` | +| web | GET | `/workflow/{task_id}/events` | `text/event-stream` | `EventStreamResponse` | + +## Generated Responses With Streaming Variants + +These endpoints call `helper.compact_generate_response(...)`. They return JSON in blocking mode and `text/event-stream` in streaming mode. Some console/debug paths always pass `streaming=True`. + +| Spec | Method | Path | Streaming behavior | Schema | +| ------- | ------ | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | --------------------------- | +| console | POST | `/apps/{app_id}/completion-messages` | `response_mode=streaming` | `GeneratedAppResponse` | +| console | POST | `/apps/{app_id}/advanced-chat/workflows/draft/run` | Always streaming | `GeneratedAppResponse` | +| console | POST | `/apps/{app_id}/advanced-chat/workflows/draft/iteration/nodes/{node_id}/run` | Always streaming | `GeneratedAppResponse` | +| console | POST | `/apps/{app_id}/advanced-chat/workflows/draft/loop/nodes/{node_id}/run` | Always streaming | `GeneratedAppResponse` | +| console | POST | `/apps/{app_id}/workflows/draft/run` | Always streaming | `GeneratedAppResponse` | +| console | POST | `/apps/{app_id}/workflows/draft/iteration/nodes/{node_id}/run` | Always streaming | `GeneratedAppResponse` | +| console | POST | `/apps/{app_id}/workflows/draft/loop/nodes/{node_id}/run` | Always streaming | `GeneratedAppResponse` | +| console | POST | `/apps/{app_id}/workflows/draft/trigger/run` | Returns waiting JSON until an event arrives; then streams workflow events | `GeneratedAppResponse` | +| console | POST | `/apps/{app_id}/workflows/draft/trigger/run-all` | Returns waiting JSON until an event arrives; then streams workflow events | `GeneratedAppResponse` | +| console | GET | `/installed-apps/{installed_app_id}/messages/{message_id}/more-like-this` | `response_mode=streaming` | `GeneratedAppResponse` | +| console | POST | `/installed-apps/{installed_app_id}/completion-messages` | `response_mode=streaming` | `GeneratedAppResponse` | +| console | POST | `/installed-apps/{installed_app_id}/chat-messages` | Always streaming | `GeneratedAppResponse` | +| console | POST | `/installed-apps/{installed_app_id}/workflows/run` | Always streaming | `GeneratedAppResponse` | +| console | POST | `/trial-apps/{app_id}/chat-messages` | Always streaming | `GeneratedAppResponse` | +| console | POST | `/trial-apps/{app_id}/completion-messages` | `response_mode=streaming` | `GeneratedAppResponse` | +| console | POST | `/trial-apps/{app_id}/workflows/run` | Always streaming | `GeneratedAppResponse` | +| console | POST | `/snippets/{snippet_id}/workflows/draft/run` | Always streaming | `GeneratedAppResponse` | +| console | POST | `/snippets/{snippet_id}/workflows/draft/iteration/nodes/{node_id}/run` | Always streaming | `GeneratedAppResponse` | +| console | POST | `/snippets/{snippet_id}/workflows/draft/loop/nodes/{node_id}/run` | Always streaming | `GeneratedAppResponse` | +| console | POST | `/rag/pipelines/{pipeline_id}/workflows/draft/run` | Always streaming | `RagPipelineOpaqueResponse` | +| console | POST | `/rag/pipelines/{pipeline_id}/workflows/published/run` | `response_mode=streaming` | `RagPipelineOpaqueResponse` | +| console | POST | `/rag/pipelines/{pipeline_id}/workflows/draft/iteration/nodes/{node_id}/run` | Always streaming | `RagPipelineOpaqueResponse` | +| console | POST | `/rag/pipelines/{pipeline_id}/workflows/draft/loop/nodes/{node_id}/run` | Always streaming | `RagPipelineOpaqueResponse` | +| console | POST | `/rag/pipelines/{pipeline_id}/workflows/draft/datasource/nodes/{node_id}/run` | Always streaming | `RagPipelineOpaqueResponse` | +| console | POST | `/rag/pipelines/{pipeline_id}/workflows/published/datasource/nodes/{node_id}/run` | Always streaming | `RagPipelineOpaqueResponse` | +| service | POST | `/completion-messages` | `response_mode=streaming` | `GeneratedAppResponse` | +| service | POST | `/chat-messages` | `response_mode=streaming`; agent apps are streaming-only | `GeneratedAppResponse` | +| service | POST | `/workflows/run` | `response_mode=streaming` | `GeneratedAppResponse` | +| service | POST | `/workflows/{workflow_id}/run` | `response_mode=streaming` | `GeneratedAppResponse` | +| service | POST | `/datasets/{dataset_id}/pipeline/run` | `response_mode=streaming` | `GeneratedAppResponse` | +| service | POST | `/datasets/{dataset_id}/pipeline/datasource/nodes/{node_id}/run` | Always streaming | `GeneratedAppResponse` | +| web | POST | `/completion-messages` | `response_mode=streaming` | `GeneratedAppResponse` | +| web | POST | `/chat-messages` | `response_mode=streaming`; agent apps are streaming-only | `GeneratedAppResponse` | +| web | GET | `/messages/{message_id}/more-like-this` | `response_mode=streaming` | `GeneratedAppResponse` | +| web | POST | `/workflows/run` | Always streaming | `GeneratedAppResponse` | + +## Redirects Not Included In Generated Contracts + +These console endpoints are browser redirect flows with no 2xx success response. They are documented in the backend OpenAPI as `302` responses with `RedirectResponse`, but `openapi-ts.api.config.ts` excludes 3xx-only operations from the oRPC contract input because they are not JSON contract operations. + +| Spec | Method | Path | Runtime response | Schema | +| ------- | ------ | ------------------------------------------------- | ------------------------------------------------- | ------------------ | +| console | GET | `/mcp/oauth/callback` | Redirect to console OAuth result page | `RedirectResponse` | +| console | GET | `/oauth/authorize/{provider}` | Redirect to OAuth authorization URL | `RedirectResponse` | +| console | GET | `/oauth/data-source/callback/{provider}` | Redirect to console with data source OAuth result | `RedirectResponse` | +| console | GET | `/oauth/login/{provider}` | Redirect to OAuth authorization URL | `RedirectResponse` | +| console | GET | `/oauth/plugin/{provider_id}/datasource/callback` | Redirect to console OAuth callback page | `RedirectResponse` | +| console | GET | `/oauth/plugin/{provider}/tool/callback` | Redirect to console tool OAuth result page | `RedirectResponse` | +| console | GET | `/oauth/plugin/{provider}/trigger/callback` | Redirect to console trigger OAuth result page | `RedirectResponse` | diff --git a/packages/contracts/openapi-ts.api.config.ts b/packages/contracts/openapi-ts.api.config.ts index 445be5f06ed..26c9e4cef40 100644 --- a/packages/contracts/openapi-ts.api.config.ts +++ b/packages/contracts/openapi-ts.api.config.ts @@ -7,48 +7,21 @@ import { $, defineConfig } from '@hey-api/openapi-ts' type JsonObject = Record type SwaggerSchema = JsonObject & { - '$defs'?: Record - '$ref'?: string - 'x-nullable'?: boolean - 'additionalProperties'?: unknown - 'allOf'?: SwaggerSchema[] - 'anyOf'?: SwaggerSchema[] - 'const'?: unknown - 'default'?: unknown - 'definitions'?: Record - 'description'?: string - 'enum'?: unknown[] - 'format'?: string - 'items'?: SwaggerSchema - 'oneOf'?: SwaggerSchema[] - 'properties'?: Record - 'required'?: string[] - 'type'?: string + $ref?: string } -type SwaggerParameter = JsonObject & { - in?: string - name?: string - required?: boolean - schema?: SwaggerSchema - type?: string -} - -type SwaggerResponse = JsonObject & { - description?: string - schema?: SwaggerSchema +type OpenApiComponents = JsonObject & { + schemas?: Record } type SwaggerOperation = JsonObject & { - deprecated?: boolean - description?: string operationId?: string - parameters?: SwaggerParameter[] - responses?: Record + responses?: Record } type SwaggerDocument = JsonObject & { - definitions?: Record + components?: OpenApiComponents + openapi?: string paths?: Record> } @@ -75,53 +48,22 @@ type ApiContractOperation = { path: string } -type ApiReadinessSurfaceStats = { - notReady: number - total: number -} - -type ApiSurface = 'console' | 'service' | 'web' - -type ApiOperationContext = { - method: string - routePath: string - runtimeBodyRequired: boolean -} - const currentDir = path.dirname(fileURLToPath(import.meta.url)) const apiOpenApiDir = path.resolve(currentDir, 'openapi') -const apiControllersDir = path.resolve(currentDir, '../../api/controllers') const operationMethods = new Set(['delete', 'get', 'patch', 'post', 'put']) -const requestBodyMethods = new Set(['delete', 'patch', 'post', 'put']) -const noBodyResponseStatuses = new Set(['204', '205', '304']) const apiSpecs: ApiSpec[] = [ - { filename: 'console-swagger.json', name: 'console' }, - { filename: 'web-swagger.json', name: 'web' }, - { filename: 'service-swagger.json', name: 'service' }, - { filename: 'openapi-swagger.json', name: 'openapi' }, + { filename: 'console-openapi.json', name: 'console' }, + { filename: 'web-openapi.json', name: 'web' }, + { filename: 'service-openapi.json', name: 'service' }, + { filename: 'openapi-openapi.json', name: 'openapi' }, ] -const inaccurateGeneratedContractDescription = 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.' -const apiReadinessStats: Record = {} - const isObject = (value: unknown): value is JsonObject => { return !!value && typeof value === 'object' && !Array.isArray(value) } -const unknownObjectSchema = (): SwaggerSchema => ({ - additionalProperties: true, - type: 'object', -}) - -const noContentSchema = (): SwaggerSchema => ({ - // Hey API's Swagger 2.0 pipeline currently needs a response schema symbol even for no-content responses. - additionalProperties: false, - properties: {}, - type: 'object', -}) - const toWords = (value: string) => { return value .replace(/[{}]/g, '') @@ -190,175 +132,20 @@ const clone = (value: T): T => { return JSON.parse(JSON.stringify(value)) as T } -const apiOperationKey = (surface: string, method: string, routePath: string) => { - return `${surface}:${method.toLowerCase()}:${routePath}` +const componentSchemaRefPrefix = '#/components/schemas/' + +const schemaNameFromRef = (ref: string) => { + if (ref.startsWith(componentSchemaRefPrefix)) + return ref.slice(componentSchemaRefPrefix.length) + return undefined } -// Swagger cannot tell whether an undocumented POST/PATCH/PUT/DELETE body is truly absent or -// just missing @expect(). Scan controllers so readiness stays conservative for those routes. -const listPythonFiles = (directory: string): string[] => { - if (!fs.existsSync(directory)) - return [] - - return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { - const entryPath = path.join(directory, entry.name) - if (entry.isDirectory()) - return listPythonFiles(entryPath) - if (entry.isFile() && entry.name.endsWith('.py')) - return [entryPath] - return [] - }) +const getDocumentSchemas = (document: SwaggerDocument) => { + const components = document.components ??= {} + return components.schemas ??= {} } -const leadingWhitespaceLength = (value: string) => { - return value.length - value.trimStart().length -} - -const parenthesesDelta = (value: string) => { - return [...value].reduce((total, char) => { - if (char === '(') - return total + 1 - if (char === ')') - return total - 1 - return total - }, 0) -} - -const collectDecorator = (lines: string[], startIndex: number) => { - const decoratorLines = [lines[startIndex] ?? ''] - let index = startIndex - let balance = parenthesesDelta(decoratorLines[0] ?? '') - - while (balance > 0 && index + 1 < lines.length) { - index += 1 - const line = lines[index] ?? '' - decoratorLines.push(line) - balance += parenthesesDelta(line) - } - - return { - decorator: decoratorLines.join('\n'), - endIndex: index, - } -} - -const routePathFromControllerPath = (controllerPath: string) => { - return controllerPath - .replace(/<(?:[^:<>]+:)?([^<>]+)>/g, '{$1}') - .replace(/\/+/g, '/') -} - -const routePathsFromDecorator = (decorator: string) => { - if (!decorator.includes('.route(')) - return [] - - return [...decorator.matchAll(/(['"])(.*?)\1/g)] - .map(([, , routePath]) => routePath) - .filter((routePath): routePath is string => typeof routePath === 'string' && routePath.startsWith('/')) - .map(routePathFromControllerPath) -} - -const methodBodyFrom = (lines: string[], methodLineIndex: number, methodIndent: number) => { - const bodyLines: string[] = [] - - for (let index = methodLineIndex + 1; index < lines.length; index += 1) { - const line = lines[index] ?? '' - if (line.trim() && leadingWhitespaceLength(line) <= methodIndent) - break - - bodyLines.push(line) - } - - return bodyLines.join('\n') -} - -const usesRuntimeJsonBody = (body: string) => { - return /\b(?:console_ns|service_api_ns|web_ns)\.payload\b/.test(body) - || /\brequest\.get_json\s*\(/.test(body) - || /\brequest\.json\b/.test(body) -} - -const collectRuntimeBodyOperationKeysFromFile = (surface: ApiSurface, filePath: string) => { - const operationKeys = new Set() - const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/) - let pendingDecorators: string[] = [] - let currentRoutes: string[] = [] - let currentClassIndent: number | undefined - - for (let index = 0; index < lines.length; index += 1) { - const line = lines[index] ?? '' - const trimmed = line.trim() - - if (!trimmed) - continue - - if (trimmed.startsWith('@')) { - const { decorator, endIndex } = collectDecorator(lines, index) - pendingDecorators.push(decorator) - index = endIndex - continue - } - - const indent = leadingWhitespaceLength(line) - const classMatch = line.match(/^(\s*)class\s+\w+/) - if (classMatch) { - currentClassIndent = indent - currentRoutes = pendingDecorators.flatMap(routePathsFromDecorator) - pendingDecorators = [] - continue - } - - if (currentClassIndent !== undefined && indent <= currentClassIndent) - currentRoutes = [] - - const methodMatch = line.match(/^\s*def\s+(delete|get|patch|post|put)\s*\(/) - if (!methodMatch) { - pendingDecorators = [] - continue - } - - pendingDecorators = [] - - const method = methodMatch[1] - if (!method || !requestBodyMethods.has(method)) - continue - - if (currentRoutes.length === 0) - continue - - const body = methodBodyFrom(lines, index, indent) - if (!usesRuntimeJsonBody(body)) - continue - - for (const routePath of currentRoutes) - operationKeys.add(apiOperationKey(surface, method, routePath)) - } - - return operationKeys -} - -const collectRuntimeBodyOperationKeys = () => { - const surfaces = { - console: path.join(apiControllersDir, 'console'), - service: path.join(apiControllersDir, 'service_api'), - web: path.join(apiControllersDir, 'web'), - } satisfies Record - - const operationKeys = new Set() - - for (const [surface, directory] of Object.entries(surfaces) as [ApiSurface, string][]) { - for (const filePath of listPythonFiles(directory)) { - for (const operationKey of collectRuntimeBodyOperationKeysFromFile(surface, filePath)) - operationKeys.add(operationKey) - } - } - - return operationKeys -} - -const runtimeBodyOperationKeys = collectRuntimeBodyOperationKeys() - -const collectDefinitionRefs = (value: unknown, refs: Set, visited = new WeakSet()) => { +const collectSchemaRefs = (value: unknown, refs: Set, visited = new WeakSet()) => { if (!value || typeof value !== 'object') return @@ -368,406 +155,22 @@ const collectDefinitionRefs = (value: unknown, refs: Set, visited = new visited.add(value) if (Array.isArray(value)) { - value.forEach(item => collectDefinitionRefs(item, refs, visited)) + value.forEach(item => collectSchemaRefs(item, refs, visited)) return } const objectValue = value as JsonObject const ref = objectValue.$ref - if (typeof ref === 'string' && ref.startsWith('#/definitions/')) - refs.add(ref.slice('#/definitions/'.length)) - - Object.values(objectValue).forEach(item => collectDefinitionRefs(item, refs, visited)) -} - -const removeNullDefaults = (value: unknown, visited = new WeakSet()) => { - if (!value || typeof value !== 'object' || visited.has(value)) - return - - visited.add(value) - - if (Array.isArray(value)) { - value.forEach(item => removeNullDefaults(item, visited)) - return + if (typeof ref === 'string') { + const refName = schemaNameFromRef(ref) + if (refName) + refs.add(refName) } - const schema = value as SwaggerSchema - if (schema.default === null) - delete schema.default - - Object.values(schema).forEach(item => removeNullDefaults(item, visited)) + Object.values(objectValue).forEach(item => collectSchemaRefs(item, refs, visited)) } -const isNullSchema = (schema: SwaggerSchema) => { - return schema.type === 'null' -} - -const normalizeNullableAnyOf = (value: unknown, visited = new WeakSet()) => { - if (!value || typeof value !== 'object' || visited.has(value)) - return - - visited.add(value) - - if (Array.isArray(value)) { - value.forEach(item => normalizeNullableAnyOf(item, visited)) - return - } - - const schema = value as SwaggerSchema - - if (Array.isArray(schema.anyOf)) { - const nonNullSchemas = schema.anyOf.filter(item => !isNullSchema(item)) - const hasNullSchema = nonNullSchemas.length !== schema.anyOf.length - - if (hasNullSchema && nonNullSchemas.length === 1) { - const { anyOf: _anyOf, ...rest } = schema - Object.keys(schema).forEach(key => delete schema[key]) - Object.assign(schema, rest, nonNullSchemas[0], { 'x-nullable': true }) - } - } - - Object.values(schema).forEach(item => normalizeNullableAnyOf(item, visited)) -} - -const hoistNestedDefinitions = (definitions: Record) => { - const visited = new WeakSet() - - const visit = (value: unknown) => { - if (!value || typeof value !== 'object' || visited.has(value)) - return - - visited.add(value) - - if (Array.isArray(value)) { - value.forEach(visit) - return - } - - const schema = value as SwaggerSchema - for (const key of ['$defs', 'definitions'] as const) { - const nestedDefinitions = schema[key] - if (!isObject(nestedDefinitions)) - continue - - for (const [name, nestedSchema] of Object.entries(nestedDefinitions)) { - definitions[name] ??= nestedSchema - visit(nestedSchema) - } - - delete schema[key] - } - - Object.values(schema).forEach(visit) - } - - Object.values(definitions).forEach(visit) -} - -const ensureReferencedDefinitions = (document: SwaggerDocument) => { - const definitions = document.definitions ??= {} - const refs = new Set() - collectDefinitionRefs(document, refs) - - for (const refName of refs) - definitions[refName] ??= unknownObjectSchema() -} - -const resolveDefinitionRef = ( - schema: SwaggerSchema | undefined, - definitions: Record, -): SwaggerSchema | undefined => { - const ref = schema?.$ref - - if (!ref?.startsWith('#/definitions/')) - return schema - - return definitions[ref.slice('#/definitions/'.length)] ?? schema -} - -const withoutNullableWrapper = (schema: SwaggerSchema | undefined): SwaggerSchema => { - if (!schema) - return {} - - const nonNullSchema = schema.anyOf?.find(item => item.type !== 'null') - if (!nonNullSchema) - return schema - - const { anyOf: _anyOf, ...rest } = schema - return { - ...rest, - ...nonNullSchema, - } -} - -const isNullEnumItem = (item: unknown) => { - return isObject(item) && (item.type === 'null' || item.const === null) -} - -const markNullableEnumSchema = (ctx: { schema: JsonObject }): undefined => { - const items = ctx.schema.items - - if (ctx.schema['x-nullable'] !== true || !Array.isArray(items) || items.some(isNullEnumItem)) - return undefined - - // Hey API's enum visitors infer nullable from a null enum item, not x-nullable. - ctx.schema.items = [...items, { const: null, type: 'null' }] - - return undefined -} - -const queryParameterFromSchema = ( - name: string, - schema: SwaggerSchema | undefined, - required: boolean, -): SwaggerParameter => { - const querySchema = withoutNullableWrapper(schema) - const parameter: SwaggerParameter = { - in: 'query', - name, - required, - } - - if (querySchema.default !== undefined) - parameter.default = querySchema.default - - if (querySchema.description) - parameter.description = querySchema.description - - if (querySchema.enum) - parameter.enum = querySchema.enum - - if (querySchema.format) - parameter.format = querySchema.format - - if (querySchema.items) - parameter.items = querySchema.items - - for (const key of [ - 'exclusiveMaximum', - 'exclusiveMinimum', - 'maxItems', - 'maxLength', - 'maximum', - 'minItems', - 'minLength', - 'minimum', - 'multipleOf', - 'pattern', - 'uniqueItems', - 'x-nullable', - ]) { - if (querySchema[key] !== undefined) - parameter[key] = querySchema[key] - } - - parameter.type = ['array', 'boolean', 'integer', 'number', 'string'].includes(querySchema.type ?? '') - ? querySchema.type - : 'string' - - return parameter -} - -const mergeQueryParameter = ( - parameters: SwaggerParameter[], - queryParameter: SwaggerParameter, -) => { - const existingIndex = parameters.findIndex((parameter) => { - return parameter.in === 'query' && parameter.name === queryParameter.name - }) - - if (existingIndex === -1) { - parameters.push(queryParameter) - return - } - - const existingParameter = parameters[existingIndex] - if (!existingParameter) { - parameters.push(queryParameter) - return - } - - parameters[existingIndex] = { - ...existingParameter, - ...queryParameter, - description: queryParameter.description ?? existingParameter.description, - required: Boolean(existingParameter.required) || Boolean(queryParameter.required), - } -} - -const normalizeGetBodyParameters = ( - operation: SwaggerOperation, - definitions: Record, -) => { - if (!Array.isArray(operation.parameters)) - return - - const bodyParameters: SwaggerParameter[] = [] - const normalizedParameters: SwaggerParameter[] = [] - - for (const parameter of operation.parameters) { - if (parameter.in === 'body') { - bodyParameters.push(parameter) - continue - } - - normalizedParameters.push(parameter) - } - - for (const parameter of bodyParameters) { - const schema = resolveDefinitionRef(parameter.schema, definitions) - const properties = schema?.properties ?? {} - const required = new Set(schema?.required ?? []) - - for (const [name, propertySchema] of Object.entries(properties)) { - mergeQueryParameter( - normalizedParameters, - queryParameterFromSchema(name, propertySchema, required.has(name)), - ) - } - } - - operation.parameters = normalizedParameters -} - -const normalizeResponses = (operation: SwaggerOperation) => { - const responses = operation.responses ??= {} - - for (const [status, response] of Object.entries(responses)) { - if (noBodyResponseStatuses.has(status)) { - response.schema = noContentSchema() - continue - } - - if (!response.schema) - response.schema = unknownObjectSchema() - } - - if (!Object.keys(responses).some(status => /^2\d\d$/.test(status))) { - responses['200'] = { - description: 'Success', - schema: unknownObjectSchema(), - } - } -} - -const hasProperties = (schema: SwaggerSchema) => { - return isObject(schema.properties) && Object.keys(schema.properties).length > 0 -} - -const isEmptySchemaObject = (value: unknown) => { - return isObject(value) && Object.keys(value).length === 0 -} - -// A field the backend marked deliberately open via the `x-dify-opaque` vendor extension — e.g. a -// JSON Schema document or an app-config blob whose shape is genuinely arbitrary. Such a field is -// intentionally an open object, not an under-annotated one, so the readiness detector must not -// flag it (or its owning operation) as inaccurate. -const isIntentionallyOpaque = (schema: SwaggerSchema) => { - return (schema as { 'x-dify-opaque'?: unknown })['x-dify-opaque'] === true -} - -const isLooseObjectSchema = (schema: SwaggerSchema) => { - if (hasProperties(schema)) - return false - - if (schema.additionalProperties === true || isEmptySchemaObject(schema.additionalProperties)) - return true - - return schema.type === 'object' && schema.additionalProperties === undefined -} - -const hasLooseSchema = ( - schema: SwaggerSchema | undefined, - definitions: Record, - visitedRefs = new Set(), -): boolean => { - if (!schema) - return true - - if (isIntentionallyOpaque(schema)) - return false - - const ref = schema?.$ref - if (ref?.startsWith('#/definitions/')) { - const refName = ref.slice('#/definitions/'.length) - if (visitedRefs.has(refName)) - return false - - return hasLooseSchema(definitions[refName], definitions, new Set([...visitedRefs, refName])) - } - - const normalizedSchema = withoutNullableWrapper(schema) - - for (const variants of [normalizedSchema.allOf, normalizedSchema.anyOf, normalizedSchema.oneOf]) { - if (Array.isArray(variants) && variants.some(item => !isNullSchema(item) && hasLooseSchema(item, definitions, visitedRefs))) - return true - } - - if (normalizedSchema.type === 'array') - return hasLooseSchema(normalizedSchema.items, definitions, visitedRefs) - - if (isLooseObjectSchema(normalizedSchema)) - return true - - if (isObject(normalizedSchema.additionalProperties) && hasLooseSchema(normalizedSchema.additionalProperties, definitions, visitedRefs)) - return true - - return Object.values(normalizedSchema.properties ?? {}) - .some(property => hasLooseSchema(property, definitions, visitedRefs)) -} - -const hasPossiblyInaccurateGeneratedContractTypes = ( - operation: SwaggerOperation, - definitions: Record, - context: ApiOperationContext, -) => { - const successResponses = Object.entries(operation.responses ?? {}) - .filter(([status]) => /^2\d\d$/.test(status)) - - if (successResponses.length === 0) - return true - - const successResponsesWithBody = successResponses.filter(([status]) => !noBodyResponseStatuses.has(status)) - if (successResponsesWithBody.some(([, response]) => hasLooseSchema(response.schema, definitions))) - return true - - if (context.runtimeBodyRequired && !operation.parameters?.some(parameter => parameter.in === 'body')) - return true - - return operation.parameters?.some((parameter) => { - return parameter.in === 'body' && hasLooseSchema(parameter.schema, definitions) - }) ?? false -} - -const appendOperationDescription = (operation: SwaggerOperation, description: string) => { - const currentDescription = operation.description?.trim() - operation.description = currentDescription ? `${currentDescription}\n\n${description}` : description -} - -const markPossiblyInaccurateGeneratedContract = (operation: SwaggerOperation) => { - operation.deprecated = true - appendOperationDescription(operation, inaccurateGeneratedContractDescription) -} - -const recordApiReadiness = (surface: string, isReady: boolean) => { - const stats = apiReadinessStats[surface] ??= { - notReady: 0, - total: 0, - } - - stats.total += 1 - - if (!isReady) - stats.notReady += 1 -} - -const formatPercent = (ready: number, total: number) => { - return total === 0 ? '0.0%' : `${((ready / total) * 100).toFixed(1)}%` -} - -const normalizeOperations = (document: SwaggerDocument, surface: string) => { - const definitions = document.definitions ??= {} - +const addOperationIds = (document: SwaggerDocument) => { for (const [routePath, pathItem] of Object.entries(document.paths ?? {})) { for (const [method, operation] of Object.entries(pathItem)) { if (!operationMethods.has(method) || !isObject(operation)) @@ -775,74 +178,50 @@ const normalizeOperations = (document: SwaggerDocument, surface: string) => { const swaggerOperation = operation as SwaggerOperation swaggerOperation.operationId = operationId(method, routePath) - - normalizeResponses(swaggerOperation) - const hasPossiblyInaccurateTypes = hasPossiblyInaccurateGeneratedContractTypes(swaggerOperation, definitions, { - method, - routePath, - runtimeBodyRequired: runtimeBodyOperationKeys.has(apiOperationKey(surface, method, routePath)), - }) - recordApiReadiness(surface, !hasPossiblyInaccurateTypes) - - if (method === 'get') - normalizeGetBodyParameters(swaggerOperation, definitions) - - if (hasPossiblyInaccurateTypes) - markPossiblyInaccurateGeneratedContract(swaggerOperation) } } } -const normalizeApiSwagger = (document: SwaggerDocument, surface: string) => { - document.definitions ??= {} - - // Flask-RESTX emits Pydantic nested $defs inside individual schemas while - // refs point at the root Swagger 2.0 definitions object. - hoistNestedDefinitions(document.definitions) - ensureReferencedDefinitions(document) - normalizeNullableAnyOf(document) - removeNullDefaults(document) - normalizeOperations(document, surface) - - return document +const hasSuccessResponse = (operation: SwaggerOperation) => { + return Object.keys(operation.responses ?? {}).some(status => /^2\d\d$/.test(status)) } -const printApiReadinessStats = () => { - const sortedSurfaces = Object.entries(apiReadinessStats) - .sort(([left], [right]) => left.localeCompare(right)) +const filterContractOperations = (document: SwaggerDocument) => { + for (const [routePath, pathItem] of Object.entries(document.paths ?? {})) { + for (const [method, operation] of Object.entries(pathItem)) { + if (!operationMethods.has(method) || !isObject(operation)) + continue - const totals = sortedSurfaces.reduce( - (summary, [, stats]) => { - summary.notReady += stats.notReady - summary.total += stats.total - return summary - }, - { notReady: 0, total: 0 }, - ) - const totalReady = totals.total - totals.notReady - const rows = sortedSurfaces.map(([surface, stats]) => { - const ready = stats.total - stats.notReady - return ` ${surface}: ${ready}/${stats.total} ready (${formatPercent(ready, stats.total)}), ${stats.notReady} not ready` - }) + if (!hasSuccessResponse(operation as SwaggerOperation)) + delete pathItem[method] + } - console.log([ - 'API OpenAPI readiness:', - ...rows, - ` total: ${totalReady}/${totals.total} ready (${formatPercent(totalReady, totals.total)}), ${totals.notReady} not ready`, - ].join('\n')) + const hasOperations = Object.entries(pathItem) + .some(([method, operation]) => operationMethods.has(method) && isObject(operation)) + + if (!hasOperations) + delete document.paths?.[routePath] + } +} + +const normalizeApiSwagger = (document: SwaggerDocument) => { + filterContractOperations(document) + addOperationIds(document) + + return document } const topLevelPathSegment = (routePath: string) => { return routePath.split('/').filter(Boolean)[0] ?? 'root' } -const selectReferencedDefinitions = ( - definitions: Record, +const selectReferencedSchemas = ( + schemas: Record, paths: Record>, ) => { - const selectedDefinitions: Record = {} + const selectedSchemas: Record = {} const pendingRefs = new Set() - collectDefinitionRefs(paths, pendingRefs) + collectSchemaRefs(paths, pendingRefs) while (pendingRefs.size > 0) { const refName = pendingRefs.values().next().value @@ -851,32 +230,40 @@ const selectReferencedDefinitions = ( pendingRefs.delete(refName) - if (selectedDefinitions[refName]) + if (selectedSchemas[refName]) continue - selectedDefinitions[refName] = definitions[refName] ?? unknownObjectSchema() + const schema = schemas[refName] + if (!schema) + throw new Error(`Missing referenced schema: ${refName}`) + + selectedSchemas[refName] = schema const nestedRefs = new Set() - collectDefinitionRefs(selectedDefinitions[refName], nestedRefs) + collectSchemaRefs(selectedSchemas[refName], nestedRefs) for (const nestedRef of nestedRefs) { - if (!selectedDefinitions[nestedRef]) + if (!selectedSchemas[nestedRef]) pendingRefs.add(nestedRef) } } - return selectedDefinitions + return selectedSchemas } const cloneDocumentWithPaths = ( document: SwaggerDocument, paths: Record>, ) => { - const { definitions: _definitions, paths: _paths, ...metadata } = document + const { components: _components, paths: _paths, ...metadata } = document const clonedPaths = clone(paths) + const components = clone(document.components ?? {}) + const sourceSchemas = getDocumentSchemas(document) + + components.schemas = selectReferencedSchemas(sourceSchemas, clonedPaths) return { ...clone(metadata), - definitions: selectReferencedDefinitions(document.definitions ?? {}, clonedPaths), + components, paths: clonedPaths, } satisfies SwaggerDocument } @@ -945,7 +332,7 @@ const splitConsoleDocument = (document: SwaggerDocument) => { } const createApiJobs = (spec: ApiSpec): ApiJob[] => { - const document = normalizeApiSwagger(readApiSwagger(spec.filename), spec.name) + const document = normalizeApiSwagger(readApiSwagger(spec.filename)) if (spec.name === 'console') return splitConsoleDocument(document) @@ -959,7 +346,6 @@ const createApiJobs = (spec: ApiSpec): ApiJob[] => { } const apiJobs = apiSpecs.flatMap(createApiJobs) -printApiReadinessStats() const createApiConfig = (job: ApiJob): UserConfig => ({ input: job.document, @@ -977,16 +363,12 @@ const createApiConfig = (job: ApiJob): UserConfig => ({ }, plugins: job.plugins ?? [ { - 'comments': false, - 'name': '@hey-api/typescript', - '~resolvers': { - enum: markNullableEnumSchema, - }, + comments: false, + name: '@hey-api/typescript', }, { 'name': 'zod', '~resolvers': { - enum: markNullableEnumSchema, string: (ctx) => { if (ctx.schema.format !== 'binary') return undefined diff --git a/packages/contracts/sandbox-contract.smoke.test.ts b/packages/contracts/sandbox-contract.smoke.test.ts new file mode 100644 index 00000000000..84e721f14a7 --- /dev/null +++ b/packages/contracts/sandbox-contract.smoke.test.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict' +import { registerHooks } from 'node:module' +import { dirname, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const thisDir = dirname(fileURLToPath(import.meta.url)) +const sourcePath = resolve(thisDir, './generated/api/console/apps/orpc.gen.ts') + +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === './zod.gen' || specifier.endsWith('/zod.gen')) + return nextResolve(`${specifier}.ts`, context) + + return nextResolve(specifier, context) + }, +}) + +const { agentSandbox, sandbox } = await import(pathToFileURL(sourcePath).href) + +assert.ok(agentSandbox.files.get) +assert.ok(agentSandbox.files.read.get) +assert.ok(agentSandbox.files.upload.post) + +assert.ok(sandbox.files.get) +assert.ok(sandbox.files.read.get) +assert.ok(sandbox.files.upload.post) diff --git a/packages/dify-ui/.storybook/main.ts b/packages/dify-ui/.storybook/main.ts index c8b7ee8e3f5..2dcdf5afe28 100644 --- a/packages/dify-ui/.storybook/main.ts +++ b/packages/dify-ui/.storybook/main.ts @@ -8,6 +8,8 @@ const config: StorybookConfig = { '@storybook/addon-links', '@storybook/addon-docs', '@storybook/addon-themes', + '@storybook/addon-a11y', + '@storybook/addon-vitest', '@chromatic-com/storybook', ], framework: '@storybook/react-vite', diff --git a/packages/dify-ui/.storybook/preview.tsx b/packages/dify-ui/.storybook/preview.tsx index a5bfc5d8af6..18bf768e4fb 100644 --- a/packages/dify-ui/.storybook/preview.tsx +++ b/packages/dify-ui/.storybook/preview.tsx @@ -24,6 +24,9 @@ const preview: Preview = { docs: { toc: true, }, + a11y: { + test: 'error', + }, }, tags: ['autodocs'], } diff --git a/packages/dify-ui/.storybook/storybook.css b/packages/dify-ui/.storybook/storybook.css index ca76cd29689..d4a567d9cd9 100644 --- a/packages/dify-ui/.storybook/storybook.css +++ b/packages/dify-ui/.storybook/storybook.css @@ -16,7 +16,13 @@ html[data-theme='dark'] { } body { + position: relative; background: var(--color-components-panel-bg); color: var(--color-text-primary, #101828); font-family: Inter, ui-sans-serif, system-ui, sans-serif; } + +#storybook-root, +#storybook-docs { + isolation: isolate; +} diff --git a/packages/dify-ui/README.md b/packages/dify-ui/README.md index b392c5adecc..1833eb4ecdf 100644 --- a/packages/dify-ui/README.md +++ b/packages/dify-ui/README.md @@ -61,6 +61,12 @@ Utilities: - `./cn` — `clsx` + `tailwind-merge` wrapper. Use this for conditional class composition. - `./styles.css` — the one CSS entry that ships the design tokens, theme variables, and project utilities/components. Import it once from the app root. +## Button loading and disabled contract + +`Button` keeps normal `disabled` controls native-disabled by default so unavailable actions are removed from the keyboard focus order. + +When `loading` is true, `Button` defaults `focusableWhenDisabled` to true. Loading represents an action that has already been triggered and is temporarily pending, so the button remains focusable while Base UI still suppresses click, pointer, keyboard activation, and submit-button activation. Pass `focusableWhenDisabled={false}` only when a loading button should use native disabled behavior. + ## Segmented control contract `SegmentedControl` is Dify's design-system primitive for mode, filter, and view selection. It is built on Base UI `ToggleGroup` + `Toggle`, so use `Tabs` instead when the UI needs `tablist` / `tabpanel` semantics. @@ -160,8 +166,23 @@ See `[web/docs/overlay.md](../../web/docs/overlay.md)` for the web app overlay b - `pnpm -C packages/dify-ui test` — Vitest unit tests for primitives. - `pnpm -C packages/dify-ui storybook` — Storybook on the default port. Each primitive has `index.stories.tsx`. +- `pnpm -C packages/dify-ui test:storybook` — Storybook component tests in Vitest browser mode. Stories without `play` are render and a11y smoke tests; stories with `play` should cover public UI contracts such as opening overlays, keyboard navigation, disabled/loading guards, form submission, and controlled state updates. - `pnpm -C packages/dify-ui type-check` — `tsgo --noEmit` for this package only. +### Test Boundary + +Use Storybook tests for behavior that belongs to the documented component example: +visible state changes, user interaction, keyboard paths, overlay open/close flows, +and accessibility-facing semantics. Keep regular Vitest unit tests for lower-level +wrapper contracts such as class variants, Base UI passthrough props, hidden input +serialization, data attribute hooks, store behavior, and edge cases that do not +need a full story. + +Storybook accessibility testing stays enabled globally with `a11y.test = 'error'`. +If a story is temporarily marked `todo`, keep the exception local to that story +and do not treat an interaction `play` test as a replacement for fixing the +underlying accessibility issue. + ### Disabling Animations In Tests Base UI can wait for `element.getAnimations()` to finish before it unmounts overlays, panels, and transition-driven components. Browser-based test runners can make that timing unstable, especially when tests assert final DOM state rather than animation behavior. diff --git a/packages/dify-ui/package.json b/packages/dify-ui/package.json index 5758e3541bf..0d2e4d47359 100644 --- a/packages/dify-ui/package.json +++ b/packages/dify-ui/package.json @@ -157,8 +157,9 @@ "scripts": { "storybook": "storybook dev -p 6006", "storybook:build": "storybook build", - "test": "vp test", - "test:watch": "vp test --watch", + "test": "vp test --project unit", + "test:storybook": "vp test --project storybook --run", + "test:watch": "vp test --project unit --watch", "type-check": "tsgo" }, "peerDependencies": { @@ -178,9 +179,11 @@ "@dify/tsconfig": "workspace:*", "@egoist/tailwindcss-icons": "catalog:", "@iconify-json/ri": "catalog:", + "@storybook/addon-a11y": "catalog:", "@storybook/addon-docs": "catalog:", "@storybook/addon-links": "catalog:", "@storybook/addon-themes": "catalog:", + "@storybook/addon-vitest": "catalog:", "@storybook/react-vite": "catalog:", "@tailwindcss/vite": "catalog:", "@tanstack/react-hotkeys": "catalog:", diff --git a/packages/dify-ui/src/alert-dialog/index.stories.tsx b/packages/dify-ui/src/alert-dialog/index.stories.tsx index 0b6f60f01ef..c8dcc6ac533 100644 --- a/packages/dify-ui/src/alert-dialog/index.stories.tsx +++ b/packages/dify-ui/src/alert-dialog/index.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite' -import { useState } from 'react' +import * as React from 'react' +import { expect, waitFor, within } from 'storybook/test' import { AlertDialog, AlertDialogActions, @@ -55,6 +56,21 @@ export const Default: Story = { ), + play: async ({ canvas, canvasElement, userEvent }) => { + const body = within(canvasElement.ownerDocument.body) + + await userEvent.click(canvas.getByRole('button', { name: 'Delete project' })) + + const dialog = body.getByRole('alertdialog', { name: 'Delete project?' }) + await waitFor(async () => { + await expect(dialog).toBeVisible() + }) + + await userEvent.click(body.getByRole('button', { name: 'Cancel' })) + await waitFor(async () => { + await expect(body.queryByRole('alertdialog', { name: 'Delete project?' })).not.toBeInTheDocument() + }) + }, } export const NonDestructive: Story = { @@ -84,8 +100,8 @@ export const NonDestructive: Story = { } const ControlledDemo = () => { - const [open, setOpen] = useState(false) - const [count, setCount] = useState(0) + const [open, setOpen] = React.useState(false) + const [count, setCount] = React.useState(0) return (
@@ -130,8 +146,8 @@ export const Controlled: Story = { } const LoadingConfirmDemo = () => { - const [pending, setPending] = useState(false) - const [open, setOpen] = useState(false) + const [pending, setPending] = React.useState(false) + const [open, setOpen] = React.useState(false) const handleConfirm = () => { setPending(true) diff --git a/packages/dify-ui/src/alert-dialog/index.tsx b/packages/dify-ui/src/alert-dialog/index.tsx index e3c8cddd7a3..f774f265652 100644 --- a/packages/dify-ui/src/alert-dialog/index.tsx +++ b/packages/dify-ui/src/alert-dialog/index.tsx @@ -1,6 +1,6 @@ 'use client' -import type { ComponentPropsWithoutRef, ReactNode } from 'react' +import type * as React from 'react' import type { ButtonProps } from '../button' import { AlertDialog as BaseAlertDialog } from '@base-ui/react/alert-dialog' import { Button } from '../button' @@ -12,7 +12,7 @@ export const AlertDialogTitle = BaseAlertDialog.Title export const AlertDialogDescription = BaseAlertDialog.Description type AlertDialogContentProps = { - children: ReactNode + children: React.ReactNode className?: string backdropClassName?: string backdropProps?: Omit @@ -47,7 +47,7 @@ export function AlertDialogContent({ ) } -type AlertDialogActionsProps = ComponentPropsWithoutRef<'div'> +type AlertDialogActionsProps = React.ComponentProps<'div'> export function AlertDialogActions({ className, ...props }: AlertDialogActionsProps) { return ( @@ -59,7 +59,7 @@ export function AlertDialogActions({ className, ...props }: AlertDialogActionsPr } type AlertDialogCancelButtonProps = Omit & { - children: ReactNode + children: React.ReactNode closeProps?: Omit } diff --git a/packages/dify-ui/src/autocomplete/__tests__/index.spec.tsx b/packages/dify-ui/src/autocomplete/__tests__/index.spec.tsx index d0cab69401e..17cdaaaceed 100644 --- a/packages/dify-ui/src/autocomplete/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/autocomplete/__tests__/index.spec.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react' +import * as React from 'react' import { render } from 'vitest-browser-react' import { Autocomplete, @@ -18,7 +18,7 @@ import { AutocompleteTrigger, } from '../index' -const renderWithSafeViewport = (ui: ReactNode) => render( +const renderWithSafeViewport = (ui: React.ReactNode) => render(
{ui}
, @@ -31,13 +31,13 @@ const renderAutocomplete = ({ open = false, defaultValue = 'workflow', }: { - children?: ReactNode + children?: React.ReactNode open?: boolean defaultValue?: string } = {}) => renderWithSafeViewport( {children ?? ( - <> + @@ -65,7 +65,7 @@ const renderAutocomplete = ({ No suggestions - + )} , ) @@ -150,7 +150,7 @@ describe('Autocomplete wrappers', () => { it('should rely on aria-labelledby when provided instead of injecting fallback labels', async () => { const screen = await renderAutocomplete({ children: ( - <> + Clear from label Trigger from label @@ -158,7 +158,7 @@ describe('Autocomplete wrappers', () => { - + ), }) diff --git a/packages/dify-ui/src/autocomplete/index.stories.tsx b/packages/dify-ui/src/autocomplete/index.stories.tsx index 5b98b39f1fa..bf96cd1ee17 100644 --- a/packages/dify-ui/src/autocomplete/index.stories.tsx +++ b/packages/dify-ui/src/autocomplete/index.stories.tsx @@ -1,8 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import type { Virtualizer } from '@tanstack/react-virtual' -import type { RefObject } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' -import { useEffect, useMemo, useRef, useState, useTransition } from 'react' +import * as React from 'react' import { Autocomplete, AutocompleteClear, @@ -298,8 +297,9 @@ const CommandPaletteList = () => { {groups.map((group, groupIndex) => ( - {groupIndex > 0 && } - {group.label} + 0 ? 'mt-1 border-t border-divider-subtle pt-2' : undefined}> + {group.label} + {(item: Suggestion) => ( @@ -336,12 +336,12 @@ const LimitedStatus = ({ } const AsyncSearchDemo = () => { - const [searchValue, setSearchValue] = useState('') - const [searchResults, setSearchResults] = useState([]) - const [error, setError] = useState(null) - const [isPending, startTransition] = useTransition() + const [searchValue, setSearchValue] = React.useState('') + const [searchResults, setSearchResults] = React.useState([]) + const [error, setError] = React.useState(null) + const [isPending, startTransition] = React.useTransition() const { contains } = useAutocompleteFilter() - const abortControllerRef = useRef(null) + const abortControllerRef = React.useRef(null) const status = (() => { if (isPending) @@ -419,9 +419,9 @@ const AsyncSearchDemo = () => { const VirtualizedSuggestionList = ({ virtualizerRef, }: { - virtualizerRef: RefObject + virtualizerRef: React.RefObject }) => { - const scrollRef = useRef(null) + const scrollRef = React.useRef(null) const filteredItems = useAutocompleteFilteredItems() const virtualizer = useVirtualizer({ count: filteredItems.length, @@ -430,7 +430,7 @@ const VirtualizedSuggestionList = ({ overscan: 6, }) - useEffect(() => { + React.useEffect(() => { virtualizerRef.current = virtualizer return () => { @@ -490,7 +490,7 @@ const FuzzyHighlight = ({ text: string query: string }) => { - const parts = useMemo(() => { + const parts = React.useMemo(() => { const trimmed = query.trim() if (!trimmed) @@ -501,18 +501,18 @@ const FuzzyHighlight = ({ }, [query, text]) return ( - <> + {parts.map((part, index) => ( part.toLowerCase() === query.trim().toLowerCase() ? {part} : part ))} - + ) } const FuzzyMatchingDemo = () => { - const [value, setValue] = useState('retr') + const [value, setValue] = React.useState('retr') const { contains } = useAutocompleteFilter({ sensitivity: 'base' }) return ( @@ -692,7 +692,7 @@ export const CommandPalette: Story = { > @@ -702,7 +702,7 @@ export const CommandPalette: Story = { } const VirtualizedLongSuggestionsDemo = () => { - const virtualizerRef = useRef(null) + const virtualizerRef = React.useRef(null) return (
diff --git a/packages/dify-ui/src/autocomplete/index.tsx b/packages/dify-ui/src/autocomplete/index.tsx index e12de083dfa..b362a9450fd 100644 --- a/packages/dify-ui/src/autocomplete/index.tsx +++ b/packages/dify-ui/src/autocomplete/index.tsx @@ -1,11 +1,12 @@ 'use client' import type { VariantProps } from 'class-variance-authority' -import type { HTMLAttributes, ReactNode } from 'react' +import type * as React from 'react' import type { Placement } from '../placement' import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete' import { cva } from 'class-variance-authority' import { cn } from '../cn' +import { textControlCompoundFocusClassName } from '../form-control-shared' import { overlayIndicatorClassName, overlayLabelClassName, @@ -49,7 +50,7 @@ const autocompleteInputGroupVariants = cva( [ 'group/autocomplete flex w-full min-w-0 items-center border border-transparent bg-components-input-bg-normal text-components-input-text-filled shadow-none outline-hidden transition-[background-color,border-color,box-shadow]', 'hover:border-components-input-border-hover hover:bg-components-input-bg-hover', - 'focus-within:border-components-input-border-active focus-within:bg-components-input-bg-active focus-within:shadow-xs', + textControlCompoundFocusClassName, 'data-focused:border-components-input-border-active data-focused:bg-components-input-bg-active data-focused:shadow-xs', 'data-disabled:cursor-not-allowed data-disabled:border-transparent data-disabled:bg-components-input-bg-disabled data-disabled:text-components-input-text-filled-disabled', 'data-disabled:hover:border-transparent data-disabled:hover:bg-components-input-bg-disabled', @@ -223,7 +224,7 @@ export function AutocompleteIcon({ } type AutocompleteContentProps = { - children: ReactNode + children: React.ReactNode placement?: Placement sideOffset?: number alignOffset?: number @@ -302,7 +303,7 @@ export function AutocompleteItem({ ) } -export type AutocompleteItemTextProps = HTMLAttributes +export type AutocompleteItemTextProps = React.ComponentProps<'span'> export function AutocompleteItemText({ className, @@ -368,7 +369,7 @@ export function AutocompleteItemIndicator({ className, children, ...props -}: HTMLAttributes) { +}: React.ComponentProps<'span'>) { return ( element as HTMLElement @@ -106,9 +108,16 @@ describe('Button', () => { expect(screen.getByRole('button').element().querySelector('[aria-hidden="true"]')).not.toBeInTheDocument() }) - it('auto-disables when loading', async () => { + it('keeps loading buttons focusable by default', async () => { const screen = await render() - await expect.element(screen.getByRole('button')).toBeDisabled() + const button = screen.getByRole('button').element() + + expect(button).not.toHaveAttribute('disabled') + expect((button as HTMLButtonElement).disabled).toBe(false) + expect(button).toHaveAttribute('aria-disabled', 'true') + + asHTMLElement(button).focus() + expect(button).toHaveFocus() }) it('sets aria-busy when loading', async () => { @@ -128,10 +137,17 @@ describe('Button', () => { await expect.element(screen.getByRole('button')).toBeDisabled() }) - it('keeps focusable when loading with focusableWhenDisabled', async () => { - const screen = await render() + it('does not keep normal disabled buttons focusable by default', async () => { + const screen = await render() const button = screen.getByRole('button').element() - expect(button).toHaveAttribute('aria-disabled', 'true') + + expect(button).toBeDisabled() + expect(button).not.toHaveAttribute('aria-disabled') + }) + + it('allows loading focusability to be opted out', async () => { + const screen = await render() + await expect.element(screen.getByRole('button')).toBeDisabled() }) }) @@ -156,6 +172,35 @@ describe('Button', () => { asHTMLElement(screen.getByRole('button').element()).click() expect(onClick).not.toHaveBeenCalled() }) + + it('does not submit a form when a loading submit button is clicked', async () => { + const onSubmit = vi.fn((event: React.FormEvent) => event.preventDefault()) + const screen = await render( +
+ +
, + ) + + asHTMLElement(screen.getByRole('button', { name: 'Submit' }).element()).click() + + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('does not implicitly submit a form through a loading submit button', async () => { + const onSubmit = vi.fn((event: React.FormEvent) => event.preventDefault()) + const screen = await render( +
+ + + +
, + ) + + asHTMLElement(screen.getByRole('textbox', { name: 'Name' }).element()).focus() + await userEvent.keyboard('{Enter}') + + expect(onSubmit).not.toHaveBeenCalled() + }) }) describe('className merging', () => { diff --git a/packages/dify-ui/src/button/index.stories.tsx b/packages/dify-ui/src/button/index.stories.tsx index b70a76f431f..91a1d38c2d3 100644 --- a/packages/dify-ui/src/button/index.stories.tsx +++ b/packages/dify-ui/src/button/index.stories.tsx @@ -1,4 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite' +import * as React from 'react' +import { expect, fn } from 'storybook/test' import { Button } from '.' @@ -11,6 +13,7 @@ const meta = { tags: ['autodocs'], argTypes: { loading: { control: 'boolean' }, + focusableWhenDisabled: { control: 'boolean' }, tone: { control: 'select', options: ['default', 'destructive'], @@ -88,8 +91,28 @@ export const Loading: Story = { args: { variant: 'primary', loading: true, + onClick: fn(), children: 'Loading Button', }, + play: async ({ args, canvas, userEvent }) => { + const button = canvas.getByRole('button', { name: 'Loading Button' }) + + await expect(button).toHaveAttribute('aria-disabled', 'true') + await expect(button).toHaveAttribute('aria-busy', 'true') + + button.focus() + await expect(button).toHaveFocus() + + await userEvent.click(button) + await expect(args.onClick).not.toHaveBeenCalled() + }, + parameters: { + docs: { + description: { + story: 'Loading buttons remain focusable by default so focus is not lost after activation. Pass `focusableWhenDisabled={false}` to opt out.', + }, + }, + }, } export const Destructive: Story = { @@ -104,10 +127,10 @@ export const WithIcon: Story = { args: { variant: 'primary', children: ( - <> - + + Launch - + ), }, } @@ -132,6 +155,7 @@ export const AsLink: Story = { args: { variant: 'ghost-accent', render: , + nativeButton: false, children: 'Link Button', }, } diff --git a/packages/dify-ui/src/button/index.tsx b/packages/dify-ui/src/button/index.tsx index 03e5c4a937a..2181b880a55 100644 --- a/packages/dify-ui/src/button/index.tsx +++ b/packages/dify-ui/src/button/index.tsx @@ -1,3 +1,5 @@ +'use client' + import type { Button as BaseButtonNS } from '@base-ui/react/button' import type { VariantProps } from 'class-variance-authority' import { Button as BaseButton } from '@base-ui/react/button' @@ -112,6 +114,7 @@ export function Button({ tone, loading, disabled, + focusableWhenDisabled, type = 'button', children, ...props @@ -121,6 +124,7 @@ export function Button({ type={type} className={cn(buttonVariants({ variant, size, tone, className }))} disabled={disabled || loading} + focusableWhenDisabled={focusableWhenDisabled ?? loading} aria-busy={loading || undefined} {...props} > diff --git a/packages/dify-ui/src/checkbox-group/__tests__/index.spec.tsx b/packages/dify-ui/src/checkbox-group/__tests__/index.spec.tsx index 4e98f428619..f716a1eb343 100644 --- a/packages/dify-ui/src/checkbox-group/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/checkbox-group/__tests__/index.spec.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import * as React from 'react' import { render } from 'vitest-browser-react' import { Checkbox } from '../../checkbox' import { FieldItem, FieldLabel, FieldRoot } from '../../field' @@ -10,7 +10,7 @@ const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElem describe('CheckboxGroup', () => { it('should manage selected values and parent mixed state', async () => { function PermissionsDemo() { - const [value, setValue] = useState(['read']) + const [value, setValue] = React.useState(['read']) return ( diff --git a/packages/dify-ui/src/checkbox-group/index.stories.tsx b/packages/dify-ui/src/checkbox-group/index.stories.tsx index ea4e638babd..1fa2643a2e0 100644 --- a/packages/dify-ui/src/checkbox-group/index.stories.tsx +++ b/packages/dify-ui/src/checkbox-group/index.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react-vite' -import { useId, useState } from 'react' +import * as React from 'react' import { CheckboxGroup } from '.' import { Checkbox } from '../checkbox' import { @@ -29,8 +29,8 @@ type Story = StoryObj function DocumentSelectionDemo() { const documentIds = ['doc-1', 'doc-2', 'doc-3'] - const [selected, setSelected] = useState(['doc-1']) - const groupLabelId = useId() + const [selected, setSelected] = React.useState(['doc-1']) + const groupLabelId = React.useId() return ( (['markdown']) + const [selected, setSelected] = React.useState(['markdown']) return ( diff --git a/packages/dify-ui/src/checkbox/index.stories.tsx b/packages/dify-ui/src/checkbox/index.stories.tsx index adf4f28388e..ff15aaa9870 100644 --- a/packages/dify-ui/src/checkbox/index.stories.tsx +++ b/packages/dify-ui/src/checkbox/index.stories.tsx @@ -1,6 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react-vite' -import type { ComponentProps } from 'react' -import { useState } from 'react' +import * as React from 'react' import { Checkbox, CheckboxSkeleton, @@ -42,8 +41,8 @@ const meta = { export default meta type Story = StoryObj -function CheckboxDemo(args: Partial>) { - const [checked, setChecked] = useState(args.checked ?? false) +function CheckboxDemo(args: Partial>) { + const [checked, setChecked] = React.useState(args.checked ?? false) return (
- + ) } diff --git a/packages/dify-ui/src/combobox/__tests__/index.spec.tsx b/packages/dify-ui/src/combobox/__tests__/index.spec.tsx index 043e0a2e1a4..08045eb20e3 100644 --- a/packages/dify-ui/src/combobox/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/combobox/__tests__/index.spec.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react' +import * as React from 'react' import { render } from 'vitest-browser-react' import { Combobox, @@ -24,7 +24,7 @@ import { ComboboxValue, } from '../index' -const renderWithSafeViewport = (ui: ReactNode) => render( +const renderWithSafeViewport = (ui: React.ReactNode) => render(
{ui}
, @@ -36,12 +36,12 @@ const renderSelectLikeCombobox = ({ children, open = false, }: { - children?: ReactNode + children?: React.ReactNode open?: boolean } = {}) => renderWithSafeViewport( {children ?? ( - <> + Resource type @@ -68,7 +68,7 @@ const renderSelectLikeCombobox = ({ No options - + )} , ) @@ -77,12 +77,12 @@ const renderInputCombobox = ({ children, open = false, }: { - children?: ReactNode + children?: React.ReactNode open?: boolean } = {}) => renderWithSafeViewport( {children ?? ( - <> + @@ -96,7 +96,7 @@ const renderInputCombobox = ({ - + )} , ) @@ -208,7 +208,7 @@ describe('Combobox wrappers', () => { it('should rely on aria-labelledby when provided instead of injecting fallback labels', async () => { const screen = await renderInputCombobox({ children: ( - <> + Clear from label Trigger from label @@ -216,7 +216,7 @@ describe('Combobox wrappers', () => { - + ), }) @@ -349,7 +349,7 @@ describe('Combobox wrappers', () => { {(selectedValue: string[]) => ( - <> + {selectedValue.map(item => ( {item} @@ -357,7 +357,7 @@ describe('Combobox wrappers', () => { ))} - + )} @@ -378,7 +378,7 @@ describe('Combobox wrappers', () => { {(selectedValue: string[]) => ( - <> + {selectedValue.map(item => ( Remove Maya @@ -386,7 +386,7 @@ describe('Combobox wrappers', () => { ))} - + )} diff --git a/packages/dify-ui/src/combobox/index.stories.tsx b/packages/dify-ui/src/combobox/index.stories.tsx index 8cf40951af7..1c8021811cd 100644 --- a/packages/dify-ui/src/combobox/index.stories.tsx +++ b/packages/dify-ui/src/combobox/index.stories.tsx @@ -1,8 +1,8 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import type { Virtualizer } from '@tanstack/react-virtual' -import type { RefObject } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' -import { useEffect, useMemo, useRef, useState, useTransition } from 'react' +import * as React from 'react' +import { expect } from 'storybook/test' import { Combobox, ComboboxChip, @@ -278,9 +278,9 @@ const GroupedToolList = () => { const VirtualizedModelList = ({ virtualizerRef, }: { - virtualizerRef: RefObject + virtualizerRef: React.RefObject }) => { - const scrollRef = useRef(null) + const scrollRef = React.useRef(null) const filteredItems = useComboboxFilteredItems
@@ -375,15 +375,15 @@ const VirtualizedLongListDemo = () => { } const AsyncDirectoryDemo = () => { - const [searchResults, setSearchResults] = useState([]) - const [selectedValue, setSelectedValue] = useState
diff --git a/packages/dify-ui/src/combobox/index.tsx b/packages/dify-ui/src/combobox/index.tsx index aeb88184ada..6931a2df7a8 100644 --- a/packages/dify-ui/src/combobox/index.tsx +++ b/packages/dify-ui/src/combobox/index.tsx @@ -1,12 +1,12 @@ 'use client' import type { VariantProps } from 'class-variance-authority' -import type { HTMLAttributes, ReactNode } from 'react' +import type * as React from 'react' import type { Placement } from '../placement' import { Combobox as BaseCombobox } from '@base-ui/react/combobox' import { cva } from 'class-variance-authority' import { cn } from '../cn' -import { formLabelClassName } from '../form-control-shared' +import { formLabelClassName, textControlCompoundFocusClassName } from '../form-control-shared' import { overlayIndicatorClassName, overlayLabelClassName, @@ -80,7 +80,7 @@ type ComboboxTriggerProps & VariantProps & { className?: string - icon?: ReactNode | false + icon?: React.ReactNode | false } export function ComboboxTrigger({ @@ -113,7 +113,7 @@ const comboboxInputGroupVariants = cva( [ 'group/combobox flex w-full min-w-0 items-center border border-transparent bg-components-input-bg-normal text-components-input-text-filled shadow-none outline-hidden transition-[background-color,border-color,box-shadow]', 'hover:border-components-input-border-hover hover:bg-components-input-bg-hover', - 'focus-within:border-components-input-border-active focus-within:bg-components-input-bg-active focus-within:shadow-xs', + textControlCompoundFocusClassName, 'data-focused:border-components-input-border-active data-focused:bg-components-input-bg-active data-focused:shadow-xs', 'data-popup-open:border-components-input-border-active data-popup-open:bg-components-input-bg-active', 'data-disabled:cursor-not-allowed data-disabled:border-transparent data-disabled:bg-components-input-bg-disabled data-disabled:text-components-input-text-filled-disabled', @@ -286,7 +286,7 @@ export function ComboboxIcon({ } type ComboboxContentProps = { - children: ReactNode + children: React.ReactNode placement?: Placement sideOffset?: number alignOffset?: number @@ -365,7 +365,7 @@ export function ComboboxItem({ ) } -export type ComboboxItemTextProps = HTMLAttributes +export type ComboboxItemTextProps = React.ComponentProps<'span'> export function ComboboxItemText({ className, @@ -383,7 +383,7 @@ export function ComboboxItemIndicator({ className, children, ...props -}: Omit & { children?: ReactNode }) { +}: Omit & { children?: React.ReactNode }) { return ( render( +const renderWithSafeViewport = (ui: React.ReactNode) => render(
{ui}
, diff --git a/packages/dify-ui/src/context-menu/index.stories.tsx b/packages/dify-ui/src/context-menu/index.stories.tsx index 0be5727e7a5..a29ef94abd7 100644 --- a/packages/dify-ui/src/context-menu/index.stories.tsx +++ b/packages/dify-ui/src/context-menu/index.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react-vite' -import { useState } from 'react' +import * as React from 'react' import { ContextMenu, ContextMenuCheckboxItem, @@ -100,7 +100,7 @@ export const WithGroupLabel: Story = { } const WithRadioItemsDemo = () => { - const [value, setValue] = useState('comfortable') + const [value, setValue] = React.useState('comfortable') return ( @@ -130,9 +130,9 @@ export const WithRadioItems: Story = { } const WithCheckboxItemsDemo = () => { - const [showToolbar, setShowToolbar] = useState(true) - const [showSidebar, setShowSidebar] = useState(false) - const [showStatusBar, setShowStatusBar] = useState(true) + const [showToolbar, setShowToolbar] = React.useState(true) + const [showSidebar, setShowSidebar] = React.useState(false) + const [showStatusBar, setShowStatusBar] = React.useState(true) return ( diff --git a/packages/dify-ui/src/context-menu/index.tsx b/packages/dify-ui/src/context-menu/index.tsx index 422dff6b622..f10eea25a94 100644 --- a/packages/dify-ui/src/context-menu/index.tsx +++ b/packages/dify-ui/src/context-menu/index.tsx @@ -1,6 +1,6 @@ 'use client' -import type { ReactNode } from 'react' +import type * as React from 'react' import type { OverlayItemVariant } from '../overlay-shared' import type { Placement } from '../placement' import { ContextMenu as BaseContextMenu } from '@base-ui/react/context-menu' @@ -27,7 +27,7 @@ export type ContextMenuActions = BaseContextMenu.Root.Actions // Intentionally no public Backdrop export; Base UI handles context-menu modal dismissal internally. type ContextMenuContentProps = { - children: ReactNode + children: React.ReactNode placement?: Placement sideOffset?: number alignOffset?: number @@ -225,7 +225,7 @@ export function ContextMenuSubTrigger({ } type ContextMenuSubContentProps = { - children: ReactNode + children: React.ReactNode placement?: Placement sideOffset?: number alignOffset?: number diff --git a/packages/dify-ui/src/dialog/index.stories.tsx b/packages/dify-ui/src/dialog/index.stories.tsx index 24556a11e77..e70cc2465db 100644 --- a/packages/dify-ui/src/dialog/index.stories.tsx +++ b/packages/dify-ui/src/dialog/index.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite' -import { useState } from 'react' +import * as React from 'react' +import { expect, waitFor, within } from 'storybook/test' import { Dialog, DialogCloseButton, @@ -66,6 +67,22 @@ export const Default: Story = { ), + play: async ({ canvas, canvasElement, userEvent }) => { + const body = within(canvasElement.ownerDocument.body) + + await userEvent.click(canvas.getByRole('button', { name: 'Open dialog' })) + + const dialog = body.getByRole('dialog', { name: 'Invite collaborators' }) + await waitFor(async () => { + await expect(dialog).toBeVisible() + }) + await expect(body.getByRole('textbox', { name: 'Email address' })).toBeVisible() + + await userEvent.click(body.getByRole('button', { name: 'Close' })) + await waitFor(async () => { + await expect(body.queryByRole('dialog', { name: 'Invite collaborators' })).not.toBeInTheDocument() + }) + }, } export const WithoutCloseButton: Story = { @@ -95,7 +112,7 @@ export const WithoutCloseButton: Story = { } const ControlledDemo = () => { - const [open, setOpen] = useState(false) + const [open, setOpen] = React.useState(false) return (
@@ -148,7 +165,7 @@ type ApiExtensionFormValues = { } const FormDialogDemo = () => { - const [open, setOpen] = useState(false) + const [open, setOpen] = React.useState(false) return ( diff --git a/packages/dify-ui/src/dialog/index.tsx b/packages/dify-ui/src/dialog/index.tsx index 95b438df094..5162a1a35af 100644 --- a/packages/dify-ui/src/dialog/index.tsx +++ b/packages/dify-ui/src/dialog/index.tsx @@ -1,13 +1,6 @@ 'use client' -// z-index strategy (relies on root `isolation: isolate` in layout.tsx): -// All @langgenius/dify-ui/* overlay primitives — z-50 -// Toast stays one layer above overlays at z-60. -// Overlays share the same z-index; DOM order handles stacking when multiple are open. -// This ensures overlays inside a Dialog (e.g. a Tooltip on a dialog button) render -// above the dialog backdrop instead of being clipped by it. - -import type { ReactNode } from 'react' +import type * as React from 'react' import { Dialog as BaseDialog } from '@base-ui/react/dialog' import { cn } from '../cn' @@ -39,7 +32,7 @@ export function DialogCloseButton({ } type DialogContentProps = { - children: ReactNode + children: React.ReactNode className?: string backdropClassName?: string backdropProps?: Omit diff --git a/packages/dify-ui/src/drawer/index.stories.tsx b/packages/dify-ui/src/drawer/index.stories.tsx new file mode 100644 index 00000000000..a1f4674c592 --- /dev/null +++ b/packages/dify-ui/src/drawer/index.stories.tsx @@ -0,0 +1,1057 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import type { DrawerRootSnapPoint } from '.' +import * as React from 'react' +import { + createDrawerHandle, + Drawer, + DrawerBackdrop, + DrawerClose, + DrawerCloseButton, + DrawerContent, + DrawerDescription, + DrawerIndent, + DrawerIndentBackground, + DrawerPopup, + DrawerPortal, + DrawerProvider, + DrawerSwipeArea, + DrawerTitle, + DrawerTrigger, + DrawerViewport, +} from '.' +import { Button } from '../button' +import { cn } from '../cn' +import { Input } from '../input' +import { + ScrollAreaContent, + ScrollAreaRoot, + ScrollAreaScrollbar, + ScrollAreaThumb, + ScrollAreaViewport, +} from '../scroll-area' + +const triggerButtonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid' +const textCloseClassName = 'inline-flex h-8 items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3.5 text-[13px] font-medium text-components-button-secondary-text shadow-xs outline-hidden hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid' +const primaryCloseClassName = 'inline-flex h-8 items-center justify-center rounded-lg border-components-button-primary-border bg-components-button-primary-bg px-3.5 text-[13px] font-medium text-components-button-primary-text shadow outline-hidden hover:border-components-button-primary-border-hover hover:bg-components-button-primary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid' +const handleClassName = 'mx-auto mt-3 h-1 w-10 shrink-0 rounded-full bg-state-base-handle' +const bottomHandleClassName = 'mx-auto mb-3 h-1 w-10 shrink-0 rounded-full bg-state-base-handle' + +const meta = { + title: 'Base/UI/Drawer', + component: Drawer, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Compound drawer built on Base UI Drawer. Use it for side panels, bottom sheets, nested editor panels, snap-point sheets, and mobile navigation surfaces that need swipe gestures. If the panel only needs modal focus management without gestures, use Dialog instead.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +const settingRows = [ + ['Production model', 'gpt-4.1'], + ['Retrieval source', 'Customer knowledge base'], + ['Response mode', 'Streaming'], +] as const + +export const Default: Story = { + render: () => ( + + }> + Open drawer + + + + + + +
+
+ + Workspace settings + + + Review the key runtime defaults for this workspace. + +
+ +
+
+
+ {settingRows.map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+
+
+ Cancel + Save changes +
+
+
+
+
+
+ ), +} + +function ControlledDemo() { + const [open, setOpen] = React.useState(false) + + return ( +
+ + + State: + {' '} + {open ? 'open' : 'closed'} + + + + + + + +
+
+ + Controlled drawer + + + Use open and onOpenChange when the owning feature needs to react to close events. + +
+ +
+
+ +
+
+ Dismiss + Done +
+
+
+
+
+
+
+ ) +} + +export const Controlled: Story = { + render: () => , +} + +export const Positions: Story = { + render: () => ( +
+ + }> + Right panel + + + + + + +
+
+ + Right panel + + + This drawer is positioned with swipeDirection="right" and the Dify default popup styles. + +
+ +
+
+
+ Position is controlled by Base UI data attributes and the drawer popup classes, not by a separate wrapper component. +
+
+
+ Close +
+
+
+
+
+
+ + }> + Left panel + + + + + + +
+
+ + Left panel + + + This drawer is positioned with swipeDirection="left" and the Dify default popup styles. + +
+ +
+
+
+ Position is controlled by Base UI data attributes and the drawer popup classes, not by a separate wrapper component. +
+
+
+ Close +
+
+
+
+
+
+ + }> + Bottom sheet + + + + + +
+ +
+ + Bottom sheet + + + This drawer uses the default swipeDirection="down" bottom sheet behavior. + +
+
+
+ The drag handle sits at the top because the sheet dismisses downward. +
+
+
+ Close +
+
+ + + + + + }> + Top sheet + + + + + + +
+ + Top sheet + + + This drawer is positioned with swipeDirection="up" and dismisses upward. + +
+
+
+ The drag handle sits at the bottom because the sheet dismisses upward. +
+
+
+ Close +
+
+
+ + + + +
+ ), +} + +const snapTopMarginRem = 1 +const visibleSnapPointRem = 30 +const initialSnapPoint: DrawerRootSnapPoint = `${visibleSnapPointRem + snapTopMarginRem}rem` +const snapPoints = [initialSnapPoint, 1] satisfies DrawerRootSnapPoint[] + +function SnapPointsDemo() { + const [snapPoint, setSnapPoint] = React.useState(initialSnapPoint) + + return ( + + }> + Open snap drawer + + + + + +
+
+ + Snap points + +
+ +
+ + Drag the sheet to snap between a compact peek and a near full-height view. + +
+ Current snap point + {String(snapPoint)} +
+
+ {Array.from({ length: 20 }, (_, index) => ( +
+ + {index + 1} + +
+
+ ))} +
+
+ Close +
+
+ + + + + + ) +} + +export const SnapPoints: Story = { + render: () => , +} + +export const NestedDrawers: Story = { + render: () => ( + + }> + Open drawer stack + + + + + +
+ +
+
+ + Account + + + Open nested drawers from inside a drawer while the parent remains in the stack. + +
+
+
+
+ + }> + Security settings + + + + +
+ +
+
+ + Security + + + Nested drawers keep their own title, footer action, and focus scope. + +
+
+
+
    +
  • Passkeys enabled
  • +
  • 2FA via authenticator app
  • +
  • 3 signed-in devices
  • +
+
+
+ + }> + Advanced options + + + + +
+ +
+
+ + Advanced + + + The stack uses Base UI nested drawer data attributes for visual treatment. + +
+
+
+ +
+
+ Done +
+
+ + + + + Close security +
+ +
+
+
+
+ Close +
+
+ + + + + ), +} + +function IndentEffectDemo() { + const [portalContainer, setPortalContainer] = React.useState(null) + + return ( + +
+ + +
+

Indent provider surface

+

+ The background and app shell respond when any drawer inside the provider opens. +

+
+ + }> + Open indent drawer + + + + + +
+ +
+ + Notifications + + + The indented shell uses DrawerProvider, DrawerIndentBackground, and DrawerIndent. + +
+
+
+ The app shell scales behind this sheet while the drawer stays inside the local portal container. +
+
+
+ Close +
+
+ + + + + +
+ + ) +} + +export const IndentEffect: Story = { + parameters: { + layout: 'centered', + }, + render: () => , +} + +function NonModalDemo() { + const [backgroundClicks, setBackgroundClicks] = React.useState(0) + + return ( + +
+ }> + Open non-modal drawer + + + + Background clicks: + {' '} + {backgroundClicks} + +
+ + + + +
+
+ + Non-modal drawer + + + Focus is not trapped and outside pointer dismissal is disabled. + +
+ +
+
+
+ The background action remains clickable while this drawer is open. Outside clicks do not dismiss it. +
+
+
+ Close +
+
+
+
+
+
+ ) +} + +export const NonModal: Story = { + render: () => , +} + +const mobilePrimaryLinks = [ + { href: '/storybook/mobile/overview', label: 'Overview' }, + { href: '/storybook/mobile/components', label: 'Components' }, + { href: '/storybook/mobile/patterns', label: 'Patterns' }, + { href: '/storybook/mobile/releases', label: 'Releases' }, +] as const + +const mobileComponentLinks = [ + { href: '/storybook/mobile/components/alert-dialog', label: 'Alert Dialog' }, + { href: '/storybook/mobile/components/avatar', label: 'Avatar' }, + { href: '/storybook/mobile/components/button', label: 'Button' }, + { href: '/storybook/mobile/components/collapsible', label: 'Collapsible' }, + { href: '/storybook/mobile/components/dialog', label: 'Dialog' }, + { href: '/storybook/mobile/components/drawer', label: 'Drawer' }, + { href: '/storybook/mobile/components/dropdown-menu', label: 'Dropdown Menu' }, + { href: '/storybook/mobile/components/file-tree', label: 'File Tree' }, + { href: '/storybook/mobile/components/pagination', label: 'Pagination' }, + { href: '/storybook/mobile/components/popover', label: 'Popover' }, + { href: '/storybook/mobile/components/scroll-area', label: 'Scroll Area' }, + { href: '/storybook/mobile/components/select', label: 'Select' }, + { href: '/storybook/mobile/components/tabs', label: 'Tabs' }, + { href: '/storybook/mobile/components/toast', label: 'Toast' }, + { href: '/storybook/mobile/components/tooltip', label: 'Tooltip' }, +] as const + +export const MobileNavigation: Story = { + parameters: { + docs: { + description: { + story: 'Based on the Base UI mobile navigation example. Open this story in a mobile or narrow viewport to evaluate the full-screen sheet behavior, long-list scrolling, and flick-to-dismiss gesture.', + }, + }, + }, + render: () => ( + + }> + Open mobile menu + + + + + + + + +
+ + + + + + + + + + + ), +} + +function SwipeToOpenDemo() { + const [portalContainer, setPortalContainer] = React.useState(null) + + return ( +
+ + + Swipe + +
+
+
Swipe area
+
Drag from the highlighted right edge to open the drawer.
+
+ }> + Open drawer + +
+
+
+ + + + + +
+
+ + Library + + + Swipe from the edge whenever you want to jump back into a panel. + +
+ +
+
+
+ Close +
+ + + + + +
+ ) +} + +export const SwipeToOpen: Story = { + render: () => , +} + +const actionItems = [ + ['Duplicate app', 'Create a copy in the same workspace.'], + ['Export DSL', 'Download the workflow definition.'], + ['Move to folder', 'Organize this app with related work.'], +] as const + +function ActionSheetDemo() { + const [open, setOpen] = React.useState(false) + + return ( + + }> + Open action sheet + + + + + + + App actions + + Choose an action for Customer support assistant. + +
    + {actionItems.map(([label, description]) => ( +
  • + + {label} + {description} + +
  • + ))} +
+
+
+ + Delete app + +
+
+
+
+
+ ) +} + +export const ActionSheet: Story = { + render: () => , +} + +type DetachedPayload = { + title: string + description: string + fields: readonly string[] +} + +const detachedPayloads = [ + { + title: 'Profile', + description: 'Update identity fields for the current member.', + fields: ['Display name', 'Role', 'Location'], + }, + { + title: 'Billing', + description: 'Review workspace billing contacts and usage limits.', + fields: ['Plan', 'Billing email', 'Monthly usage'], + }, +] as const satisfies readonly DetachedPayload[] + +function DetachedTriggersDemo() { + const [drawerHandle] = React.useState(() => createDrawerHandle()) + + return ( +
+
+
+
+
External trigger surface
+
These triggers are rendered before the shared Drawer.Root.
+
+ + handle + +
+
+ {detachedPayloads.map(payload => ( +
+
+
{payload.title}
+
{`payload: ${payload.fields.join(', ')}`}
+
+ } + > + {`Open ${payload.title}`} + +
+ ))} +
+
+
+ One Drawer.Root is mounted separately below this trigger surface and reads the payload from whichever detached trigger opened it. +
+ + {({ payload }) => ( + + + + + +
+
+ + {payload?.title ?? 'Detached drawer'} + + + {payload?.description ?? 'This drawer is opened by a trigger outside Drawer.Root.'} + +
+ +
+
+
+ Opened by detached trigger payload +
+
+ {(payload?.fields ?? ['Detached trigger']).map(field => ( +
+ {field} +
+ ))} +
+
+
+ Done +
+
+
+
+
+ )} +
+
+ ) +} + +export const DetachedTriggers: Story = { + render: () => , +} + +export const StackingAndAnimations: Story = { + render: () => ( + + }> + Open animated stack + + + + + + +
+
+ + Right panel animation + + + This panel slides in from the right using Base UI starting, ending, swiping, and nested data attributes. + +
+ +
+
+
+
+ Open the nested right panel to see the parent drawer dim while the frontmost drawer keeps the same right-side motion. +
+ + }> + Open nested right panel + + + + + +
+
+ + Nested right panel + + + The front drawer uses the same right-side entering, ending, and swipe transition classes. + +
+ +
+
+
+ Close this drawer to watch focus and visual stacking return to the parent drawer. +
+
+
+ Close nested +
+
+
+
+
+
+
+
+ data-starting-style +
+
+ data-ending-style +
+
+ data-swiping +
+
+ data-nested-drawer-open +
+
+
+
+
+ Close +
+
+
+
+
+
+ ), +} + +export const InstantRightPanel: Story = { + render: () => ( + + }> + Open instant right panel + + + + + +
+
+ + Instant right panel + + + This non-modal drawer opens without a slide-in animation and ignores outside clicks. + +
+ +
+
+
+ The page stays interactive because this drawer is non-modal. Starting and ending styles both keep the panel at translateX(0), so open and close are instant. +
+
+
+ Close +
+
+
+
+
+
+ ), +} diff --git a/packages/dify-ui/src/drawer/index.tsx b/packages/dify-ui/src/drawer/index.tsx index 24da53c57c8..e08b0fb752c 100644 --- a/packages/dify-ui/src/drawer/index.tsx +++ b/packages/dify-ui/src/drawer/index.tsx @@ -1,6 +1,6 @@ 'use client' -import type { ReactNode } from 'react' +import type * as React from 'react' import { Drawer as BaseDrawer } from '@base-ui/react/drawer' import { cn } from '../cn' @@ -90,7 +90,7 @@ export function DrawerContent({ } type DrawerCloseButtonProps = Omit & { - children?: ReactNode + children?: React.ReactNode } export function DrawerCloseButton({ diff --git a/packages/dify-ui/src/dropdown-menu/__tests__/index.spec.tsx b/packages/dify-ui/src/dropdown-menu/__tests__/index.spec.tsx index a9753a84447..f28606670e3 100644 --- a/packages/dify-ui/src/dropdown-menu/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/dropdown-menu/__tests__/index.spec.tsx @@ -1,3 +1,4 @@ +import type * as React from 'react' import { render } from 'vitest-browser-react' import { DropdownMenu, @@ -11,7 +12,7 @@ import { DropdownMenuTrigger, } from '../index' -const renderWithSafeViewport = (ui: import('react').ReactNode) => render( +const renderWithSafeViewport = (ui: React.ReactNode) => render(
{ui}
, diff --git a/packages/dify-ui/src/dropdown-menu/index.stories.tsx b/packages/dify-ui/src/dropdown-menu/index.stories.tsx index f73b33ac8be..e0dcfaf123e 100644 --- a/packages/dify-ui/src/dropdown-menu/index.stories.tsx +++ b/packages/dify-ui/src/dropdown-menu/index.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react-vite' -import { useState } from 'react' +import * as React from 'react' import { DropdownMenu, DropdownMenuCheckboxItem, @@ -133,7 +133,7 @@ export const WithSubmenu: Story = { } const WithRadioItemsDemo = () => { - const [value, setValue] = useState('comfortable') + const [value, setValue] = React.useState('comfortable') return ( @@ -163,9 +163,9 @@ export const WithRadioItems: Story = { } const WithCheckboxItemsDemo = () => { - const [showToolbar, setShowToolbar] = useState(true) - const [showSidebar, setShowSidebar] = useState(false) - const [showStatusBar, setShowStatusBar] = useState(true) + const [showToolbar, setShowToolbar] = React.useState(true) + const [showSidebar, setShowSidebar] = React.useState(false) + const [showStatusBar, setShowStatusBar] = React.useState(true) return ( @@ -252,8 +252,8 @@ export const WithLinkItems: Story = { } const ComplexDemo = () => { - const [sortOrder, setSortOrder] = useState('newest') - const [showArchived, setShowArchived] = useState(false) + const [sortOrder, setSortOrder] = React.useState('newest') + const [showArchived, setShowArchived] = React.useState(false) return ( diff --git a/packages/dify-ui/src/dropdown-menu/index.tsx b/packages/dify-ui/src/dropdown-menu/index.tsx index 509d4c9d352..9f873a88ae1 100644 --- a/packages/dify-ui/src/dropdown-menu/index.tsx +++ b/packages/dify-ui/src/dropdown-menu/index.tsx @@ -1,6 +1,6 @@ 'use client' -import type { ReactNode } from 'react' +import type * as React from 'react' import type { OverlayItemVariant } from '../overlay-shared' import type { Placement } from '../placement' import { Menu } from '@base-ui/react/menu' @@ -89,7 +89,7 @@ export function DropdownMenuLabel({ } type DropdownMenuContentProps = { - children: ReactNode + children: React.ReactNode placement?: Placement sideOffset?: number alignOffset?: number @@ -197,7 +197,7 @@ export function DropdownMenuSubTrigger({ } type DropdownMenuSubContentProps = { - children: ReactNode + children: React.ReactNode placement?: Placement sideOffset?: number alignOffset?: number diff --git a/packages/dify-ui/src/field/__tests__/index.spec.tsx b/packages/dify-ui/src/field/__tests__/index.spec.tsx index 95692123606..e84d150aeaf 100644 --- a/packages/dify-ui/src/field/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/field/__tests__/index.spec.tsx @@ -1,3 +1,4 @@ +import * as React from 'react' import { render } from 'vitest-browser-react' import { Checkbox } from '../../checkbox' import { CheckboxGroup } from '../../checkbox-group' @@ -96,7 +97,7 @@ describe('Field primitives', () => { it('should apply design-system control sizes when requested', async () => { const screen = await render( - <> + Name @@ -105,7 +106,7 @@ describe('Field primitives', () => { Alias - , + , ) await expect.element(screen.getByRole('textbox', { name: 'Name' })).toHaveClass('rounded-[10px]', 'py-[7px]', 'system-md-regular') diff --git a/packages/dify-ui/src/file-tree/index.stories.tsx b/packages/dify-ui/src/file-tree/index.stories.tsx index 2f00b36f49f..a75e40aa65c 100644 --- a/packages/dify-ui/src/file-tree/index.stories.tsx +++ b/packages/dify-ui/src/file-tree/index.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite' -import type { ReactNode } from 'react' import type { FileTreeIconType } from '.' -import { useState } from 'react' +import * as React from 'react' +import { expect } from 'storybook/test' import { FileTreeBadge, FileTreeFile, @@ -118,7 +118,7 @@ function FileTreeNodeRows({ } function ComposedFileTree() { - const [selectedItemId, setSelectedItemId] = useState('button') + const [selectedItemId, setSelectedItemId] = React.useState('button') return ( ('app-components-file-tree') + const [selectedItemId, setSelectedItemId] = React.useState('app-components-file-tree') return ( @@ -331,6 +331,19 @@ function VisualStates() { export const Default: Story = { render: () => , + play: async ({ canvas, userEvent }) => { + const srcFolder = canvas.getByRole('button', { name: 'src' }) + + await expect(canvas.getByRole('button', { name: 'components' })).toBeVisible() + + await userEvent.click(srcFolder) + await expect(srcFolder).toHaveAttribute('aria-expanded', 'false') + await expect(canvas.queryByRole('button', { name: 'components' })).not.toBeInTheDocument() + + await userEvent.click(srcFolder) + await expect(srcFolder).toHaveAttribute('aria-expanded', 'true') + await expect(canvas.getByRole('button', { name: 'components' })).toBeVisible() + }, } export const DataDriven: Story = { diff --git a/packages/dify-ui/src/file-tree/index.tsx b/packages/dify-ui/src/file-tree/index.tsx index 1b56ae8face..f3e42e7a41f 100644 --- a/packages/dify-ui/src/file-tree/index.tsx +++ b/packages/dify-ui/src/file-tree/index.tsx @@ -1,22 +1,18 @@ 'use client' -import type { ReactNode } from 'react' import { Collapsible as BaseCollapsible } from '@base-ui/react/collapsible' import { mergeProps } from '@base-ui/react/merge-props' import { useRender } from '@base-ui/react/use-render' -import { - createContext, - useContext, -} from 'react' +import * as React from 'react' import { cn } from '../cn' -const FileTreeLevelContext = createContext(1) +const FileTreeLevelContext = React.createContext(1) function useFileTreeLevel() { - return useContext(FileTreeLevelContext) + return React.useContext(FileTreeLevelContext) } -function getLabelText(children: ReactNode) { +function getLabelText(children: React.ReactNode) { return typeof children === 'string' || typeof children === 'number' ? String(children) : undefined @@ -201,12 +197,12 @@ export function FileTreeFile({ 'aria-current': selected ? 'true' : undefined, 'className': fileTreeRowClassName({ className }), 'children': ( - <> + {renderGuides(level)}
{children}
- +
), } as useRender.ElementProps<'button'> @@ -272,7 +268,7 @@ export type FileTreeIconProps = Omit, 'children'> & { type?: FileTreeIconType - children?: ReactNode + children?: React.ReactNode } export function FileTreeIcon({ @@ -286,18 +282,18 @@ export function FileTreeIcon({ 'aria-hidden': true, 'className': cn('relative flex size-5 shrink-0 items-center justify-center text-text-secondary', className), 'children': ( - <> + {children ?? ( type === 'folder' ? ( - <> + - + ) : )} - + ), } diff --git a/packages/dify-ui/src/form-control-shared.ts b/packages/dify-ui/src/form-control-shared.ts index d8454fce52b..288c1f7fcd2 100644 --- a/packages/dify-ui/src/form-control-shared.ts +++ b/packages/dify-ui/src/form-control-shared.ts @@ -2,12 +2,16 @@ import { cva } from 'class-variance-authority' export const formLabelClassName = 'w-fit py-1 text-text-secondary system-sm-medium data-disabled:cursor-not-allowed' +export const textControlFocusClassName = 'focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs' + +export const textControlCompoundFocusClassName = 'focus-within:border-components-input-border-active focus-within:bg-components-input-bg-active focus-within:shadow-xs' + export const textControlVariants = cva( [ 'w-full appearance-none border border-transparent bg-components-input-bg-normal text-components-input-text-filled caret-primary-600 outline-hidden transition-[background-color,border-color,box-shadow]', 'placeholder:text-components-input-text-placeholder', 'hover:border-components-input-border-hover hover:bg-components-input-bg-hover', - 'focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs', + textControlFocusClassName, 'data-invalid:border-components-input-border-destructive data-invalid:bg-components-input-bg-destructive', 'read-only:cursor-default read-only:shadow-none read-only:hover:border-transparent read-only:hover:bg-components-input-bg-normal read-only:focus:border-transparent read-only:focus:bg-components-input-bg-normal read-only:focus:shadow-none', 'disabled:cursor-not-allowed disabled:border-transparent disabled:bg-components-input-bg-disabled disabled:text-components-input-text-filled-disabled', diff --git a/packages/dify-ui/src/input/__tests__/index.spec.tsx b/packages/dify-ui/src/input/__tests__/index.spec.tsx index 599775b8ac4..8ceb8dc07c9 100644 --- a/packages/dify-ui/src/input/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/input/__tests__/index.spec.tsx @@ -1,3 +1,4 @@ +import * as React from 'react' import { render } from 'vitest-browser-react' import { FieldControl, FieldError, FieldLabel, FieldRoot } from '../../field' import { Form } from '../../form' @@ -22,7 +23,7 @@ describe('Input', () => { it('should apply size variants shared with FieldControl', async () => { const screen = await render( - <> +
- , + , ) await expect.element(screen.getByRole('textbox', { name: 'Small input' })).toHaveClass('rounded-md', 'py-[3px]', 'system-xs-regular') diff --git a/packages/dify-ui/src/internals/use-iso-layout-effect.ts b/packages/dify-ui/src/internals/use-iso-layout-effect.ts new file mode 100644 index 00000000000..44bf4fb9371 --- /dev/null +++ b/packages/dify-ui/src/internals/use-iso-layout-effect.ts @@ -0,0 +1,9 @@ +'use client' + +import * as React from 'react' + +const noop: typeof React.useLayoutEffect = () => {} + +export const useIsoLayoutEffect = typeof document !== 'undefined' + ? React.useLayoutEffect + : noop diff --git a/packages/dify-ui/src/kbd/index.tsx b/packages/dify-ui/src/kbd/index.tsx index 22b9397b2be..967a0a77667 100644 --- a/packages/dify-ui/src/kbd/index.tsx +++ b/packages/dify-ui/src/kbd/index.tsx @@ -1,7 +1,7 @@ 'use client' import type { VariantProps } from 'class-variance-authority' -import type { ComponentProps } from 'react' +import type * as React from 'react' import { cva } from 'class-variance-authority' import { cn } from '../cn' @@ -28,7 +28,7 @@ const kbdVariants = cva( export type KbdColor = NonNullable['color']> export type KbdProps - = Omit, 'color'> + = Omit, 'color'> & VariantProps export function Kbd({ @@ -46,7 +46,7 @@ export function Kbd({ ) } -export type KbdGroupProps = ComponentProps<'span'> +export type KbdGroupProps = React.ComponentProps<'span'> export function KbdGroup({ className, diff --git a/packages/dify-ui/src/number-field/__tests__/index.spec.tsx b/packages/dify-ui/src/number-field/__tests__/index.spec.tsx index 0461e5f40ec..c05b46eb900 100644 --- a/packages/dify-ui/src/number-field/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/number-field/__tests__/index.spec.tsx @@ -1,4 +1,3 @@ -import type { ReactNode } from 'react' import type { NumberFieldButtonProps, NumberFieldControlsProps, @@ -6,7 +5,12 @@ import type { NumberFieldInputProps, NumberFieldUnitProps, } from '../index' +import * as React from 'react' import { render } from 'vitest-browser-react' +import { + FieldLabel, + FieldRoot, +} from '../../field' import { NumberField, NumberFieldControls, @@ -21,7 +25,7 @@ type RenderNumberFieldOptions = { defaultValue?: number groupProps?: Partial inputProps?: Partial - unitProps?: Partial & { children?: ReactNode } + unitProps?: Partial & { children?: React.ReactNode } controlsProps?: Partial incrementProps?: Partial decrementProps?: Partial @@ -44,11 +48,7 @@ const renderNumberField = ({ return render( - + {unitProps && ( {unitChildren} @@ -75,6 +75,7 @@ describe('NumberField wrapper', () => { }) await expect.element(screen.getByTestId('group')).toHaveClass('rounded-lg') + await expect.element(screen.getByTestId('group')).toHaveClass('focus-within:border-components-input-border-active') await expect.element(screen.getByTestId('group')).toHaveClass('custom-group') }) @@ -89,8 +90,25 @@ describe('NumberField wrapper', () => { }) await expect.element(screen.getByTestId('group')).toHaveClass('rounded-[10px]') - await expect.element(screen.getByTestId('input')).toHaveClass('px-4') - await expect.element(screen.getByTestId('input')).toHaveClass('py-2') + await expect.element(screen.getByRole('textbox', { name: 'Amount' })).toHaveClass('px-4') + await expect.element(screen.getByRole('textbox', { name: 'Amount' })).toHaveClass('py-2') + }) + + it('should surface field invalid state on the visual group', async () => { + const screen = await render( + + Amount + + + + + + , + ) + + await expect.element(screen.getByTestId('group')).toHaveAttribute('data-invalid') + await expect.element(screen.getByTestId('group')).toHaveClass('data-invalid:border-components-input-border-destructive') + await expect.element(screen.getByRole('textbox', { name: 'Amount' })).toHaveAttribute('aria-invalid', 'true') }) it('should set input defaults and forward passthrough props', async () => { @@ -191,7 +209,7 @@ describe('NumberField wrapper', () => { it('should rely on aria-labelledby when provided instead of injecting a fallback aria-label', async () => { const screen = await render( - <> + Increment from label Decrement from label @@ -203,7 +221,7 @@ describe('NumberField wrapper', () => { - , + , ) await expect.element(screen.getByRole('button', { name: 'Increment from label' })).not.toHaveAttribute('aria-label') diff --git a/packages/dify-ui/src/number-field/index.stories.tsx b/packages/dify-ui/src/number-field/index.stories.tsx index b9472943e04..3d85b42c096 100644 --- a/packages/dify-ui/src/number-field/index.stories.tsx +++ b/packages/dify-ui/src/number-field/index.stories.tsx @@ -1,5 +1,13 @@ import type { Meta, StoryObj } from '@storybook/react-vite' -import { useId, useState } from 'react' +import * as React from 'react' +import { Button } from '../button' +import { + FieldDescription, + FieldError, + FieldLabel, + FieldRoot, +} from '../field' +import { Form } from '../form' import { NumberField, NumberFieldControls, @@ -8,104 +16,7 @@ import { NumberFieldIncrement, NumberFieldInput, NumberFieldUnit, -} from '.' -import { cn } from '../cn' - -type DemoFieldProps = { - label: string - helperText: string - placeholder: string - size: 'medium' | 'large' - unit?: string - defaultValue?: number | null - min?: number - max?: number - step?: number - disabled?: boolean - readOnly?: boolean - showCurrentValue?: boolean - widthClassName?: string - formatValue?: (value: number | null) => string -} - -const formatNumericValue = (value: number | null, unit?: string) => { - if (value === null) - return 'Empty' - - if (!unit) - return String(value) - - return `${value} ${unit}` -} - -const FieldLabel = ({ - inputId, - label, - helperText, -}: Pick & { inputId: string }) => ( -
- -

{helperText}

-
-) - -const DemoField = ({ - label, - helperText, - placeholder, - size, - unit, - defaultValue, - min, - max, - step, - disabled, - readOnly, - showCurrentValue, - widthClassName, - formatValue, -}: DemoFieldProps) => { - const inputId = useId() - const [value, setValue] = useState(defaultValue ?? null) - - return ( -
- - - - - {unit && {unit}} - - - - - - - {showCurrentValue && ( -

- Current value: - {' '} - {formatValue ? formatValue(value) : formatNumericValue(value, unit)} -

- )} -
- ) -} +} from './index' const meta = { title: 'Base/Form/NumberField', @@ -114,7 +25,7 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Compound numeric input built on Base UI NumberField. Stories explicitly enumerate the shipped CVA variants, then cover realistic numeric-entry cases such as decimals, empty values, range limits, read-only, and disabled states.', + component: 'Compound numeric input built on Base UI NumberField. Use it with FieldRoot for labelled, described, and validated form fields.', }, }, }, @@ -122,164 +33,317 @@ const meta = { } satisfies Meta export default meta + type Story = StoryObj -export const VariantMatrix: Story = { +type NumberFieldExampleProps = { + id: string + label: string + name: string + defaultValue?: number + min?: number + max?: number + step?: number | 'any' + placeholder?: string + unit?: string + size?: 'medium' | 'large' + disabled?: boolean + readOnly?: boolean +} + +function NumberFieldExample({ + id, + label, + name, + defaultValue, + min, + max, + step, + placeholder, + unit, + size = 'medium', + disabled, + readOnly, +}: NumberFieldExampleProps) { + return ( +
+ + + + + {unit && {unit}} + + + + + + +
+ ) +} + +export const Basic: Story = { render: () => ( -
- - - + ), +} + +export const Sizes: Story = { + render: () => ( +
+ -
), } -export const DecimalInputs: Story = { +export const States: Story = { render: () => ( -
- + + Placeholder + + + + % + + + + + + + + + Filled + + + + % + + + + + + + + + Invalid + + + + % + + + + + + + Use a value from 0 to 100. + + + Disabled + + + + + + + + + + + + Read-only + + + + % + + + + + + + +
+ ), + parameters: { + a11y: { + test: 'todo', + }, + }, +} + +function ControlledDemo() { + const [value, setValue] = React.useState(0.82) + + return ( + + Score threshold + value === null ? 'Empty' : value.toFixed(2)} - /> - value === null ? 'Empty' : value.toFixed(1)} - /> - value === null ? 'Empty' : value.toFixed(2)} - /> - value === null ? 'Empty' : `${value.toFixed(1)} s`} - /> + format={{ + maximumFractionDigits: 2, + }} + onValueChange={setValue} + > + + + + + + + + + + Current value: + {' '} + {value === null ? 'Empty' : value.toFixed(2)} + + + ) +} + +export const Controlled: Story = { + render: () => ( +
+
), } -export const BoundariesAndStates: Story = { +function FormDemo() { + const [savedValue, setSavedValue] = React.useState(null) + + return ( +
{ + setSavedValue(String(values.topK ?? '')) + }} + > + + Top K + + + + + + + + + + Choose how many chunks are returned. + Top K is required. + Use at least 1. + Use 10 or fewer. + +
+ +
+ {savedValue && ( +
+ Saved: + {' '} + {savedValue} +
+ )} +
+ ) +} + +export const WithField: Story = { + render: () => , +} + +export const Formatting: Story = { render: () => ( -
- - - - +
+ + Budget + + + + + + + + + + + + Temperature + + + + + + + + + +
), } diff --git a/packages/dify-ui/src/number-field/index.tsx b/packages/dify-ui/src/number-field/index.tsx index 794e79eb2a9..2ff6fb53c08 100644 --- a/packages/dify-ui/src/number-field/index.tsx +++ b/packages/dify-ui/src/number-field/index.tsx @@ -1,10 +1,11 @@ 'use client' import type { VariantProps } from 'class-variance-authority' -import type { HTMLAttributes } from 'react' +import type * as React from 'react' import { NumberField as BaseNumberField } from '@base-ui/react/number-field' import { cva } from 'class-variance-authority' import { cn } from '../cn' +import { textControlCompoundFocusClassName } from '../form-control-shared' export const NumberField = BaseNumberField.Root export type NumberFieldRootProps = BaseNumberField.Root.Props @@ -13,7 +14,9 @@ export const numberFieldGroupVariants = cva( [ 'group/number-field flex w-full min-w-0 items-stretch overflow-hidden border border-transparent bg-components-input-bg-normal text-components-input-text-filled shadow-none outline-hidden transition-[background-color,border-color,box-shadow]', 'hover:border-components-input-border-hover hover:bg-components-input-bg-hover', + textControlCompoundFocusClassName, 'data-focused:border-components-input-border-active data-focused:bg-components-input-bg-active data-focused:shadow-xs', + 'data-invalid:border-components-input-border-destructive data-invalid:bg-components-input-bg-destructive', 'data-disabled:cursor-not-allowed data-disabled:border-transparent data-disabled:bg-components-input-bg-disabled data-disabled:text-components-input-text-filled-disabled', 'data-disabled:hover:border-transparent data-disabled:hover:bg-components-input-bg-disabled', 'data-readonly:shadow-none data-readonly:hover:border-transparent data-readonly:hover:bg-components-input-bg-normal motion-reduce:transition-none', @@ -32,7 +35,12 @@ export const numberFieldGroupVariants = cva( ) export type NumberFieldSize = NonNullable['size']> -export type NumberFieldGroupProps = BaseNumberField.Group.Props & VariantProps +export type NumberFieldGroupProps + = Omit + & VariantProps + & { + className?: string + } export function NumberFieldGroup({ className, @@ -67,7 +75,12 @@ export const numberFieldInputVariants = cva( }, ) -export type NumberFieldInputProps = Omit & VariantProps +export type NumberFieldInputProps + = Omit + & VariantProps + & { + className?: string + } export function NumberFieldInput({ className, @@ -97,7 +110,7 @@ export const numberFieldUnitVariants = cva( }, ) -export type NumberFieldUnitProps = HTMLAttributes & VariantProps +export type NumberFieldUnitProps = React.ComponentProps<'span'> & VariantProps export function NumberFieldUnit({ className, @@ -116,7 +129,7 @@ const numberFieldControlsVariants = cva( 'flex shrink-0 flex-col items-stretch border-l border-divider-subtle bg-transparent text-text-tertiary', ) -export type NumberFieldControlsProps = HTMLAttributes +export type NumberFieldControlsProps = React.ComponentProps<'div'> export function NumberFieldControls({ className, @@ -185,7 +198,12 @@ type NumberFieldButtonVariantProps = Omit< 'direction' > -export type NumberFieldButtonProps = BaseNumberField.Increment.Props & NumberFieldButtonVariantProps +export type NumberFieldButtonProps + = Omit + & NumberFieldButtonVariantProps + & { + className?: string + } const incrementAriaLabel = 'Increment value' const decrementAriaLabel = 'Decrement value' diff --git a/packages/dify-ui/src/pagination/__tests__/index.spec.tsx b/packages/dify-ui/src/pagination/__tests__/index.spec.tsx index b52c81727e7..88bcd4bf180 100644 --- a/packages/dify-ui/src/pagination/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/pagination/__tests__/index.spec.tsx @@ -152,7 +152,11 @@ describe('Pagination primitive', () => { cancelable: true, })) - await expect.element(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' })).toBeInTheDocument() + const summaryButton = screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }) + await expect.element(summaryButton).toBeInTheDocument() + await vi.waitFor(() => { + expect(document.activeElement).toBe(summaryButton.element()) + }) }) it('cancels the page input editing mode with Escape', async () => { diff --git a/packages/dify-ui/src/pagination/index.stories.tsx b/packages/dify-ui/src/pagination/index.stories.tsx index 8b016a2ef27..6ffab6bfee9 100644 --- a/packages/dify-ui/src/pagination/index.stories.tsx +++ b/packages/dify-ui/src/pagination/index.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite' -import type { ComponentProps } from 'react' -import { useState } from 'react' +import * as React from 'react' +import { expect } from 'storybook/test' import { Pagination, PaginationSkeleton, @@ -10,18 +10,21 @@ function PaginationExample({ initialPage = 2, initialPageSize = 25, totalPages = 200, + label = 'Pagination', }: { initialPage?: number initialPageSize?: number totalPages?: number + label?: string }) { - const [page, setPage] = useState(initialPage) - const [pageSize, setPageSize] = useState(initialPageSize) + const [page, setPage] = React.useState(initialPage) + const [pageSize, setPageSize] = React.useState(initialPageSize) return ( ) { +function PaginationDemo(props: React.ComponentProps) { return (
@@ -43,10 +46,10 @@ function PaginationDemo(props: ComponentProps) { function DesignSpecDemo() { return (
- - - - + + + +
) } @@ -75,11 +78,28 @@ type Story = StoryObj export const Playground: Story = { render: () => , + play: async ({ canvas, userEvent }) => { + await expect(canvas.getByRole('button', { name: 'Edit page number, current page 2 of 200' })).toBeVisible() + + await userEvent.click(canvas.getByRole('button', { name: 'Next page' })) + await expect(canvas.getByRole('button', { name: 'Edit page number, current page 3 of 200' })).toBeVisible() + + await userEvent.click(canvas.getByRole('button', { name: '50' })) + await expect(canvas.getByRole('button', { name: '50' })).toHaveAttribute('aria-pressed', 'true') + }, + parameters: { + a11y: { + test: 'todo', + }, + }, } export const DesignSpec: Story = { render: () => , parameters: { + a11y: { + test: 'todo', + }, docs: { description: { story: 'Pagination rows with default, hover-like, focused, page-size, and skeleton examples.', diff --git a/packages/dify-ui/src/pagination/index.tsx b/packages/dify-ui/src/pagination/index.tsx index b9cd1795021..386017828cc 100644 --- a/packages/dify-ui/src/pagination/index.tsx +++ b/packages/dify-ui/src/pagination/index.tsx @@ -1,12 +1,12 @@ 'use client' import type { Button as BaseButtonNS } from '@base-ui/react/button' -import type { ReactNode } from 'react' import { Button as BaseButton } from '@base-ui/react/button' import { mergeProps } from '@base-ui/react/merge-props' import { useRender } from '@base-ui/react/use-render' -import { createContext, useContext, useMemo, useRef, useState } from 'react' +import * as React from 'react' import { cn } from '../cn' +import { useIsoLayoutEffect } from '../internals/use-iso-layout-effect' import { NumberField, NumberFieldGroup, @@ -28,10 +28,10 @@ type PaginationContextValue = { items: PageItem[] } -const PaginationContext = createContext(null) +const PaginationContext = React.createContext(null) function usePaginationContext(component: string) { - const context = useContext(PaginationContext) + const context = React.useContext(PaginationContext) if (!context) throw new Error(`${component} must be used inside PaginationRoot.`) @@ -156,7 +156,7 @@ export function PaginationRoot({ const normalizedPage = clampPage(page, normalizedTotalPages) const hasPages = normalizedTotalPages > 0 const disabled = normalizedTotalPages <= 1 - const items = useMemo(() => getPageItems({ + const items = React.useMemo(() => getPageItems({ page: normalizedPage, totalPages: normalizedTotalPages, siblingCount, @@ -170,7 +170,7 @@ export function PaginationRoot({ visiblePageCount, ]) - const context = useMemo(() => ({ + const context = React.useMemo(() => ({ page: normalizedPage, totalPages: normalizedTotalPages, hasPages, @@ -239,7 +239,7 @@ export function PaginationNavigation({ } type PaginationButtonProps = Omit & { - children?: ReactNode + children?: React.ReactNode } const paginationArrowButtonClassName = [ @@ -316,7 +316,7 @@ export function PaginationNext({ export type PaginationPageJumpProps = Omit & { inputLabel?: string - children?: ReactNode + children?: React.ReactNode } export function PaginationPageJump({ @@ -327,8 +327,26 @@ export function PaginationPageJump({ ...props }: PaginationPageJumpProps) { const pagination = usePaginationContext('PaginationPageJump') - const [editing, setEditing] = useState(false) - const summaryButtonRef = useRef(null) + const [editing, setEditing] = React.useState(false) + const summaryButtonRef = React.useRef(null) + const restoreSummaryFocusRef = React.useRef(false) + + useIsoLayoutEffect(() => { + if (editing || !restoreSummaryFocusRef.current) + return + + restoreSummaryFocusRef.current = false + + const summaryButton = summaryButtonRef.current + if (!summaryButton) + return + + const activeElement = summaryButton.ownerDocument.activeElement + if (activeElement && activeElement !== summaryButton.ownerDocument.body) + return + + summaryButton.focus({ preventScroll: true }) + }, [editing]) if (!pagination.hasPages) return null @@ -367,14 +385,15 @@ export function PaginationPageJump({ onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault() + restoreSummaryFocusRef.current = true event.currentTarget.blur() return } if (event.key === 'Escape') { event.preventDefault() + restoreSummaryFocusRef.current = true setEditing(false) - requestAnimationFrame(() => summaryButtonRef.current?.focus()) } }} /> @@ -402,11 +421,11 @@ export function PaginationPageJump({ }} > {children ?? ( - <> + {pagination.page} / {pagination.totalPages} - + )} ) @@ -444,7 +463,7 @@ export function PaginationPageList({ export type PaginationPageProps = Omit & { page: number - children?: ReactNode + children?: React.ReactNode } export function PaginationPage({ @@ -464,9 +483,8 @@ export function PaginationPage({ aria-current={current ? 'page' : undefined} aria-label={ariaLabel ?? (current ? `Page ${page}, current page` : `Go to page ${page}`)} className={cn( - 'inline-flex h-8 min-w-8 touch-manipulation items-center justify-center rounded-lg px-1 py-2 system-sm-medium tabular-nums text-text-tertiary outline-hidden transition-colors hover:bg-components-button-ghost-bg-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid', + 'inline-flex h-8 min-w-8 touch-manipulation items-center justify-center rounded-lg px-1 py-2 system-sm-medium tabular-nums text-text-tertiary outline-hidden hover:bg-components-button-ghost-bg-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid', current && 'bg-components-button-tertiary-bg text-components-button-tertiary-text hover:bg-components-button-ghost-bg-hover', - 'motion-reduce:transition-none', className, )} onClick={(event) => { @@ -505,7 +523,7 @@ export type PaginationPageSizeProps = { 'value': Value 'options': readonly Value[] 'onValueChange': (value: Value) => void - 'label'?: ReactNode + 'label'?: React.ReactNode 'aria-label'?: string 'className'?: string } @@ -563,7 +581,7 @@ export type PaginationPageSizeConfig = { value: Value options: readonly Value[] onValueChange: (value: Value) => void - label?: ReactNode + label?: React.ReactNode ariaLabel?: string } diff --git a/packages/dify-ui/src/popover/__tests__/index.spec.tsx b/packages/dify-ui/src/popover/__tests__/index.spec.tsx index ba368c9ad37..54090a1dfbd 100644 --- a/packages/dify-ui/src/popover/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/popover/__tests__/index.spec.tsx @@ -1,3 +1,4 @@ +import type * as React from 'react' import { render } from 'vitest-browser-react' import { Popover, @@ -5,7 +6,7 @@ import { PopoverTrigger, } from '..' -const renderWithSafeViewport = (ui: import('react').ReactNode) => render( +const renderWithSafeViewport = (ui: React.ReactNode) => render(
{ui}
, @@ -29,6 +30,11 @@ describe('PopoverContent', () => { await expect.element(screen.getByRole('group', { name: 'default positioner' })).toHaveAttribute('data-side', 'bottom') await expect.element(screen.getByRole('group', { name: 'default positioner' })).toHaveAttribute('data-align', 'center') await expect.element(screen.getByRole('dialog', { name: 'default popover' })).toHaveTextContent('Default content') + await expect.element(screen.getByRole('dialog', { name: 'default popover' })).toHaveClass( + 'outline-hidden', + 'focus:outline-hidden', + 'focus-visible:outline-hidden', + ) }) it('should apply parsed custom placement and custom offsets when placement props are provided', async () => { diff --git a/packages/dify-ui/src/popover/index.stories.tsx b/packages/dify-ui/src/popover/index.stories.tsx index a622fa3d214..21df9296764 100644 --- a/packages/dify-ui/src/popover/index.stories.tsx +++ b/packages/dify-ui/src/popover/index.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import type { Placement } from '.' import { Button } from '@langgenius/dify-ui/button' -import { useState } from 'react' +import * as React from 'react' import { Popover, PopoverClose, @@ -127,9 +127,9 @@ export const Infotip: Story = { closeDelay={200} aria-label="Set which resource to use first when running models." render={( - + )} /> { - const [placement, setPlacement] = useState('bottom') + const [placement, setPlacement] = React.useState('bottom') return (
@@ -208,7 +208,7 @@ export const Placements: Story = { } const ControlledDemo = () => { - const [open, setOpen] = useState(false) + const [open, setOpen] = React.useState(false) return (
diff --git a/packages/dify-ui/src/popover/index.tsx b/packages/dify-ui/src/popover/index.tsx index c29c50d1fe1..a231c3fdedf 100644 --- a/packages/dify-ui/src/popover/index.tsx +++ b/packages/dify-ui/src/popover/index.tsx @@ -1,6 +1,6 @@ 'use client' -import type { ReactNode } from 'react' +import type * as React from 'react' import type { Placement } from '../placement' import { Popover as BasePopover } from '@base-ui/react/popover' import { cn } from '../cn' @@ -16,7 +16,7 @@ export const PopoverDescription = BasePopover.Description export const createPopoverHandle = BasePopover.createHandle type PopoverContentProps = { - children: ReactNode + children: React.ReactNode placement?: Placement sideOffset?: number alignOffset?: number @@ -57,6 +57,7 @@ export function PopoverContent({ render( +const renderWithSafeViewport = (ui: React.ReactNode) => render(
{ui}
, diff --git a/packages/dify-ui/src/preview-card/index.stories.tsx b/packages/dify-ui/src/preview-card/index.stories.tsx index 540ac08c1a2..c375db3e643 100644 --- a/packages/dify-ui/src/preview-card/index.stories.tsx +++ b/packages/dify-ui/src/preview-card/index.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import type { Placement } from '.' -import { useState } from 'react' +import * as React from 'react' import { createPreviewCardHandle, PreviewCard, @@ -144,7 +144,7 @@ const PLACEMENTS: Placement[] = [ ] const PlacementsDemo = () => { - const [placement, setPlacement] = useState('bottom') + const [placement, setPlacement] = React.useState('bottom') return (
diff --git a/packages/dify-ui/src/preview-card/index.tsx b/packages/dify-ui/src/preview-card/index.tsx index f4448e477ab..a3e8b751007 100644 --- a/packages/dify-ui/src/preview-card/index.tsx +++ b/packages/dify-ui/src/preview-card/index.tsx @@ -1,6 +1,6 @@ 'use client' -import type { ReactNode } from 'react' +import type * as React from 'react' import type { Placement } from '../placement' import { PreviewCard as BasePreviewCard } from '@base-ui/react/preview-card' import { cn } from '../cn' @@ -27,7 +27,7 @@ export const PreviewCardTrigger = BasePreviewCard.Trigger export const createPreviewCardHandle = BasePreviewCard.createHandle type PreviewCardContentProps = { - children: ReactNode + children: React.ReactNode placement?: Placement sideOffset?: number alignOffset?: number diff --git a/packages/dify-ui/src/progress/index.stories.tsx b/packages/dify-ui/src/progress/index.stories.tsx index eb9a3326ba9..2ee8253ab86 100644 --- a/packages/dify-ui/src/progress/index.stories.tsx +++ b/packages/dify-ui/src/progress/index.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import type { ProgressCircleColor, ProgressCircleSize } from '.' -import { Fragment } from 'react' +import * as React from 'react' import { ProgressCircle } from '.' const colors: ProgressCircleColor[] = ['gray', 'white', 'blue', 'warning', 'error'] @@ -48,7 +48,7 @@ export const CircleMatrix: Story = {
))} {colors.map(color => ( - +
{color}
@@ -61,7 +61,7 @@ export const CircleMatrix: Story = { aria-label={`${color} ${size} progress`} /> ))} -
+ ))}
), diff --git a/packages/dify-ui/src/radio-group/__tests__/index.spec.tsx b/packages/dify-ui/src/radio-group/__tests__/index.spec.tsx index 423ce957492..dba0c1ae5e6 100644 --- a/packages/dify-ui/src/radio-group/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/radio-group/__tests__/index.spec.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import * as React from 'react' import { render } from 'vitest-browser-react' import { FieldItem, FieldLabel, FieldRoot } from '../../field' import { FieldsetLegend, FieldsetRoot } from '../../fieldset' @@ -12,7 +12,7 @@ const clickElement = (element: HTMLElement | SVGElement) => { describe('RadioGroup', () => { it('should manage a controlled single selection', async () => { function StorageDemo() { - const [value, setValue] = useState('ssd') + const [value, setValue] = React.useState('ssd') return ( diff --git a/packages/dify-ui/src/radio-group/index.stories.tsx b/packages/dify-ui/src/radio-group/index.stories.tsx index d28d9b06b00..906182918b5 100644 --- a/packages/dify-ui/src/radio-group/index.stories.tsx +++ b/packages/dify-ui/src/radio-group/index.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react-vite' -import { useState } from 'react' +import * as React from 'react' import { RadioGroup } from '.' import { FieldDescription, @@ -28,7 +28,7 @@ export default meta type Story = StoryObj function StandardFormRowsDemo() { - const [value, setValue] = useState('vector') + const [value, setValue] = React.useState('vector') return ( @@ -67,7 +67,7 @@ export const StandardFormRows: Story = { } function BooleanInlineDemo() { - const [value, setValue] = useState(true) + const [value, setValue] = React.useState(true) return ( @@ -108,7 +108,7 @@ export const BooleanInline: Story = { } function OptionCardsDemo() { - const [value, setValue] = useState('default') + const [value, setValue] = React.useState('default') return ( @@ -174,7 +174,7 @@ function DynamicFormFieldDemo() { { value: 'high_quality', label: 'High quality' }, { value: 'economy', label: 'Economy' }, ] - const [selected, setSelected] = useState('automatic') + const [selected, setSelected] = React.useState('automatic') return ( diff --git a/packages/dify-ui/src/radio/__tests__/index.spec.tsx b/packages/dify-ui/src/radio/__tests__/index.spec.tsx index ba02f8a0c4c..fc539d63480 100644 --- a/packages/dify-ui/src/radio/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/radio/__tests__/index.spec.tsx @@ -1,4 +1,4 @@ -import type { ComponentProps, ReactNode } from 'react' +import type * as React from 'react' import { render } from 'vitest-browser-react' import { FieldItem, FieldLabel, FieldRoot } from '../../field' import { FieldsetLegend, FieldsetRoot } from '../../fieldset' @@ -15,8 +15,8 @@ const clickElement = (element: HTMLElement | SVGElement) => { element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) } -type TestRadioGroupProps = ComponentProps & { - children: ReactNode +type TestRadioGroupProps = React.ComponentProps & { + children: React.ReactNode label: string name?: string } @@ -37,8 +37,8 @@ function TestRadioGroup({ ) } -type TestRadioOptionProps = ComponentProps & { - children: ReactNode +type TestRadioOptionProps = React.ComponentProps & { + children: React.ReactNode } function TestRadioOption({ diff --git a/packages/dify-ui/src/radio/index.stories.tsx b/packages/dify-ui/src/radio/index.stories.tsx index 58af1bbbc14..ebe27efdbea 100644 --- a/packages/dify-ui/src/radio/index.stories.tsx +++ b/packages/dify-ui/src/radio/index.stories.tsx @@ -1,6 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react-vite' -import type { ComponentProps } from 'react' -import { useState } from 'react' +import * as React from 'react' import { Radio, RadioSkeleton, @@ -36,8 +35,8 @@ const meta = { export default meta type Story = StoryObj -function RadioDemo(args: Partial>) { - const [value, setValue] = useState('ssd') +function RadioDemo(args: Partial>) { + const [value, setValue] = React.useState('ssd') return ( diff --git a/packages/dify-ui/src/radio/index.tsx b/packages/dify-ui/src/radio/index.tsx index bbe8066c220..84944942deb 100644 --- a/packages/dify-ui/src/radio/index.tsx +++ b/packages/dify-ui/src/radio/index.tsx @@ -1,7 +1,7 @@ 'use client' import type { Radio as BaseRadioNS } from '@base-ui/react/radio' -import type { HTMLAttributes } from 'react' +import type * as React from 'react' import { Radio as BaseRadio } from '@base-ui/react/radio' import { cn } from '../cn' @@ -87,7 +87,7 @@ export function Radio({ } export type RadioSkeletonProps - = Omit, 'className'> + = Omit, 'className'> & { className?: string } diff --git a/packages/dify-ui/src/scroll-area/__tests__/index.spec.tsx b/packages/dify-ui/src/scroll-area/__tests__/index.spec.tsx index ee0f3911382..73f2f90719f 100644 --- a/packages/dify-ui/src/scroll-area/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/scroll-area/__tests__/index.spec.tsx @@ -1,3 +1,4 @@ +import * as React from 'react' import { render } from 'vitest-browser-react' import { ScrollArea, @@ -84,7 +85,7 @@ describe('scroll-area wrapper', () => { it('should render the convenience wrapper and apply slot props', async () => { const screen = await render( - <> +

Installed apps

{ >
Scrollable content
- , +
, ) const viewport = screen.getByRole('region', { name: 'Installed apps' }) diff --git a/packages/dify-ui/src/scroll-area/index.stories.tsx b/packages/dify-ui/src/scroll-area/index.stories.tsx index bd3a1cef162..27f427eb69f 100644 --- a/packages/dify-ui/src/scroll-area/index.stories.tsx +++ b/packages/dify-ui/src/scroll-area/index.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react-vite' -import * as React from 'react' +import type * as React from 'react' import { ScrollAreaContent, ScrollAreaCorner, @@ -17,7 +17,7 @@ const meta = { layout: 'padded', docs: { description: { - component: 'Compound scroll container built on Base UI Scroll Area. The examples mirror the upstream anatomy and focus patterns while applying Dify UI tokens, panel surfaces, and scrollbar spacing.', + component: 'Compound scroll container built on Base UI Scroll Area. The examples mirror the upstream anatomy and focus patterns while applying Dify UI tokens, panel surfaces, and scrollbar spacing. Base UI ScrollArea.Content defaults to min-width: fit-content, so vertical-only regions that should truncate long content must set min-width: 0 on the content slot.', }, }, }, @@ -70,6 +70,19 @@ const articleParagraphs = [ 'A scroll area follows the same principle in an interface. The viewport owns scrolling, the content stays inside its measured width, and the scrollbar remains a visual affordance rather than a second layout system.', ] as const +const fileRows = [ + 'agent-roster-skill-detail-dialog-preview-image.png', + 'workflow-agent-binding-source-of-truth-summary.md', + 'very-long-file-name-that-should-truncate-inside-a-vertical-scroll-area-without-creating-horizontal-scroll.json', + 'runtime-output-schema.ts', + 'knowledge-retrieval-notes.md', + 'composer-draft-original-state-diffing-notes.md', + 'generated-contract-console-query-options.ts', + 'agent-v2-workflow-node-config-schema.json', + 'selected-file-highlight-behavior.spec.tsx', + 'scroll-area-content-min-width-regression.md', +] as const + const gridCells = Array.from({ length: 100 }, (_, index) => index + 1) function StorySection({ @@ -176,6 +189,39 @@ export const Vertical: Story = { ), } +export const VerticalTruncation: Story = { + render: () => ( + +
+ + + + {fileRows.map(file => ( +
+ + + {file} + +
+ ))} +
+
+ + + +
+
+
+ ), +} + export const ScrollFade: Story = { render: () => ( & { - children: ReactNode + children: React.ReactNode orientation?: 'vertical' | 'horizontal' slotClassNames?: ScrollAreaSlotClassNames label?: string diff --git a/packages/dify-ui/src/segmented-control/__tests__/index.spec.tsx b/packages/dify-ui/src/segmented-control/__tests__/index.spec.tsx index cf01712fb51..46adc5122d6 100644 --- a/packages/dify-ui/src/segmented-control/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/segmented-control/__tests__/index.spec.tsx @@ -25,7 +25,7 @@ describe('SegmentedControl wrappers', () => { await expect.element(screen.getByRole('button', { name: 'One' })).toHaveClass( 'data-pressed:bg-components-segmented-control-item-active-bg', 'data-pressed:text-text-accent-light-mode-only', - 'focus-visible:z-10', + 'focus-visible:ring-inset', ) }) diff --git a/packages/dify-ui/src/segmented-control/index.stories.tsx b/packages/dify-ui/src/segmented-control/index.stories.tsx index f03dacc2b90..ead9931898b 100644 --- a/packages/dify-ui/src/segmented-control/index.stories.tsx +++ b/packages/dify-ui/src/segmented-control/index.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react-vite' -import type { ReactNode } from 'react' +import * as React from 'react' import { SegmentedControl, SegmentedControlDivider, @@ -35,10 +35,10 @@ const Icon = () => ( ) const Item = () => ( - <> + Item - + ) function SegmentedControlExample({ @@ -93,7 +93,7 @@ function SpecPanel({ children, }: { className?: string - children: ReactNode + children: React.ReactNode }) { return (
@@ -113,6 +113,9 @@ export const DesignSpec: Story = {
), parameters: { + a11y: { + test: 'todo', + }, docs: { description: { story: 'Figma node 2473:9851: segmented control examples with text+icon and icon-only rows, with and without outer padding.', @@ -168,6 +171,9 @@ export const DataAttributeStates: Story = {
), parameters: { + a11y: { + test: 'todo', + }, docs: { description: { story: '`SegmentedControlItem` gets `data-pressed` and `data-disabled` from Base UI Toggle. Accent, neutral, and multiple-selection examples are composed through props and className.', diff --git a/packages/dify-ui/src/segmented-control/index.tsx b/packages/dify-ui/src/segmented-control/index.tsx index ad7fc01e80a..ba347b58f18 100644 --- a/packages/dify-ui/src/segmented-control/index.tsx +++ b/packages/dify-ui/src/segmented-control/index.tsx @@ -2,7 +2,7 @@ import type { Toggle as BaseToggleNS } from '@base-ui/react/toggle' import type { ToggleGroup as BaseToggleGroupNS } from '@base-ui/react/toggle-group' -import type { HTMLAttributes } from 'react' +import type * as React from 'react' import { Toggle as BaseToggle } from '@base-ui/react/toggle' import { ToggleGroup as BaseToggleGroup } from '@base-ui/react/toggle-group' import { cn } from '../cn' @@ -33,13 +33,13 @@ export function SegmentedControlItem({ }: SegmentedControlItemProps) { return ( ) } -export type SegmentedControlDividerProps = Omit, 'className'> & { +export type SegmentedControlDividerProps = Omit, 'className'> & { className?: string } diff --git a/packages/dify-ui/src/select/__tests__/index.spec.tsx b/packages/dify-ui/src/select/__tests__/index.spec.tsx index d1a89243b66..cdfeffe61bd 100644 --- a/packages/dify-ui/src/select/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/select/__tests__/index.spec.tsx @@ -1,3 +1,4 @@ +import type * as React from 'react' import { render } from 'vitest-browser-react' import { Select, @@ -13,7 +14,7 @@ import { } from '../index' const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement -const renderWithSafeViewport = (ui: import('react').ReactNode) => render( +const renderWithSafeViewport = (ui: React.ReactNode) => render(
{ui}
, diff --git a/packages/dify-ui/src/select/index.stories.tsx b/packages/dify-ui/src/select/index.stories.tsx index 62abb0048d7..75602572019 100644 --- a/packages/dify-ui/src/select/index.stories.tsx +++ b/packages/dify-ui/src/select/index.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite' -import { useState } from 'react' +import * as React from 'react' +import { expect, waitFor, within } from 'storybook/test' import { Select, SelectContent, @@ -19,6 +20,8 @@ const triggerWidth = 'w-64' const cityItems = [ { label: 'Seattle', value: 'seattle' }, { label: 'New York', value: 'new-york' }, + { label: 'Tokyo', value: 'tokyo' }, + { label: 'Paris', value: 'paris' }, ] const meta = { @@ -41,11 +44,11 @@ type Story = StoryObj export const Default: Story = { render: () => (
- - + Seattle @@ -66,6 +69,27 @@ export const Default: Story = {
), + play: async ({ canvas, canvasElement, userEvent }) => { + const trigger = canvas.getByRole('combobox', { name: 'City' }) + const body = within(canvasElement.ownerDocument.body) + + await expect(trigger).toHaveTextContent('Seattle') + + trigger.focus() + await userEvent.keyboard('{ArrowDown}') + + await waitFor(async () => { + await expect(body.getByRole('option', { name: 'Tokyo' })).toBeVisible() + }) + + await userEvent.keyboard('{ArrowDown}{ArrowDown}{Enter}') + await expect(trigger).toHaveTextContent('Tokyo') + + await userEvent.keyboard('{Escape}') + await waitFor(async () => { + await expect(body.queryByRole('listbox', { name: 'City options' })).not.toBeInTheDocument() + }) + }, } export const WithVisibleLabel: Story = { @@ -263,7 +287,7 @@ export const ReadOnly: Story = { } const ControlledDemo = () => { - const [value, setValue] = useState('balanced') + const [value, setValue] = React.useState('balanced') return (
diff --git a/packages/dify-ui/src/select/index.tsx b/packages/dify-ui/src/select/index.tsx index ddfd7cf7584..046afccf3b3 100644 --- a/packages/dify-ui/src/select/index.tsx +++ b/packages/dify-ui/src/select/index.tsx @@ -1,7 +1,7 @@ 'use client' import type { VariantProps } from 'class-variance-authority' -import type { ReactNode } from 'react' +import type * as React from 'react' import type { Placement } from '../placement' import { Select as BaseSelect } from '@base-ui/react/select' import { cva } from 'class-variance-authority' @@ -107,7 +107,7 @@ export function SelectSeparator({ } type SelectContentProps = { - children: ReactNode + children: React.ReactNode placement?: Placement sideOffset?: number alignOffset?: number diff --git a/packages/dify-ui/src/slider/index.stories.tsx b/packages/dify-ui/src/slider/index.stories.tsx index 11b22f0de34..8f9c8d7cc99 100644 --- a/packages/dify-ui/src/slider/index.stories.tsx +++ b/packages/dify-ui/src/slider/index.stories.tsx @@ -1,6 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react-vite' -import type * as React from 'react' -import { useState } from 'react' +import * as React from 'react' import { Slider, SliderControl, @@ -51,7 +50,7 @@ function SliderDemo({ defaultValue: _defaultValue, ...args }: React.ComponentProps) { - const [value, setValue] = useState(initialValue) + const [value, setValue] = React.useState(initialValue) return (
diff --git a/packages/dify-ui/src/status-dot/index.stories.tsx b/packages/dify-ui/src/status-dot/index.stories.tsx index b44deb96824..c6b6d56e27c 100644 --- a/packages/dify-ui/src/status-dot/index.stories.tsx +++ b/packages/dify-ui/src/status-dot/index.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import type { StatusDotSize, StatusDotStatus } from '.' -import { Fragment } from 'react' +import * as React from 'react' import { StatusDot, StatusDotSkeleton } from '.' const statuses: StatusDotStatus[] = ['success', 'warning', 'error', 'normal', 'disabled'] @@ -39,14 +39,14 @@ export const Matrix: Story = {
Small
Medium
{statuses.map(status => ( - +
{status}
{sizes.map(size => ( ))} -
+ ))}
), diff --git a/packages/dify-ui/src/status-dot/index.tsx b/packages/dify-ui/src/status-dot/index.tsx index 087f3da4ba0..e2ce1917e63 100644 --- a/packages/dify-ui/src/status-dot/index.tsx +++ b/packages/dify-ui/src/status-dot/index.tsx @@ -1,7 +1,7 @@ 'use client' import type { VariantProps } from 'class-variance-authority' -import type { ComponentProps } from 'react' +import type * as React from 'react' import { cva } from 'class-variance-authority' import { cn } from '../cn' @@ -49,14 +49,14 @@ export type StatusDotStatus = NonNullable export type StatusDotSize = NonNullable export type StatusDotProps - = Omit, 'children'> + = Omit, 'children'> & { status?: StatusDotStatus size?: StatusDotSize } export type StatusDotSkeletonProps - = Omit, 'children'> + = Omit, 'children'> & { size?: StatusDotSize } diff --git a/packages/dify-ui/src/styles/utilities.css b/packages/dify-ui/src/styles/utilities.css index 69b15d4c106..312d6052bd8 100644 --- a/packages/dify-ui/src/styles/utilities.css +++ b/packages/dify-ui/src/styles/utilities.css @@ -265,6 +265,12 @@ line-height: 1.2; } +@utility title-5xl-semi-bold { + font-size: 30px; + font-weight: 600; + line-height: 1.2; +} + @utility title-5xl-bold { font-size: 30px; font-weight: 700; diff --git a/packages/dify-ui/src/switch/index.stories.tsx b/packages/dify-ui/src/switch/index.stories.tsx index 2156000bbc0..40e9ca6e519 100644 --- a/packages/dify-ui/src/switch/index.stories.tsx +++ b/packages/dify-ui/src/switch/index.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite' -import type { ComponentProps } from 'react' -import { useState, useTransition } from 'react' +import * as React from 'react' +import { expect } from 'storybook/test' import { Switch, SwitchSkeleton } from '.' import { FieldDescription, @@ -47,12 +47,12 @@ const meta = { export default meta type Story = StoryObj -type SwitchDemoProps = Partial, 'checked' | 'defaultChecked' | 'onCheckedChange'>> & { +type SwitchDemoProps = Partial, 'checked' | 'defaultChecked' | 'onCheckedChange'>> & { checked?: boolean } const SwitchDemo = (args: SwitchDemoProps) => { - const [enabled, setEnabled] = useState(args.checked ?? false) + const [enabled, setEnabled] = React.useState(args.checked ?? false) return ( @@ -78,6 +78,17 @@ export const Default: Story = { checked: false, disabled: false, }, + play: async ({ canvas, userEvent }) => { + const switchControl = canvas.getByRole('switch', { name: 'Enable auto retry' }) + + await expect(switchControl).toHaveAttribute('aria-checked', 'false') + await expect(canvas.getByText('Failures require manual retry.')).toBeVisible() + + await userEvent.click(switchControl) + + await expect(switchControl).toHaveAttribute('aria-checked', 'true') + await expect(canvas.getByText('Failures will retry automatically.')).toBeVisible() + }, } export const DefaultOn: Story = { @@ -167,7 +178,7 @@ export const AllStates: Story = { } const SizeComparisonDemo = () => { - const [states, setStates] = useState({ + const [states, setStates] = React.useState({ xs: false, sm: false, md: true, @@ -209,7 +220,7 @@ export const SizeComparison: Story = { } const LoadingDemo = () => { - const [loading, setLoading] = useState(true) + const [loading, setLoading] = React.useState(true) return (
@@ -275,7 +286,7 @@ export const Loading: Story = { const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) function useMockAutoRetrySettingQuery() { - const [enabled, setEnabled] = useState(false) + const [enabled, setEnabled] = React.useState(false) return { data: { @@ -290,8 +301,8 @@ function useMockUpdateAutoRetrySettingMutation({ }: { onSuccess: (enabled: boolean) => void }) { - const [requestCount, setRequestCount] = useState(0) - const [isPending, startTransition] = useTransition() + const [requestCount, setRequestCount] = React.useState(0) + const [isPending, startTransition] = React.useTransition() const mutate = (nextValue: boolean) => { if (isPending) diff --git a/packages/dify-ui/src/switch/index.tsx b/packages/dify-ui/src/switch/index.tsx index 201417a6980..d35809ecad8 100644 --- a/packages/dify-ui/src/switch/index.tsx +++ b/packages/dify-ui/src/switch/index.tsx @@ -2,7 +2,7 @@ import type { Switch as BaseSwitchNS } from '@base-ui/react/switch' import type { VariantProps } from 'class-variance-authority' -import type { HTMLAttributes } from 'react' +import type * as React from 'react' import { Switch as BaseSwitch } from '@base-ui/react/switch' import { cva } from 'class-variance-authority' import { cn } from '../cn' @@ -134,7 +134,7 @@ const switchSkeletonVariants = cva( ) export type SwitchSkeletonProps - = HTMLAttributes + = React.ComponentProps<'div'> & VariantProps export function SwitchSkeleton({ diff --git a/packages/dify-ui/src/textarea/__tests__/index.spec.tsx b/packages/dify-ui/src/textarea/__tests__/index.spec.tsx index f8a540a540c..ad7ef848aa9 100644 --- a/packages/dify-ui/src/textarea/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/textarea/__tests__/index.spec.tsx @@ -1,4 +1,4 @@ -import type { FocusEvent } from 'react' +import type * as React from 'react' import { render } from 'vitest-browser-react' import { FieldDescription, @@ -131,7 +131,7 @@ describe('Textarea', () => { it('should route field props through Base UI Field.Control and textarea-only props to textarea', async () => { const onFormSubmit = vi.fn() - const onBlur = vi.fn((event: FocusEvent) => { + const onBlur = vi.fn((event: React.FocusEvent) => { expect(event.currentTarget.tagName).toBe('TEXTAREA') }) const screen = await render( diff --git a/packages/dify-ui/src/textarea/index.stories.tsx b/packages/dify-ui/src/textarea/index.stories.tsx index 28ddde3c44a..b7875c1ea92 100644 --- a/packages/dify-ui/src/textarea/index.stories.tsx +++ b/packages/dify-ui/src/textarea/index.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react-vite' -import { useState } from 'react' +import * as React from 'react' import { Button } from '../button' import { FieldDescription, @@ -91,7 +91,7 @@ export const States: Story = { } const FormDemo = () => { - const [savedDescription, setSavedDescription] = useState(null) + const [savedDescription, setSavedDescription] = React.useState(null) return (
{ - const [value, setValue] = useState('Summarize customer feedback into actionable product themes.') + const [value, setValue] = React.useState('Summarize customer feedback into actionable product themes.') return ( @@ -160,7 +160,7 @@ export const Controlled: Story = { const CharacterCounterDemo = () => { const maxLength = 120 - const [value, setValue] = useState('Summarize customer feedback into actionable product themes.') + const [value, setValue] = React.useState('Summarize customer feedback into actionable product themes.') return ( diff --git a/packages/dify-ui/src/textarea/index.tsx b/packages/dify-ui/src/textarea/index.tsx index bfdde26a208..7075a3bd511 100644 --- a/packages/dify-ui/src/textarea/index.tsx +++ b/packages/dify-ui/src/textarea/index.tsx @@ -2,17 +2,18 @@ import type { Field as BaseFieldNS } from '@base-ui/react/field' import type { VariantProps } from 'class-variance-authority' -import type { ComponentPropsWithRef } from 'react' +import type * as React from 'react' import { Field as BaseField } from '@base-ui/react/field' import { cva } from 'class-variance-authority' import { cn } from '../cn' +import { textControlFocusClassName } from '../form-control-shared' const textareaVariants = cva( [ 'min-h-20 w-full appearance-none overflow-auto border border-transparent bg-components-input-bg-normal text-components-input-text-filled caret-primary-600 outline-hidden transition-[background-color,border-color,box-shadow]', 'placeholder:text-components-input-text-placeholder', 'hover:border-components-input-border-hover hover:bg-components-input-bg-hover', - 'focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs', + textControlFocusClassName, 'data-invalid:border-components-input-border-destructive data-invalid:bg-components-input-bg-destructive', 'read-only:cursor-default read-only:shadow-none read-only:hover:border-transparent read-only:hover:bg-components-input-bg-normal read-only:focus:border-transparent read-only:focus:bg-components-input-bg-normal read-only:focus:shadow-none', 'disabled:cursor-not-allowed disabled:border-transparent disabled:bg-components-input-bg-disabled disabled:text-components-input-text-filled-disabled', @@ -50,7 +51,7 @@ type UncontrolledTextareaProps = { onValueChange?: TextareaOnValueChange } -type TextareaNativeProps = ComponentPropsWithRef<'textarea'> +type TextareaNativeProps = React.ComponentPropsWithRef<'textarea'> type TextareaOnlyProps = Pick type TextareaElementProps = Omit< TextareaNativeProps, diff --git a/packages/dify-ui/src/themes/dark.css b/packages/dify-ui/src/themes/dark.css index 4c005983ef1..1b24e8fb489 100644 --- a/packages/dify-ui/src/themes/dark.css +++ b/packages/dify-ui/src/themes/dark.css @@ -156,6 +156,19 @@ html[data-theme="dark"] { --color-components-main-nav-nav-button-bg-active: rgb(200 206 218 / 0.14); --color-components-main-nav-nav-button-border: rgb(255 255 255 / 0.08); --color-components-main-nav-nav-button-bg-hover: rgb(200 206 218 / 0.04); + --color-components-main-nav-glass-text-glow: #3146ff2e; + --color-components-main-nav-glass-surface-first: #0033ff14; + --color-components-main-nav-glass-surface-middle-1: #0033ff1f; + --color-components-main-nav-glass-surface-middle-2: #0033ff1a; + --color-components-main-nav-glass-surface-end: #0033ff14; + --color-components-main-nav-glass-edge-highlight-first: #fffffffa; + --color-components-main-nav-glass-edge-highlight-end: #ffffff6b; + --color-components-main-nav-glass-edge-reflection-first: #0033ff00; + --color-components-main-nav-glass-edge-reflection-middle: #0033ff99; + --color-components-main-nav-glass-edge-reflection-end: #0033ff00; + --color-components-main-nav-glass-inner-glow: #ffffff4d; + --color-components-main-nav-glass-shadow-reflection: #0033ff0a; + --color-components-main-nav-glass-shadow-reflection-glow: #ffffff00; --color-components-main-nav-nav-user-border: rgb(255 255 255 / 0.05); diff --git a/packages/dify-ui/src/themes/light.css b/packages/dify-ui/src/themes/light.css index 16e5e898ee5..3feb4afb47f 100644 --- a/packages/dify-ui/src/themes/light.css +++ b/packages/dify-ui/src/themes/light.css @@ -156,6 +156,19 @@ html[data-theme="light"] { --color-components-main-nav-nav-button-bg-active: #fcfcfd; --color-components-main-nav-nav-button-border: rgb(255 255 255 / 0.95); --color-components-main-nav-nav-button-bg-hover: rgb(16 24 40 / 0.04); + --color-components-main-nav-glass-text-glow: #3146ff2e; + --color-components-main-nav-glass-surface-first: #0033ff14; + --color-components-main-nav-glass-surface-middle-1: #0033ff1f; + --color-components-main-nav-glass-surface-middle-2: #0033ff1a; + --color-components-main-nav-glass-surface-end: #0033ff14; + --color-components-main-nav-glass-edge-highlight-first: #fffffffa; + --color-components-main-nav-glass-edge-highlight-end: #ffffff6b; + --color-components-main-nav-glass-edge-reflection-first: #0033ff00; + --color-components-main-nav-glass-edge-reflection-middle: #0033ff99; + --color-components-main-nav-glass-edge-reflection-end: #0033ff00; + --color-components-main-nav-glass-inner-glow: #ffffff4d; + --color-components-main-nav-glass-shadow-reflection: #0033ff0a; + --color-components-main-nav-glass-shadow-reflection-glow: #ffffff00; --color-components-main-nav-nav-user-border: #ffffff; diff --git a/packages/dify-ui/src/themes/theme.css b/packages/dify-ui/src/themes/theme.css index 04e02d852ce..3e35feb8eb8 100644 --- a/packages/dify-ui/src/themes/theme.css +++ b/packages/dify-ui/src/themes/theme.css @@ -163,6 +163,19 @@ --color-components-main-nav-nav-button-bg-active: var(--color-components-main-nav-nav-button-bg-active); --color-components-main-nav-nav-button-border: var(--color-components-main-nav-nav-button-border); --color-components-main-nav-nav-button-bg-hover: var(--color-components-main-nav-nav-button-bg-hover); + --color-components-main-nav-glass-text-glow: var(--color-components-main-nav-glass-text-glow); + --color-components-main-nav-glass-surface-first: var(--color-components-main-nav-glass-surface-first); + --color-components-main-nav-glass-surface-middle-1: var(--color-components-main-nav-glass-surface-middle-1); + --color-components-main-nav-glass-surface-middle-2: var(--color-components-main-nav-glass-surface-middle-2); + --color-components-main-nav-glass-surface-end: var(--color-components-main-nav-glass-surface-end); + --color-components-main-nav-glass-edge-highlight-first: var(--color-components-main-nav-glass-edge-highlight-first); + --color-components-main-nav-glass-edge-highlight-end: var(--color-components-main-nav-glass-edge-highlight-end); + --color-components-main-nav-glass-edge-reflection-first: var(--color-components-main-nav-glass-edge-reflection-first); + --color-components-main-nav-glass-edge-reflection-middle: var(--color-components-main-nav-glass-edge-reflection-middle); + --color-components-main-nav-glass-edge-reflection-end: var(--color-components-main-nav-glass-edge-reflection-end); + --color-components-main-nav-glass-inner-glow: var(--color-components-main-nav-glass-inner-glow); + --color-components-main-nav-glass-shadow-reflection: var(--color-components-main-nav-glass-shadow-reflection); + --color-components-main-nav-glass-shadow-reflection-glow: var(--color-components-main-nav-glass-shadow-reflection-glow); --color-components-main-nav-nav-user-border: var(--color-components-main-nav-nav-user-border); diff --git a/packages/dify-ui/src/toast/__tests__/index.spec.tsx b/packages/dify-ui/src/toast/__tests__/index.spec.tsx index 0b06c6e1be8..7e9227e362e 100644 --- a/packages/dify-ui/src/toast/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/toast/__tests__/index.spec.tsx @@ -69,6 +69,28 @@ describe('@langgenius/dify-ui/toast', () => { dispatchToastMouseOut(viewport) }) + it('should clamp varying-height toasts to the frontmost stack height when collapsed', async () => { + const screen = await render() + + toast.info('Long background toast', { + description: 'This longer toast intentionally spans multiple lines so it would overflow the collapsed stack without matching the frontmost toast height.', + }) + toast.success('Short front toast', { + description: 'Short message.', + }) + + await expect.element(screen.getByText('Short front toast')).toBeInTheDocument() + await expect.element(screen.getByText('Long background toast')).toBeInTheDocument() + await expect.element(screen.getByRole('region', { name: 'Notifications' })).toHaveAttribute('aria-live', 'polite') + await expect.element(screen.getByRole('dialog', { name: 'Short front toast' })).toBeInTheDocument() + await expect.element(screen.getByRole('dialog', { name: 'Long background toast' })).toBeInTheDocument() + + const longToastContent = screen.getByText('Long background toast').element().closest('[class*="transition-opacity"]') + expect(longToastContent).toHaveAttribute('data-behind') + expect(longToastContent).toHaveClass('h-full') + expect(longToastContent?.parentElement).toHaveClass('h-full') + }) + it('should render a neutral toast when called directly', async () => { const screen = await render() diff --git a/packages/dify-ui/src/toast/index.stories.tsx b/packages/dify-ui/src/toast/index.stories.tsx index 772d0c456ce..192d81c692f 100644 --- a/packages/dify-ui/src/toast/index.stories.tsx +++ b/packages/dify-ui/src/toast/index.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react-vite' -import type { ReactNode } from 'react' +import * as React from 'react' import { toast, ToastHost } from '.' const buttonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-2 text-sm text-text-secondary shadow-xs transition-colors hover:bg-state-base-hover' @@ -14,7 +14,7 @@ const ExampleCard = ({ eyebrow: string title: string description: string - children: ReactNode + children: React.ReactNode }) => { return (
@@ -117,6 +117,15 @@ const StackExamples = () => { }) } + const createVaryingHeightStack = () => { + toast.info('Long background toast', { + description: 'This longer toast intentionally spans multiple lines so the collapsed stack can be checked against the shorter frontmost toast height without panel overflow.', + }) + toast.success('Short front toast', { + description: 'Short message.', + }) + } + return ( { + ) } @@ -192,11 +204,14 @@ const PromiseExamples = () => { const ActionExamples = () => { const createActionToast = () => { - toast.warning('Project archived', { + let archivedToastId = '' + archivedToastId = toast.warning('Project archived', { description: 'You can restore it from workspace settings for the next 30 days.', + timeout: 10000, actionProps: { children: 'Undo', onClick: () => { + toast.dismiss(archivedToastId) toast.success('Project restored', { description: 'The workspace is active again.', }) @@ -233,6 +248,32 @@ const ActionExamples = () => { ) } +const DeduplicateExamples = () => { + const saveCountRef = React.useRef(0) + + const saveDraft = () => { + saveCountRef.current += 1 + toast.success('Draft saved', { + id: 'draft-save-status', + description: saveCountRef.current === 1 + ? 'Click again while this toast is visible to update the same mounted toast.' + : `Same toast updated ${saveCountRef.current} times.`, + }) + } + + return ( + + + + ) +} + const UpdateExamples = () => { const createUpdatableToast = () => { const toastId = toast.info('Import started', { @@ -272,7 +313,7 @@ const UpdateExamples = () => { const ToastDocsDemo = () => { return ( - <> +
@@ -292,11 +333,12 @@ const ToastDocsDemo = () => { +
- + ) } diff --git a/packages/dify-ui/src/toast/index.tsx b/packages/dify-ui/src/toast/index.tsx index 269e18fa658..c97ac3a0550 100644 --- a/packages/dify-ui/src/toast/index.tsx +++ b/packages/dify-ui/src/toast/index.tsx @@ -5,7 +5,7 @@ import type { ToastManagerUpdateOptions, ToastObject, } from '@base-ui/react/toast' -import type { ReactNode } from 'react' +import type * as React from 'react' import { Toast as BaseToast } from '@base-ui/react/toast' import { cn } from '../cn' @@ -64,11 +64,11 @@ type ToastHostProps = { } type ToastDismiss = (toastId?: string) => void -type ToastCall = (title: ReactNode, options?: ToastOptions) => string -type TypedToastCall = (title: ReactNode, options?: TypedToastOptions) => string +type ToastCall = (title: React.ReactNode, options?: ToastOptions) => string +type TypedToastCall = (title: React.ReactNode, options?: TypedToastOptions) => string type ToastApi = { - (title: ReactNode, options?: ToastOptions): string + (title: React.ReactNode, options?: ToastOptions): string success: TypedToastCall error: TypedToastCall warning: TypedToastCall @@ -166,12 +166,12 @@ function ToastCard({ 'after:pointer-events-auto after:absolute after:top-full after:left-0 after:h-[calc(var(--toast-gap)+1px)] after:w-full after:content-[\'\']', )} > -
+