Files
dify/api/controllers/openapi/_models.py
T
GareArc e7d6485641 refactor(openapi): drop the workspace switch op and the console-only current field
difyctl keeps its workspace pin on the device and no longer calls the
server switch, so the op had no caller. `current` on the workspace list
and detail reported the web console's workspace, which read as the one
difyctl uses.
2026-10-14 19:42:47 -07:00

1333 lines
40 KiB
Python

"""Shared response substructures for openapi endpoints."""
from __future__ import annotations
from collections.abc import Sequence
from datetime import datetime
from enum import StrEnum
from typing import Any, Final, Literal, Protocol, Self
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator, model_validator
from constants.languages import supported_language
from constants.oauth_bearer import SubjectType
from controllers.common.human_input import HumanInputFormSubmitPayload
from controllers.openapi._i18n import localized
from controllers.openapi._upload import UploadPart, UploadParts
from core.plugin.entities.plugin import PluginCategory
from core.plugin.entities.plugin_daemon import PluginInstallTaskStatus
from core.tools.entities.tool_entities import ToolProviderType
from enums import DeploymentEdition, WebAppAccessMode
from fields.workflow_run_fields import WorkflowRunPaginationResponse
from graphon.enums import BuiltinNodeTypes, WorkflowExecutionStatus
from graphon.model_runtime.entities.model_entities import ModelType
from graphon.nodes.tool.entities import ToolInputType
from graphon.variables import SegmentType
from libs.helper import EmailStr, UUIDStr, UUIDStrOrEmpty, to_timestamp, uuid_value
from models.enums import WorkflowRunTriggeredFrom
from models.model import AppMode, IconType
from services.entities.app_entities import SupportedAppType
from services.entities.dsl_entities import CheckDependenciesResult, Import, ImportMode
from services.workflow.graph_check import GraphIssue
from services.workflow.graph_diff import WorkflowDiff
# Server-side cap on `limit` query param for /openapi/v1/* list endpoints.
MAX_PAGE_LIMIT = 100
class UsageInfo(BaseModel):
prompt_tokens: int = 0
completion_tokens: int = 0
total_tokens: int = 0
class MessageMetadata(BaseModel):
usage: UsageInfo | None = None
retriever_resources: list[dict[str, Any]] = []
class Hint(BaseModel):
"""A next step: the op id to call and its ready input."""
summary: str
op: str
input: dict[str, Any] = Field(description="Ready-to-send input for `op`; unknown values are null")
form: list[dict[str, Any]] | None = Field(
default=None, description="Form fields behind the hint's input: a paused run's form inputs, or credentials"
)
class Hinted(BaseModel):
"""The one place a response carries server-built next steps; `hints` is reserved on every op input."""
hints: list[Hint] = Field(default_factory=list, description="Next steps the caller can take")
class PageQuery(BaseModel):
"""The two query parameters every list op takes; the next-page hint is built from this model."""
page: int = Field(1, ge=1)
limit: int = Field(20, ge=1, le=MAX_PAGE_LIMIT)
class PaginationEnvelope[T](Hinted):
"""The one shape every paginated list on this surface answers with."""
page: int
limit: int
total: int
has_more: bool
data: list[T]
@classmethod
def build(cls, *, page: int, limit: int, total: int, items: list[T]) -> Self:
return cls(page=page, limit=limit, total=total, has_more=page * limit < total, data=items)
@classmethod
def page_of(cls, items: list[T], *, query: PageQuery) -> Self:
"""The page `query` asks for, cut from a list the service returned whole."""
start = (query.page - 1) * query.limit
return cls.build(page=query.page, limit=query.limit, total=len(items), items=items[start : start + query.limit])
class AppListRow(BaseModel):
id: str
name: str
description: str | None = None
mode: AppMode
updated_at: str | None = None
workspace_id: str | None = None
workspace_name: str | None = None
class AppListResponse(PaginationEnvelope[AppListRow]):
pass
class PermittedExternalAppsListResponse(PaginationEnvelope[AppListRow]):
pass
class AppInfo(BaseModel):
id: str
name: str
description: str | None = None
mode: str
class AppDescribeInfo(AppInfo):
updated_at: str | None = None
service_api_enabled: bool
is_agent: bool = Field(False, description="True for agent-chat and advanced-chat apps")
class AppDescribeResponse(Hinted):
info: AppDescribeInfo | None = None
parameters: dict[str, Any] | None = Field(default=None)
input_schema: dict[str, Any] | None = Field(default=None)
class WorkflowRunData(BaseModel):
id: str
workflow_id: str
status: str
outputs: dict[str, Any] = Field(default_factory=dict)
error: str | None = None
elapsed_time: float | None = None
total_tokens: int | None = None
total_steps: int | None = None
created_at: int | None = None
finished_at: int | None = None
class AccountPayload(BaseModel):
id: str
email: str
name: str
class WorkspacePayload(BaseModel):
id: str
name: str
role: str
class DeviceTokenResponse(BaseModel):
token: str
expires_at: str
subject_type: SubjectType
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: SubjectType
subject_email: str | None = None
subject_issuer: str | None = None
account: AccountPayload | None = None
workspaces: list[WorkspacePayload] = []
default_workspace_id: str | None = None
class SessionRow(BaseModel):
id: str
prefix: str
client_id: str
device_label: str
created_at: str | None = None
last_used_at: str | None = None
expires_at: str | None = None
class SessionListResponse(PaginationEnvelope[SessionRow]):
pass
class SessionListQuery(PageQuery):
"""Pagination for GET /account/sessions. Strict (extra='forbid')."""
model_config = ConfigDict(extra="forbid")
limit: int = Field(100, ge=1, le=MAX_PAGE_LIMIT)
class RevokeResponse(BaseModel):
status: str
class WorkspaceSummaryResponse(BaseModel):
id: str
name: str
role: str
status: str
class WorkspaceListResponse(PaginationEnvelope[WorkspaceSummaryResponse]):
pass
class WorkspaceListQuery(PageQuery):
"""Strict (extra='forbid')."""
model_config = ConfigDict(extra="forbid")
class WorkspaceDetailResponse(BaseModel):
id: str
name: str
role: str
status: str
created_at: str | None = None
class DeviceFlowErrorResponse(BaseModel):
"""RFC 8628-style device-flow error. These routes keep the OAuth ``{"error": code}`` shape
instead of ``ErrorBody`` because device clients parse it."""
error: Literal[
"unsupported_client",
"slow_down",
"expired_token",
"authorization_pending",
"access_denied",
"expired_or_unknown",
"already_resolved",
"approve_in_progress",
"approval_outcome_unknown",
"state_lost",
]
class DeviceCodeResponse(BaseModel):
device_code: str
user_code: str
verification_uri: str
expires_in: int
interval: int
class DeviceLookupResponse(BaseModel):
valid: bool
expires_in_remaining: int = 0
client_id: str | None = None
class DeviceMutateResponse(BaseModel):
status: str
class DeviceApprovalContextResponse(BaseModel):
subject_email: str
subject_issuer: str
user_code: str
csrf_token: str
expires_at: datetime
class ServerVersionResponse(BaseModel):
"""Meta endpoint payload for `GET /openapi/v1/_version` — no auth required."""
version: str
edition: DeploymentEdition
class HealthResponse(BaseModel):
"""Liveness payload for `GET /openapi/v1/_health` — no auth required."""
ok: bool
def _csv_string_query_schema(schema: dict[str, Any]) -> None:
"""Re-shape a set/list field's query schema to a comma-separated string — the wire form the
handler actually accepts (`request.args` is flat + the validator splits on ','). Without this
the generated contract would type it as an array and serialize `fields[0]=…&fields[1]=…`,
which `extra='forbid'` rejects. Runtime `set[str]` validation is unaffected."""
schema.pop("anyOf", None)
schema.pop("items", None)
schema.pop("uniqueItems", None)
schema["type"] = "string"
class AppDescribeQuery(BaseModel):
"""`?fields=` allow-list for GET /apps/<id>.
Empty / omitted → all blocks. Unknown member → ValidationError → 422.
"""
model_config = ConfigDict(extra="forbid")
fields: set[str] | None = Field(default=None, json_schema_extra=_csv_string_query_schema)
@field_validator("fields", mode="before")
@classmethod
def _parse_fields(cls, v: object) -> set[str] | None:
if v is None or v == "":
return None
if not isinstance(v, str):
raise ValueError("fields must be a comma-separated string")
_ALLOWED_DESCRIBE_FIELDS = frozenset({"info", "parameters", "input_schema"})
members = {m.strip() for m in v.split(",") if m.strip()}
unknown = members - _ALLOWED_DESCRIBE_FIELDS
if unknown:
raise ValueError(f"unknown field(s): {sorted(unknown)}")
return members
class AppListQuery(PageQuery):
"""mode is a closed enum of listable app types."""
workspace_id: UUIDStr
mode: SupportedAppType | None = None
name: str | None = Field(
None,
max_length=200,
description="Filter by a name substring; an app id returns that one app and ignores `mode`",
)
class _ConversationFields(BaseModel):
conversation_id: UUIDStrOrEmpty | None = Field(default=None, description="Continue an existing conversation")
auto_generate_name: bool = Field(default=True, description="Let the server name a new conversation")
@field_validator("conversation_id", mode="before")
@classmethod
def _normalize_conv(cls, value: str | None) -> str | None:
if isinstance(value, str):
value = value.strip()
if not value:
return None
try:
return uuid_value(value)
except ValueError as exc:
raise ValueError("conversation_id must be a valid UUID") from exc
class _WorkflowVersionFields(BaseModel):
workflow_id: str | None = Field(default=None, description="Pin a published workflow version")
class RunPayloadBase(BaseModel):
"""What every run takes; each mode's payload adds its own fields and forbids the rest."""
inputs: dict[str, Any] = Field(
default_factory=dict,
description=(
"Variables declared by the app. The exact shape is per app: read `input_schema` from "
"describe.console_app. A file variable takes a Dify file mapping (remote url or upload id) here, "
"or a local path in `files`, not both."
),
)
files: UploadParts | None = Field(
default=None,
description=(
"Local file paths keyed by the app's file variable name; each file is uploaded and becomes "
"that variable's value. Give a list of paths for a file-list variable"
),
)
attachments: list[UploadPart] | None = Field(
default=None,
description="Local file paths attached to the run itself (the app's `sys.files`), not to a variable",
)
workspace_id: UUIDStrOrEmpty | None = Field(default=None, description="Workspace that owns the app")
class WorkflowRunPayload(RunPayloadBase, _WorkflowVersionFields):
model_config = ConfigDict(extra="forbid")
class DraftWorkflowRunPayload(RunPayloadBase):
model_config = ConfigDict(extra="forbid")
class ChatRunPayload(RunPayloadBase, _ConversationFields):
model_config = ConfigDict(extra="forbid")
query: str = Field(description="User message")
@field_validator("query")
@classmethod
def _non_blank(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("query must not be blank")
return value
class AdvancedChatRunPayload(ChatRunPayload, _WorkflowVersionFields):
"""A chat run against an advanced-chat (chatflow) app, which can also pin a workflow version."""
model_config = ConfigDict(extra="forbid")
class CompletionRunPayload(RunPayloadBase):
model_config = ConfigDict(extra="forbid")
query: str = Field(default="", description="Prompt text; most completion apps take their input through `inputs`")
class FileUploadPayload(BaseModel):
file: UploadPart = Field(
description="The file to upload; its id can then be used in an app run's file variables",
)
class DeviceCodeRequest(BaseModel):
client_id: str
device_label: str
class DevicePollRequest(BaseModel):
device_code: str
client_id: str
class DeviceLookupQuery(BaseModel):
user_code: str
class DeviceMutateRequest(BaseModel):
user_code: str
class PermittedExternalAppsListQuery(PageQuery):
"""Strict (extra='forbid')."""
model_config = ConfigDict(extra="forbid")
mode: SupportedAppType | None = None
name: str | None = Field(None, max_length=200)
# Closed enum for invite/update-role payloads. Owner is intentionally not
# assignable through these endpoints — ownership transfer goes through the
# console's three-step email-verification flow.
MemberAssignableRole = Literal["normal", "admin"]
class MemberResponse(BaseModel):
id: str
name: str
email: str
role: str
status: str
avatar: str | None = None
class MemberListResponse(PaginationEnvelope[MemberResponse]):
pass
class MemberListQuery(PageQuery):
"""Strict (extra='forbid')."""
model_config = ConfigDict(extra="forbid")
class MemberInvitePayload(BaseModel):
model_config = ConfigDict(extra="forbid")
email: EmailStr
role: MemberAssignableRole
class MemberRoleUpdatePayload(BaseModel):
model_config = ConfigDict(extra="forbid")
role: MemberAssignableRole
class MemberInviteResponse(BaseModel):
result: Literal["success"] = "success"
email: str
role: str
member_id: str
invite_url: str
tenant_id: str
class MemberActionResponse(BaseModel):
result: Literal["success"] = "success"
class MarketplacePluginQuery(PageQuery):
model_config = ConfigDict(extra="forbid")
query: str = Field("", description="Words to search for; empty lists the most installed plugins")
category: PluginCategory | None = Field(None, description="Only plugins of this category")
class MarketplacePluginRow(BaseModel):
plugin_id: str
identifier: str = Field(description="Latest versioned id; pass it to install.plugin")
version: str
category: str
label: str | None
brief: str | None
authorized_category: str | None = Field(
description=(
"Who vouches for the plugin: langgenius (official), partner or community; "
"workspace install-scope rules check this"
)
)
install_count: int
installed: bool
installed_version: str | None
class MarketplacePluginListResponse(PaginationEnvelope[MarketplacePluginRow]):
pass
class PluginListQuery(BaseModel):
model_config = ConfigDict(extra="forbid")
category: PluginCategory | None = Field(None, description="Only plugins of this category")
class PluginProvides(BaseModel):
model_provider: str | None = Field(description="Model provider id this plugin adds, for describe.model_provider")
tool_provider: str | None = Field(description="Tool provider id this plugin adds, for describe.tool_provider")
class PluginRow(BaseModel):
plugin_id: str
identifier: str
version: str
latest_version: str | None
category: str
label: str | None
source: str
provides: PluginProvides
class PluginListResponse(Hinted):
data: list[PluginRow]
class PluginInstallPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
identifiers: list[str] = Field(
min_length=1,
description=(
"Versioned plugin ids such as langgenius/openai:0.2.1@sha256…, from get.marketplace.plugin "
"(identifier) or check.console_app.dependency"
),
)
class PluginTaskStartResponse(Hinted):
task_id: str
all_installed: bool
class PluginTaskItem(BaseModel):
plugin_id: str
identifier: str
status: PluginInstallTaskStatus
message: str
class PluginTaskResponse(Hinted):
task_id: str
status: PluginInstallTaskStatus
plugins: list[PluginTaskItem]
class PluginDeleteResponse(BaseModel):
plugin_id: str
deleted: bool
class CheckDependenciesResponse(CheckDependenciesResult, Hinted):
pass
class TaskStopResponse(BaseModel):
"""200 body for POST /apps/<id>/tasks/<task_id>:stop. The handler always returns
{"result": "success"}, so `result` is required (no default) — the generated contract
types it as a required `'success'` rather than an optional field."""
result: Literal["success"]
class AppDslImportPayload(BaseModel):
"""Request body for POST /workspaces/<workspace_id>/apps/imports."""
model_config = ConfigDict(extra="forbid")
mode: Literal["yaml-content", "yaml-url"] = Field(..., description="Import mode: yaml-content or yaml-url")
yaml_content: str | None = Field(None, description="Inline YAML DSL string (required when mode is yaml-content)")
yaml_url: str | None = Field(None, description="Remote URL to fetch YAML from (required when mode is yaml-url)")
name: str | None = Field(None, description="Override the app name from the DSL")
description: str | None = Field(None, description="Override the app description from the DSL")
icon_type: str | None = Field(None)
icon: str | None = Field(None)
icon_background: str | None = Field(None)
app_id: str | None = Field(None, description="Existing app ID to overwrite (workflow/advanced-chat apps only)")
draft_hash: str | None = Field(
None,
description="draft_hash from the export or restore this import is based on. The import fails if the "
"draft's graph, features, environment variables or conversation variables changed since. Requires app_id",
)
_source: str = PrivateAttr()
@model_validator(mode="after")
def _validate_source_by_mode(self) -> AppDslImportPayload:
if self.draft_hash is not None and not self.app_id:
raise ValueError("draft_hash is only valid when app_id names the app to overwrite")
source = self.yaml_url if self.mode == ImportMode.YAML_URL else self.yaml_content
if not source:
raise ValueError(f"{self.mode.replace('-', '_')} is required when mode is '{self.mode}'")
self._source = source
return self
@property
def source(self) -> str:
"""The URL or the YAML text, whichever the mode names."""
return self._source
class AppDslExportQuery(BaseModel):
"""Query parameters for GET /apps/<app_id>/dsl."""
include_secret: bool = Field(False, description="Include secret values in plaintext in the exported DSL")
workflow_id: UUIDStr | None = Field(
None, description="Export a specific workflow version instead of the current draft"
)
class AppDslExportResponse(BaseModel):
"""Export DSL response."""
data: str = Field(..., description="DSL YAML string")
draft_hash: str | None = Field(
None,
description="Hash of the draft's graph, features, environment variables and conversation variables; "
"pass it to the import to refuse overwriting newer edits",
)
class AppDslImportResponse(Import, Hinted):
"""`Import` plus the server-built next step for a pending import."""
class DslCheckPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
yaml_content: str = Field(description="The DSL to check, as YAML text")
app_id: str | None = Field(None, description="Check against this existing app's mode, as an overwrite import would")
class DslIssueRow(BaseModel):
code: str
severity: str = Field(description="error: import refuses it; warning: import allows it, the release check does not")
node_id: str | None
loc: list[str | int]
message: str
@classmethod
def of(cls, issue: GraphIssue) -> DslIssueRow:
return cls(
code=issue.code,
severity=issue.code.severity,
node_id=issue.node_id,
loc=list(issue.loc),
message=issue.message,
)
class DslCheckResponse(Hinted):
valid: bool = Field(description="No error-severity issues")
issues: list[DslIssueRow]
class ReleaseCheckName(StrEnum):
DRAFT_VALID = "draft_valid"
TESTED = "tested"
class ReleaseCheckRow(BaseModel):
name: ReleaseCheckName
passed: bool
detail: str
class ReleaseState(BaseModel):
service_api_enabled: bool
webapp_enabled: bool
class ReleaseCheckResponse(Hinted):
ready: bool = Field(description="Every check passed; show the human `changes` and ask before publishing")
checks: list[ReleaseCheckRow]
issues: list[DslIssueRow]
state: ReleaseState
changes: WorkflowDiff
class FormSubmitResponse(Hinted):
"""200 body for POST /apps/<id>/human-input-forms/<token>:submit: the hint to follow the
resumed run. `extra='forbid'` pins `additionalProperties: false` so the generated contract
is exact rather than an under-annotated open object."""
model_config = ConfigDict(extra="forbid")
class OpenApiFormSubmitPayload(HumanInputFormSubmitPayload):
"""The console payload plus local file parts; `_files.merge_files` sets them on `inputs`."""
files: UploadParts | None = Field(
default=None,
description="Local file paths keyed by the form's file input name, same convention as the run ops' `files`",
)
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
class RunListQuery(BaseModel):
last_id: UUIDStr | None = Field(None, description="Cursor: id of the last run on the previous page")
limit: int = Field(20, ge=1, le=MAX_PAGE_LIMIT)
status: (
Literal[
WorkflowExecutionStatus.RUNNING,
WorkflowExecutionStatus.SUCCEEDED,
WorkflowExecutionStatus.FAILED,
WorkflowExecutionStatus.STOPPED,
WorkflowExecutionStatus.PARTIAL_SUCCEEDED,
]
| None
) = None
triggered_from: Literal[WorkflowRunTriggeredFrom.DEBUGGING, WorkflowRunTriggeredFrom.APP_RUN] = Field(
WorkflowRunTriggeredFrom.DEBUGGING,
description="debugging: draft test runs, as in the console; app-run: real use",
)
class RunListResponse(WorkflowRunPaginationResponse, Hinted):
"""Cursor page of runs; `hints` carries the next page."""
class PublishPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
marked_name: str = Field("", max_length=20, description="Version name")
marked_comment: str = Field("", max_length=100, description="Version note")
class PublishResponse(BaseModel):
version_id: str
created_at: int
warning: str | None = Field(None, description="Variable references that may read a skipped branch")
class VersionListQuery(PageQuery):
named_only: bool = Field(False, description="Only versions that have a name")
class VersionRow(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
marked_name: str
marked_comment: str
created_by: str | None = None
created_at: int
current: bool = False
@field_validator("created_at", mode="before")
@classmethod
def _timestamp(cls, value: datetime | int) -> int | None:
return to_timestamp(value)
class VersionListResponse(Hinted):
"""Page of published versions, newest first; there is no total, `hints` carries the next page."""
page: int
limit: int
has_more: bool
data: list[VersionRow]
class EnvVariableRow(BaseModel):
id: str = Field(description="What set and delete take to address this variable")
name: str = Field(description="What nodes use to refer to this variable")
description: str = ""
value_type: str
value: Any = Field(description="The value; a secret with a value is masked, an empty one reads as empty")
class EnvVariableListResponse(BaseModel):
data: list[EnvVariableRow]
class EnvVariableValueType(StrEnum):
"""Value types the draft environment-variable ``set`` op accepts.
A curated subset of ``SegmentType``: what the console's environment-variable editor
allows (``ENVIRONMENT_VARIABLE_SUPPORTED_TYPES`` in controllers/console/app/workflow.py).
Members reference ``SegmentType.*.value`` so the subset relationship is type-checked.
"""
STRING = SegmentType.STRING.value
NUMBER = SegmentType.NUMBER.value
SECRET = SegmentType.SECRET.value
class EnvVariableSetPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(description="Variable name")
value_type: EnvVariableValueType = Field(description="string, number or secret")
value: Any = Field(description="The value; sending the masked value of an existing secret keeps the stored one")
description: str = Field("", description="What the variable is for")
class RestoreResponse(BaseModel):
result: Literal["success"]
draft_hash: str = Field(
description="Hash of the restored draft's graph, features, environment variables and conversation "
"variables; pass it to a DSL import as draft_hash"
)
class NodeTypeRow(BaseModel):
type: str
version: str
class NodeTypeListResponse(BaseModel):
data: list[NodeTypeRow]
class NodeTypeDetailResponse(BaseModel):
model_config = ConfigDict(populate_by_name=True)
type: str
version: str
schema_: dict[str, Any] = Field(alias="schema")
default_config: dict[str, Any]
AppIconType = Literal[IconType.EMOJI, IconType.IMAGE, IconType.LINK]
class CreateAppPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, description="App name")
description: str | None = Field(default=None, max_length=400, description="App description")
icon_type: AppIconType | None = Field(default=None, description="emoji, image or link")
icon: str | None = Field(default=None, description="Emoji, file id or URL, per icon_type")
icon_background: str | None = Field(default=None, description="Background colour for an emoji icon")
class CreatedAppResponse(BaseModel):
app_id: str
mode: str
name: str
class AppSettingsInfo(BaseModel):
name: str
description: str | None = None
icon_type: str | None = None
icon: str | None = None
icon_background: str | None = None
max_active_requests: int | None = None
class ChatAppInfo(AppSettingsInfo):
use_icon_as_answer_icon: bool = False
class AgentAppInfo(ChatAppInfo):
role: str | None = None
class AppInfoPatch(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str | None = Field(default=None, min_length=1, description="App name")
description: str | None = Field(default=None, max_length=400, description="Pass an empty string to clear")
icon_type: AppIconType | None = Field(default=None, description="emoji, image or link")
icon: str | None = Field(default=None, description="Emoji, file id or URL, per icon_type")
icon_background: str | None = Field(default=None, description="Background colour for an emoji icon")
max_active_requests: int | None = Field(
default=None,
ge=0,
description="Concurrent run cap; 0 uses the server default. The server-wide cap still applies",
)
class ChatAppInfoPatch(AppInfoPatch):
use_icon_as_answer_icon: bool | None = Field(default=None, description="Show the app icon on answers")
class AgentAppInfoPatch(ChatAppInfoPatch):
role: str | None = Field(default=None, max_length=255, description="The agent's role; empty string clears it")
class ServiceApi(BaseModel):
enabled: bool
base_url: str
class AgentServiceApi(ServiceApi):
access_ready: bool
api_rpm: int = 0
api_rph: int = 0
class ServiceApiPatch(BaseModel):
model_config = ConfigDict(extra="forbid")
enabled: bool = Field(description="Turn the app's Service API on or off")
class NodeRunPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
inputs: dict[str, Any] = Field(
default_factory=dict,
description="Overrides for what the last draft run saved, keyed by variable reference such as #llm.text#",
)
files: UploadParts | None = Field(
default=None,
description=(
"Local file paths keyed by the file variable name (a start variable or #node.var#); each is uploaded"
),
)
class AdvancedChatNodeRunPayload(NodeRunPayload):
query: str = Field(default="", description="The user message the node sees as sys.query")
class WebApp(BaseModel):
enabled: bool
access_token: str | None = None
app_base_url: str
url: str | None = None
title: str | None = None
description: str | None = None
icon_type: str | None = None
icon: str | None = None
icon_background: str | None = None
default_language: str | None = None
copyright: str | None = None
privacy_policy: str | None = None
custom_disclaimer: str | None = None
class WorkflowWebApp(WebApp):
show_workflow_steps: bool = False
class ChatWebApp(WebApp):
chat_color_theme: str | None = None
chat_color_theme_inverted: bool = False
use_icon_as_answer_icon: bool = False
input_placeholder: str | None = None
class AdvancedChatWebApp(ChatWebApp):
show_workflow_steps: bool = False
class AgentWebApp(ChatWebApp):
access_ready: bool
class WebAppPatch(BaseModel):
model_config = ConfigDict(extra="forbid")
enabled: bool | None = Field(default=None, description="Turn the web app on or off")
title: str | None = Field(default=None, description="Page title")
description: str | None = Field(default=None, description="Page description")
icon_type: AppIconType | None = Field(default=None, description="emoji, image or link")
icon: str | None = Field(default=None, description="Emoji, file id or URL, per icon_type")
icon_background: str | None = Field(default=None, description="Background colour for an emoji icon")
default_language: str | None = Field(default=None, description="Language code, e.g. en-US")
copyright: str | None = Field(default=None, description="Footer copyright text")
privacy_policy: str | None = Field(default=None, description="Privacy policy URL")
custom_disclaimer: str | None = Field(default=None, description="Disclaimer shown on the page")
@field_validator("default_language")
@classmethod
def _language(cls, value: str | None) -> str | None:
return value if value is None else supported_language(value)
class WorkflowWebAppPatch(WebAppPatch):
show_workflow_steps: bool | None = Field(default=None, description="Show each node step to users")
class ChatWebAppPatch(WebAppPatch):
chat_color_theme: str | None = Field(default=None, description="Chat colour, e.g. #1C64F2")
chat_color_theme_inverted: bool | None = Field(default=None, description="Invert the chat colours")
use_icon_as_answer_icon: bool | None = Field(default=None, description="Show the app icon on answers")
input_placeholder: str | None = Field(default=None, description="Placeholder of the chat input")
class AdvancedChatWebAppPatch(ChatWebAppPatch):
show_workflow_steps: bool | None = Field(default=None, description="Show each node step to users")
class WebAppToken(BaseModel):
access_token: str | None = None
app_base_url: str
url: str | None = None
class WebAppAccess(BaseModel):
access_mode: str
class WebAppAccessPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
access_mode: WebAppAccessMode = Field(
description="public, private_all or sso_verified. Choose specific members (private) in the Dify console"
)
class _FormOption(Protocol):
value: str
class _FormShowOn(Protocol):
variable: str
value: str
class CredentialFormField(BaseModel):
name: str
type: str
required: bool
label: str | None
placeholder: str | None
options: list[str] | None = Field(description="Allowed values, when the field is a choice")
show_on: list[dict[str, str]] = Field(description="Show this field only when these other fields have these values")
@classmethod
def of(
cls,
*,
name: str,
type: object,
required: bool,
label: BaseModel | None,
placeholder: BaseModel | None,
options: Sequence[_FormOption] | None,
show_on: Sequence[_FormShowOn] = (),
language: str | None,
) -> CredentialFormField:
"""A model or tool provider's credential form field, cut to what a caller needs."""
return cls(
name=name,
type=str(type),
required=required,
label=localized(label, language),
placeholder=localized(placeholder, language),
options=[option.value for option in options] if options else None,
show_on=[{"variable": rule.variable, "value": rule.value} for rule in show_on],
)
class CredentialRef(BaseModel):
id: str
name: str | None
class ModelProviderRow(BaseModel):
provider: str = Field(description="Provider id such as langgenius/openai/openai")
label: str | None
model_types: list[str]
configured: bool
active_credential: CredentialRef | None
class CustomModelRow(BaseModel):
model: str
model_type: str
active_credential: CredentialRef | None
class ModelProviderDetailResponse(ModelProviderRow, Hinted):
credential_form: list[CredentialFormField]
credentials: list[CredentialRef]
custom_model_form: list[CredentialFormField] | None
custom_models: list[CustomModelRow]
_CREDENTIALS_DESCRIPTION: Final = "Secret values, keyed by the field names in credential_form of the describe op."
_CREDENTIALS_UPDATE_DESCRIPTION: Final = (
_CREDENTIALS_DESCRIPTION + " Send [__HIDDEN__] for a secret you keep unchanged."
)
class ProviderCredentialCreatePayload(BaseModel):
model_config = ConfigDict(extra="forbid")
credentials: dict[str, Any] = Field(description=_CREDENTIALS_DESCRIPTION)
name: str | None = Field(None, description="Credential name; the server makes one when absent")
class ProviderCredentialUpdatePayload(BaseModel):
model_config = ConfigDict(extra="forbid")
credentials: dict[str, Any] = Field(description=_CREDENTIALS_UPDATE_DESCRIPTION)
name: str | None = None
class CredentialWriteResponse(Hinted):
id: str
name: str | None
active: bool
class ToolProviderRow(BaseModel):
provider: str = Field(description="Tool provider id such as langgenius/tavily/tavily")
label: str | None
configured: bool = Field(description="Ready to use: needs no credential, or the workspace has one")
credential_types: list[str] = Field(description="api-key can be set here; oauth2 needs the console")
class ToolProviderDetailResponse(ToolProviderRow, Hinted):
credential_form: list[CredentialFormField] = Field(description="Fields of an api-key credential")
credentials: list[CredentialRef]
default_credential: CredentialRef | None
class ToolCredentialCreatePayload(BaseModel):
model_config = ConfigDict(extra="forbid")
credentials: dict[str, Any] = Field(description=_CREDENTIALS_DESCRIPTION)
name: str | None = Field(None, max_length=30)
class ToolCredentialUpdatePayload(BaseModel):
model_config = ConfigDict(extra="forbid")
credentials: dict[str, Any] = Field(description=_CREDENTIALS_UPDATE_DESCRIPTION)
name: str | None = Field(None, max_length=30)
class ToolSource(StrEnum):
"""Tool provider types a workflow tool node can call; values match the node's provider_type."""
BUILTIN = ToolProviderType.BUILT_IN.value
WORKFLOW = ToolProviderType.WORKFLOW.value
API = ToolProviderType.API.value
MCP = ToolProviderType.MCP.value
class ToolListQuery(PageQuery):
model_config = ConfigDict(extra="forbid")
query: str = Field("", description="Words to match in the tool name or label, or the provider id or label")
provider_type: ToolSource | None = Field(None, description="Only tools of this provider type")
provider: str | None = Field(None, description="Only this provider's tools")
class ToolInputValue(BaseModel):
type: ToolInputType = ToolInputType.CONSTANT
value: Any = None
class ToolParameterRow(BaseModel):
name: str
label: str | None
type: str
form: str = Field(description="llm: goes in tool_parameters; form: goes in tool_configurations; schema: fixed")
required: bool
default: Any
options: list[str]
min: float | None
max: float | None
llm_description: str | None
TOOL_NODE_VERSION: Final = "2"
class ToolNodeTemplate(BaseModel):
"""A tool node's data with every value as {type, value}."""
type: str = BuiltinNodeTypes.TOOL
title: str
provider_type: str
provider_id: str
provider_name: str
plugin_id: str | None
plugin_unique_identifier: str | None
tool_name: str
tool_label: str
tool_node_version: str = TOOL_NODE_VERSION
tool_parameters: dict[str, ToolInputValue]
tool_configurations: dict[str, ToolInputValue]
class ToolRow(BaseModel):
provider: str
provider_type: str
provider_label: str | None
configured: bool = Field(
description=(
"Ready to use: needs no credential, or the workspace has one. MCP: authorize or refresh in the console"
)
)
name: str
label: str
description: str | None
parameters: list[ToolParameterRow]
node_data: ToolNodeTemplate = Field(
description=(
"Use as the tool node's data; fill every null value; pass upstream values as "
'{type: mixed, value: "{{#node.var#}}"} or {type: variable, value: [node, var]}'
)
)
class ToolListResponse(PaginationEnvelope[ToolRow]):
pass
class ModelListQuery(PageQuery):
model_config = ConfigDict(extra="forbid")
provider: str | None = Field(None, description="Only this provider's models, such as langgenius/openai/openai")
model_type: ModelType | None = Field(None, description="Only models of this type")
query: str = Field("", description="Words to match in the model name, label or provider")
class LlmModelBlock(BaseModel):
provider: str
name: str
mode: str
completion_params: dict[str, Any] = Field(default_factory=dict)
class ModelRow(BaseModel):
provider: str
provider_label: str | None
model: str
model_type: str
label: str | None
status: str = Field(description="active means usable now; anything else needs the provider set up")
features: list[str]
node_model: LlmModelBlock | None = Field(None, description="LLM rows: paste as the node's model")
class ModelListResponse(PaginationEnvelope[ModelRow]):
pass
KNOWLEDGE_TOP_K: Final = 4
class MultipleRetrievalFragment(BaseModel):
top_k: int = KNOWLEDGE_TOP_K
reranking_enable: bool = False
class KnowledgeRetrievalFragment(BaseModel):
dataset_ids: list[str]
retrieval_mode: Literal["multiple"] = "multiple"
multiple_retrieval_config: MultipleRetrievalFragment = Field(default_factory=MultipleRetrievalFragment)
class KnowledgeBaseListQuery(PageQuery):
model_config = ConfigDict(extra="forbid")
query: str = Field("", description="Words to match in the knowledge base name")
class KnowledgeBaseRow(BaseModel):
id: str
name: str
description: str | None
provider: str = Field(description="vendor: indexed in Dify; external: an external knowledge API")
indexing_technique: str | None
document_count: int
usable: bool = Field(description="false when its embedding model isn't available in this workspace")
node_data: KnowledgeRetrievalFragment = Field(
description=(
"Merge into a knowledge-retrieval node's data and set query_variable_selector; "
"to search several bases, join their dataset_ids. For rerank or weights read describe node_type"
)
)
class KnowledgeBaseListResponse(PaginationEnvelope[KnowledgeBaseRow]):
pass
class ModelRef(BaseModel):
model_config = ConfigDict(extra="forbid")
model: str = Field(description="Model name, as in get.model")
model_type: ModelType
class ModelCredentialCreatePayload(ModelRef):
credentials: dict[str, Any] = Field(description=_CREDENTIALS_DESCRIPTION)
name: str | None = None
class ModelCredentialUpdatePayload(ModelRef):
credentials: dict[str, Any] = Field(description=_CREDENTIALS_UPDATE_DESCRIPTION)
name: str | None = None