diff --git a/.github/workflows/backport_release.yaml b/.github/workflows/backport_release.yaml new file mode 100644 index 000000000..ede6bde33 --- /dev/null +++ b/.github/workflows/backport_release.yaml @@ -0,0 +1,519 @@ +name: Backport Release + +on: + workflow_dispatch: + inputs: + commit: + description: 'Full 40-char SHA of the tip commit of the backport source branch (the PR head commit that passed tests). The branch is resolved from this SHA and must be unique.' + required: true + type: string + +permissions: + contents: read + pull-requests: read + checks: read + +jobs: + backport-release: + name: Create backport release + runs-on: ubuntu-latest + environment: backport release + + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 + with: + app-id: ${{ secrets.FEN_RELEASE_APP_ID }} + private-key: ${{ secrets.FEN_RELEASE_PRIVATE_KEY }} + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + token: ${{ steps.app-token.outputs.token }} + fetch-depth: 0 + fetch-tags: true + + - name: Configure git + run: | + git config user.name "fen-release[bot]" + git config user.email "fen-release[bot]@users.noreply.github.com" + + - name: Resolve source branch from commit SHA + id: resolve + env: + SOURCE_COMMIT: ${{ inputs.commit }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + + # Require a full 40-char lowercase-hex SHA. Short SHAs are ambiguous + # and we will be comparing this value against API responses (PR head + # SHA, ref tips) that always return the full form. + if [[ ! "${SOURCE_COMMIT}" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::Input commit '${SOURCE_COMMIT}' is not a full 40-char lowercase hex SHA." + exit 1 + fi + + # Fetch all remote branches so we can search for which one(s) point + # at this SHA. `actions/checkout` with fetch-depth: 0 fetches full + # history of the checked-out ref but does not necessarily populate + # every refs/remotes/origin/*, so do it explicitly. + git fetch --prune origin '+refs/heads/*:refs/remotes/origin/*' + + # Verify the commit actually exists in this repo's object DB. + if ! git cat-file -e "${SOURCE_COMMIT}^{commit}" 2>/dev/null; then + echo "::error::Commit ${SOURCE_COMMIT} was not found in the repository." + exit 1 + fi + + # Find every remote branch whose tip == SOURCE_COMMIT. Exactly one + # branch must point at it. If zero, the commit isn't anyone's tip + # (likely stale, force-pushed past, or never the PR head). If more + # than one, the (branch -> SHA) mapping is ambiguous and we refuse + # to guess — the operator must give us a unique branch to release. + mapfile -t matching_branches < <( + git for-each-ref \ + --format='%(refname:strip=3)' \ + --points-at="${SOURCE_COMMIT}" \ + refs/remotes/origin/ \ + | grep -vx 'HEAD' || true + ) + + if [[ "${#matching_branches[@]}" -eq 0 ]]; then + echo "::error::No branch on origin has ${SOURCE_COMMIT} as its tip." + echo "::error::Either the branch was updated after you copied this SHA, or this commit was never the head of a branch." + exit 1 + fi + + if [[ "${#matching_branches[@]}" -gt 1 ]]; then + echo "::error::More than one branch on origin has ${SOURCE_COMMIT} as its tip; cannot pick one:" + for b in "${matching_branches[@]}"; do + echo "::error:: - ${b}" + done + echo "::error::Refusing to proceed with an ambiguous source branch." + exit 1 + fi + + source_branch="${matching_branches[0]}" + + if [[ "${source_branch}" == "${DEFAULT_BRANCH}" ]]; then + echo "::error::Source branch must not be the default branch ('${DEFAULT_BRANCH}')." + exit 1 + fi + + echo "Resolved commit ${SOURCE_COMMIT} to branch '${source_branch}'." + echo "source_branch=${source_branch}" >> "$GITHUB_OUTPUT" + + - name: Determine latest stable release + id: latest + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + set -euo pipefail + + # List all tags matching vMAJOR.MINOR.PATCH and pick the highest by numeric + # comparison of each component. We DO NOT use `sort -V` because it treats + # v0.19.99 as higher than v0.20.1. + latest_tag="$( + git tag --list 'v[0-9]*.[0-9]*.[0-9]*' \ + | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \ + | awk -F'[v.]' '{ printf "%010d %010d %010d %s\n", $2, $3, $4, $0 }' \ + | sort -k1,1n -k2,2n -k3,3n \ + | tail -n1 \ + | awk '{print $4}' + )" + + if [[ -z "${latest_tag}" ]]; then + echo "::error::No stable release tags (vMAJOR.MINOR.PATCH) were found." + exit 1 + fi + + # Parse components + ver="${latest_tag#v}" + major="${ver%%.*}" + rest="${ver#*.}" + minor="${rest%%.*}" + patch="${rest#*.}" + + new_patch=$((patch + 1)) + new_version="v${major}.${minor}.${new_patch}" + release_branch="release/v${major}.${minor}" + + latest_sha="$(git rev-list -n 1 "refs/tags/${latest_tag}")" + + echo "latest_tag=${latest_tag}" >> "$GITHUB_OUTPUT" + echo "latest_sha=${latest_sha}" >> "$GITHUB_OUTPUT" + echo "major=${major}" >> "$GITHUB_OUTPUT" + echo "minor=${minor}" >> "$GITHUB_OUTPUT" + echo "patch=${patch}" >> "$GITHUB_OUTPUT" + echo "new_version=${new_version}" >> "$GITHUB_OUTPUT" + echo "new_version_no_v=${major}.${minor}.${new_patch}" >> "$GITHUB_OUTPUT" + echo "release_branch=${release_branch}" >> "$GITHUB_OUTPUT" + + echo "Latest stable release: ${latest_tag} (${latest_sha})" + echo "New version will be: ${new_version}" + echo "Release branch: ${release_branch}" + + - name: Validate source branch is cut directly from the latest stable release + env: + SOURCE_BRANCH: ${{ steps.resolve.outputs.source_branch }} + SOURCE_COMMIT: ${{ inputs.commit }} + LATEST_TAG_SHA: ${{ steps.latest.outputs.latest_sha }} + LATEST_TAG: ${{ steps.latest.outputs.latest_tag }} + run: | + set -euo pipefail + + # Use the user-provided SHA directly rather than re-resolving the branch + # tip — the resolve step already proved the branch tip equals SOURCE_COMMIT, + # and pinning to the SHA here makes the rest of the job TOCTOU-safe against + # someone pushing to the branch mid-run. + source_sha="${SOURCE_COMMIT}" + + # Walking first-parent from the source tip must reach LATEST_TAG_SHA. + # We capture rev-list into a variable and grep against a here-string + # rather than piping `rev-list | grep -q`: under `set -o pipefail`, + # `grep -q` would exit on first match and SIGPIPE the still-streaming + # `rev-list`, propagating exit 141 as a spurious "not found". + first_parent_chain="$(git rev-list --first-parent "${source_sha}")" + if ! grep -Fxq "${LATEST_TAG_SHA}" <<< "${first_parent_chain}"; then + echo "::error::Source branch '${SOURCE_BRANCH}' is not cut from '${LATEST_TAG}'." + echo "::error::Its first-parent history does not include ${LATEST_TAG_SHA}." + exit 1 + fi + + # Additionally, every commit added on top of the tag (the set we are + # about to publish) must itself be a descendant of the tag along + # first-parent — i.e. no sibling commits from master sneak in via a + # non-first-parent path. Enforce by requiring that the symmetric + # difference is empty in one direction: commits in source that are + # NOT first-parent-reachable from source starting at the tag. + # We do this by intersecting: + # A = commits reachable from source but not from tag (full DAG) + # B = commits on the first-parent chain from source down to tag + # and requiring A == B. + all_added="$(git rev-list "${LATEST_TAG_SHA}..${source_sha}" | sort)" + first_parent_added="$( + git rev-list --first-parent "${LATEST_TAG_SHA}..${source_sha}" | sort + )" + + if [[ "${all_added}" != "${first_parent_added}" ]]; then + echo "::error::Source branch '${SOURCE_BRANCH}' contains commits not on its first-parent chain from '${LATEST_TAG}'." + echo "::error::This usually means the branch was cut from master (not from the tag) or contains a merge from master." + echo "Commits reachable but not on first-parent chain:" + comm -23 <(printf '%s\n' "${all_added}") <(printf '%s\n' "${first_parent_added}") \ + | while read -r sha; do + echo " $(git log -1 --format='%h %s' "${sha}")" + done + exit 1 + fi + + added_count="$(printf '%s\n' "${all_added}" | grep -c . || true)" + echo "Source branch is cut directly from ${LATEST_TAG} with ${added_count} commit(s) on top." + + - name: Validate PR exists, is open, named correctly, has latest commit, and checks pass + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + SOURCE_BRANCH: ${{ steps.resolve.outputs.source_branch }} + SOURCE_COMMIT: ${{ inputs.commit }} + NEW_VERSION: ${{ steps.latest.outputs.new_version }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + + expected_title="ComfyUI backport release ${NEW_VERSION}" + + # Find open PRs from this branch into master. The --state open filter + # is load-bearing: a closed/merged PR with passing checks must not be + # accepted as authorization for a new release. + pr_json="$( + gh pr list \ + --repo "${REPO}" \ + --state open \ + --head "${SOURCE_BRANCH}" \ + --base master \ + --json number,title,headRefOid,state \ + --limit 10 + )" + + pr_count="$(echo "${pr_json}" | jq 'length')" + if [[ "${pr_count}" -eq 0 ]]; then + echo "::error::No open PR found from '${SOURCE_BRANCH}' into 'master'. The PR must exist and be open." + exit 1 + fi + + # Pick the PR matching the expected title + pr_number="$(echo "${pr_json}" | jq -r --arg t "${expected_title}" ' + map(select(.title == $t)) | .[0].number // empty + ')" + pr_head_sha="$(echo "${pr_json}" | jq -r --arg t "${expected_title}" ' + map(select(.title == $t)) | .[0].headRefOid // empty + ')" + + if [[ -z "${pr_number}" ]]; then + echo "::error::No open PR from '${SOURCE_BRANCH}' into 'master' is titled '${expected_title}'." + echo "Found PRs:" + echo "${pr_json}" | jq -r '.[] | " #\(.number): \(.title)"' + exit 1 + fi + + # The PR's current head commit must equal the SHA the operator gave us. + # This is what closes the door on releasing stale code: if anyone has + # pushed to the branch since the operator validated tests passed, the + # PR head will have advanced past SOURCE_COMMIT and we abort. (The + # resolve step already proved the branch tip == SOURCE_COMMIT; this + # ties that same SHA to the PR that authorizes the release.) + if [[ "${pr_head_sha}" != "${SOURCE_COMMIT}" ]]; then + echo "::error::PR #${pr_number} head commit is ${pr_head_sha}, but the operator-provided commit is ${SOURCE_COMMIT}." + echo "::error::The PR has new commits since this release was authorized. Re-run with the new head SHA after verifying its checks." + exit 1 + fi + + echo "Found open PR #${pr_number} titled '${expected_title}' at head ${pr_head_sha} (matches operator-provided commit)." + + # Verify all check runs on the head commit have completed successfully. + # A check is considered passing if conclusion is success, neutral, or skipped. + checks_json="$( + gh api \ + --paginate \ + "repos/${REPO}/commits/${pr_head_sha}/check-runs" \ + --jq '.check_runs[] | {name: .name, status: .status, conclusion: .conclusion}' + )" + + if [[ -z "${checks_json}" ]]; then + echo "::error::No check runs found on PR head commit ${pr_head_sha}." + exit 1 + fi + + echo "Check runs on ${pr_head_sha}:" + echo "${checks_json}" | jq -s '.' + + failing="$(echo "${checks_json}" | jq -s ' + map(select( + .status != "completed" + or (.conclusion as $c + | ["success","neutral","skipped"] + | index($c) | not) + )) + ')" + + failing_count="$(echo "${failing}" | jq 'length')" + if [[ "${failing_count}" -gt 0 ]]; then + echo "::error::One or more checks have not passed on PR head commit ${pr_head_sha}:" + echo "${failing}" | jq -r '.[] | " - \(.name): status=\(.status) conclusion=\(.conclusion)"' + exit 1 + fi + + echo "All checks have passed on ${pr_head_sha}." + + - name: Prepare release branch + id: prepare + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + REPO: ${{ github.repository }} + RELEASE_BRANCH: ${{ steps.latest.outputs.release_branch }} + LATEST_TAG: ${{ steps.latest.outputs.latest_tag }} + LATEST_TAG_SHA: ${{ steps.latest.outputs.latest_sha }} + PATCH: ${{ steps.latest.outputs.patch }} + run: | + set -euo pipefail + + # Try to fetch the release branch. If patch == 0, it shouldn't exist yet + # and we'll create it from the latest stable tag. If patch > 0, it must + # already exist and its tip must equal the latest stable tag commit (i.e. + # the previous patch release). + if git ls-remote --exit-code --heads origin "${RELEASE_BRANCH}" >/dev/null 2>&1; then + echo "Release branch '${RELEASE_BRANCH}' already exists on origin." + git fetch origin "refs/heads/${RELEASE_BRANCH}:refs/remotes/origin/${RELEASE_BRANCH}" + git checkout -B "${RELEASE_BRANCH}" "refs/remotes/origin/${RELEASE_BRANCH}" + + current_tip="$(git rev-parse HEAD)" + if [[ "${current_tip}" != "${LATEST_TAG_SHA}" ]]; then + echo "::error::Release branch '${RELEASE_BRANCH}' tip (${current_tip}) is not at the latest stable release '${LATEST_TAG}' (${LATEST_TAG_SHA})." + echo "::error::Refusing to release on top of a divergent branch." + exit 1 + fi + echo "branch_existed=true" >> "$GITHUB_OUTPUT" + else + if [[ "${PATCH}" != "0" ]]; then + echo "::error::Release branch '${RELEASE_BRANCH}' does not exist on origin, but the latest stable release '${LATEST_TAG}' has patch=${PATCH} (>0). This is inconsistent." + exit 1 + fi + echo "Release branch '${RELEASE_BRANCH}' does not exist. Creating from ${LATEST_TAG}." + git checkout -B "${RELEASE_BRANCH}" "refs/tags/${LATEST_TAG}" + echo "branch_existed=false" >> "$GITHUB_OUTPUT" + fi + + - name: Fast-forward merge source branch into release branch + env: + SOURCE_BRANCH: ${{ steps.resolve.outputs.source_branch }} + SOURCE_COMMIT: ${{ inputs.commit }} + RELEASE_BRANCH: ${{ steps.latest.outputs.release_branch }} + run: | + set -euo pipefail + + # --ff-only guarantees no merge commit is created. If a fast-forward is + # not possible (i.e. the release branch has commits the source branch + # doesn't), the merge will fail and we abort. Because we already validated + # that the source branch is rooted on the latest stable tag, and the + # release branch tip equals that same tag, this fast-forward should + # always succeed for a well-formed backport branch. + # + # We merge the operator-provided SHA, not the branch ref, so a push to + # the branch in the window between resolve and now cannot smuggle new + # commits into the release. + if ! git merge --ff-only "${SOURCE_COMMIT}"; then + echo "::error::Cannot fast-forward '${RELEASE_BRANCH}' to ${SOURCE_COMMIT} (tip of '${SOURCE_BRANCH}'). A merge commit would be required. Aborting." + exit 1 + fi + + echo "Fast-forwarded '${RELEASE_BRANCH}' to ${SOURCE_COMMIT} (tip of '${SOURCE_BRANCH}')." + + - name: Bump version files + env: + NEW_VERSION_NO_V: ${{ steps.latest.outputs.new_version_no_v }} + run: | + set -euo pipefail + + if [[ ! -f comfyui_version.py ]]; then + echo "::error::comfyui_version.py not found in repo root." + exit 1 + fi + if [[ ! -f pyproject.toml ]]; then + echo "::error::pyproject.toml not found in repo root." + exit 1 + fi + + # Replace the version string in comfyui_version.py. + # Expected format: __version__ = "X.Y.Z" + python3 - "$NEW_VERSION_NO_V" <<'PY' + import re, sys, pathlib + new = sys.argv[1] + + p = pathlib.Path("comfyui_version.py") + src = p.read_text() + new_src, n = re.subn( + r'(__version__\s*=\s*[\'"])[^\'"]+([\'"])', + lambda m: f'{m.group(1)}{new}{m.group(2)}', + src, + count=1, + ) + if n != 1: + sys.exit("Could not find __version__ assignment in comfyui_version.py") + p.write_text(new_src) + + p = pathlib.Path("pyproject.toml") + src = p.read_text() + # Replace the first `version = "..."` inside [project] or [tool.poetry]. + new_src, n = re.subn( + r'(?m)^(version\s*=\s*")[^"]+(")', + lambda m: f'{m.group(1)}{new}{m.group(2)}', + src, + count=1, + ) + if n != 1: + sys.exit("Could not find version assignment in pyproject.toml") + p.write_text(new_src) + PY + + echo "Updated version to ${NEW_VERSION_NO_V} in comfyui_version.py and pyproject.toml." + git --no-pager diff -- comfyui_version.py pyproject.toml + + - name: Commit version bump and tag release + env: + NEW_VERSION: ${{ steps.latest.outputs.new_version }} + run: | + set -euo pipefail + + git add comfyui_version.py pyproject.toml + git commit -m "ComfyUI ${NEW_VERSION}" + + if git rev-parse -q --verify "refs/tags/${NEW_VERSION}" >/dev/null; then + echo "::error::Tag ${NEW_VERSION} already exists locally." + exit 1 + fi + git tag "${NEW_VERSION}" + + - name: Verify tag does not already exist on origin + env: + NEW_VERSION: ${{ steps.latest.outputs.new_version }} + run: | + set -euo pipefail + if git ls-remote --exit-code --tags origin "refs/tags/${NEW_VERSION}" >/dev/null 2>&1; then + echo "::error::Tag ${NEW_VERSION} already exists on origin. Aborting." + exit 1 + fi + + - name: Push release branch and tag + env: + RELEASE_BRANCH: ${{ steps.latest.outputs.release_branch }} + NEW_VERSION: ${{ steps.latest.outputs.new_version }} + run: | + set -euo pipefail + + # Push the branch first, then the tag. Atomic-ish: if the branch push + # fails we never publish the tag. + git push origin "refs/heads/${RELEASE_BRANCH}:refs/heads/${RELEASE_BRANCH}" + git push origin "refs/tags/${NEW_VERSION}" + + echo "Released ${NEW_VERSION} on ${RELEASE_BRANCH}." + + - name: Delete remote source branch + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + REPO: ${{ github.repository }} + SOURCE_BRANCH: ${{ steps.resolve.outputs.source_branch }} + SOURCE_COMMIT: ${{ inputs.commit }} + RELEASE_BRANCH: ${{ steps.latest.outputs.release_branch }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + + # Belt-and-braces: the resolve step already refuses the default branch, + # but never delete the default or the release branch under any + # circumstances. + if [[ "${SOURCE_BRANCH}" == "${DEFAULT_BRANCH}" || "${SOURCE_BRANCH}" == "${RELEASE_BRANCH}" ]]; then + echo "::error::Refusing to delete '${SOURCE_BRANCH}' (matches default or release branch)." + exit 1 + fi + + # Delete the source branch on origin, but only if its tip is still the + # SHA we released from. If someone pushed new commits to it after we + # resolved it, leave it alone — those commits would be silently lost. + current_tip="$(git ls-remote origin "refs/heads/${SOURCE_BRANCH}" | awk '{print $1}')" + if [[ -z "${current_tip}" ]]; then + echo "Source branch '${SOURCE_BRANCH}' no longer exists on origin; nothing to delete." + exit 0 + fi + if [[ "${current_tip}" != "${SOURCE_COMMIT}" ]]; then + echo "::warning::Source branch '${SOURCE_BRANCH}' tip (${current_tip}) no longer matches released commit (${SOURCE_COMMIT}). Leaving it in place." + exit 0 + fi + + git push origin --delete "refs/heads/${SOURCE_BRANCH}" + echo "Deleted remote branch '${SOURCE_BRANCH}'." + + - name: Summary + if: always() + env: + NEW_VERSION: ${{ steps.latest.outputs.new_version }} + RELEASE_BRANCH: ${{ steps.latest.outputs.release_branch }} + LATEST_TAG: ${{ steps.latest.outputs.latest_tag }} + SOURCE_BRANCH: ${{ steps.resolve.outputs.source_branch }} + SOURCE_COMMIT: ${{ inputs.commit }} + run: | + # SOURCE_BRANCH is empty if the resolve step never produced an output + # (e.g. the workflow failed in or before that step). Show a placeholder + # in that case so the summary table still renders cleanly. + source_branch_display="${SOURCE_BRANCH:-(unresolved)}" + { + echo "## Backport release" + echo "" + echo "| Field | Value |" + echo "|---|---|" + echo "| Source commit | \`${SOURCE_COMMIT}\` |" + echo "| Source branch | \`${source_branch_display}\` |" + echo "| Previous stable | \`${LATEST_TAG}\` |" + echo "| New version | \`${NEW_VERSION}\` |" + echo "| Release branch | \`${RELEASE_BRANCH}\` |" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/README.md b/README.md index 0eecd8a4b..5125bad14 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ [website-url]: https://www.comfy.org/ [discord-shield]: https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fdiscord.com%2Fapi%2Finvites%2Fcomfyorg%3Fwith_counts%3Dtrue&query=%24.approximate_member_count&logo=discord&logoColor=white&label=Discord&color=green&suffix=%20total -[discord-url]: https://www.comfy.org/discord +[discord-url]: https://discord.com/invite/comfyorg [twitter-shield]: https://img.shields.io/twitter/follow/ComfyUI [twitter-url]: https://x.com/ComfyUI diff --git a/app/frontend_management.py b/app/frontend_management.py index d0596b276..483da2d29 100644 --- a/app/frontend_management.py +++ b/app/frontend_management.py @@ -62,6 +62,8 @@ def get_comfy_package_versions(): def check_comfy_packages_versions(): """Warn for every comfy* package whose installed version is below requirements.txt.""" from packaging.version import InvalidVersion, parse as parse_pep440 + outdated_packages = [] + for pkg in get_comfy_package_versions(): installed_str = pkg["installed"] required_str = pkg["required"] @@ -73,19 +75,26 @@ def check_comfy_packages_versions(): logging.error(f"Failed to check {pkg['name']} version: {e}") continue if outdated: - app.logger.log_startup_warning( - f""" + outdated_packages.append((pkg["name"], installed_str, required_str)) + else: + logging.info("{} version: {}".format(pkg["name"], installed_str)) + + if outdated_packages: + package_warnings = "\n".join( + f"Installed {name} version {installed} is lower than the recommended version {required}." + for name, installed, required in outdated_packages + ) + app.logger.log_startup_warning( + f""" ________________________________________________________________________ WARNING WARNING WARNING WARNING WARNING -Installed {pkg["name"]} version {installed_str} is lower than the recommended version {required_str}. +{package_warnings} {get_missing_requirements_message()} ________________________________________________________________________ """.strip() - ) - else: - logging.info("{} version: {}".format(pkg["name"], installed_str)) + ) REQUEST_TIMEOUT = 10 # seconds diff --git a/comfy_api_nodes/apis/rodin.py b/comfy_api_nodes/apis/rodin.py index fc26a6e73..24524d642 100644 --- a/comfy_api_nodes/apis/rodin.py +++ b/comfy_api_nodes/apis/rodin.py @@ -1,7 +1,5 @@ -from __future__ import annotations - from enum import Enum -from typing import Optional, List + from pydantic import BaseModel, Field @@ -11,44 +9,76 @@ class Rodin3DGenerateRequest(BaseModel): material: str = Field(..., description="The material type.") quality_override: int = Field(..., description="The poly count of the mesh.") mesh_mode: str = Field(..., description="It controls the type of faces of generated models.") - TAPose: Optional[bool] = Field(None, description="") + TAPose: bool | None = Field(None, description="") + + +class Rodin3DGen25Request(BaseModel): + + tier: str = Field(..., description="Gen-2.5 tier (e.g. Gen-2.5-High).") + prompt: str | None = Field(None, description="Required for Text-to-3D; ignored otherwise.") + seed: int | None = Field(None, description="0-65535.") + material: str | None = Field(None, description="PBR | Shaded | All | None.") + geometry_file_format: str | None = Field(None, description="glb | usdz | fbx | obj | stl.") + texture_mode: str | None = Field(None, description="legacy | extreme-low | low | medium | high.") + mesh_mode: str | None = Field(None, description="Raw (triangular) | Quad.") + quality_override: int | None = Field(None, description="Mesh face count override.") + geometry_instruct_mode: str | None = Field(None, description="faithful | creative.") + bbox_condition: list[int] | None = Field(None, description="Bounding box [Width(Y), Height(Z), Length(X)] in cm.") + height: int | None = Field(None, description="Approximate model height in cm.") + TAPose: bool | None = Field(None, description="T/A pose for human-like models.") + hd_texture: bool | None = Field(None, description="Enhanced texture quality.") + texture_delight: bool | None = Field(None, description="Remove baked lighting from textures.") + is_micro: bool | None = Field(None, description="Micro detail (Extreme-High only).") + use_original_alpha: bool | None = Field(None, description="Preserve image transparency.") + preview_render: bool | None = Field(None, description="Generate high-quality preview render.") + addons: list[str] | None = Field(None, description='Optional addons, e.g. ["HighPack"].') + class GenerateJobsData(BaseModel): - uuids: List[str] = Field(..., description="str LIST") + uuids: list[str] = Field(..., description="str LIST") subscription_key: str = Field(..., description="subscription key") + class Rodin3DGenerateResponse(BaseModel): - message: Optional[str] = Field(None, description="Return message.") - prompt: Optional[str] = Field(None, description="Generated Prompt from image.") - submit_time: Optional[str] = Field(None, description="Submit Time") - uuid: Optional[str] = Field(None, description="Task str") - jobs: Optional[GenerateJobsData] = Field(None, description="Details of jobs") + message: str | None = Field(None, description="Return message.") + prompt: str | None = Field(None, description="Generated Prompt from image.") + submit_time: str | None = Field(None, description="Submit Time") + uuid: str | None = Field(None, description="Task str") + jobs: GenerateJobsData | None = Field(None, description="Details of jobs") + class JobStatus(str, Enum): """ Status for jobs """ + Done = "Done" Failed = "Failed" Generating = "Generating" Waiting = "Waiting" + class Rodin3DCheckStatusRequest(BaseModel): subscription_key: str = Field(..., description="subscription from generate endpoint") + class JobItem(BaseModel): uuid: str = Field(..., description="uuid") - status: JobStatus = Field(...,description="Status Currently") + status: JobStatus = Field(..., description="Status Currently") + class Rodin3DCheckStatusResponse(BaseModel): - jobs: List[JobItem] = Field(..., description="Job status List") + jobs: list[JobItem] = Field(..., description="Job status List") + class Rodin3DDownloadRequest(BaseModel): task_uuid: str = Field(..., description="Task str") + class RodinResourceItem(BaseModel): url: str = Field(..., description="Download Url") name: str = Field(..., description="File name with ext") + class Rodin3DDownloadResponse(BaseModel): - list: List[RodinResourceItem] = Field(..., description="Source List") + items: list[RodinResourceItem] = Field(..., alias="list", description="Source List") diff --git a/comfy_api_nodes/nodes_rodin.py b/comfy_api_nodes/nodes_rodin.py index 2b829b8db..2df5a3e13 100644 --- a/comfy_api_nodes/nodes_rodin.py +++ b/comfy_api_nodes/nodes_rodin.py @@ -5,32 +5,37 @@ Rodin API docs: https://developer.hyper3d.ai/ """ -from inspect import cleandoc -import folder_paths as comfy_paths -import os import logging import math +import os +from inspect import cleandoc from io import BytesIO -from typing_extensions import override +from typing import Any + +import aiohttp from PIL import Image +from typing_extensions import override + +import folder_paths as comfy_paths +from comfy_api.latest import IO, ComfyExtension, Types from comfy_api_nodes.apis.rodin import ( - Rodin3DGenerateRequest, - Rodin3DGenerateResponse, + JobStatus, Rodin3DCheckStatusRequest, Rodin3DCheckStatusResponse, Rodin3DDownloadRequest, Rodin3DDownloadResponse, - JobStatus, + Rodin3DGen25Request, + Rodin3DGenerateRequest, + Rodin3DGenerateResponse, ) from comfy_api_nodes.util import ( - sync_op, - poll_op, ApiEndpoint, download_url_to_bytesio, download_url_to_file_3d, + poll_op, + sync_op, + validate_string, ) -from comfy_api.latest import ComfyExtension, IO, Types - COMMON_PARAMETERS = [ IO.Int.Input( @@ -51,40 +56,30 @@ COMMON_PARAMETERS = [ ] -def get_quality_mode(poly_count): - polycount = poly_count.split("-") - poly = polycount[1] - count = polycount[0] - if poly == "Triangle": - mesh_mode = "Raw" - elif poly == "Quad": - mesh_mode = "Quad" - else: - mesh_mode = "Quad" - - if count == "4K": - quality_override = 4000 - elif count == "8K": - quality_override = 8000 - elif count == "18K": - quality_override = 18000 - elif count == "50K": - quality_override = 50000 - elif count == "2K": - quality_override = 2000 - elif count == "20K": - quality_override = 20000 - elif count == "150K": - quality_override = 150000 - elif count == "500K": - quality_override = 500000 - else: - quality_override = 18000 - - return mesh_mode, quality_override +_QUALITY_MESH_OPTIONS: dict[str, tuple[str, int]] = { + "4K-Quad": ("Quad", 4000), + "8K-Quad": ("Quad", 8000), + "18K-Quad": ("Quad", 18000), + "50K-Quad": ("Quad", 50000), + "200K-Quad": ("Quad", 200000), + "2K-Triangle": ("Raw", 2000), + "20K-Triangle": ("Raw", 20000), + "150K-Triangle": ("Raw", 150000), + "200K-Triangle": ("Raw", 200000), + "500K-Triangle": ("Raw", 500000), + "1M-Triangle": ("Raw", 1000000), +} -def tensor_to_filelike(tensor, max_pixels: int = 2048*2048): +def get_quality_mode(poly_count: str) -> tuple[str, int]: + """Map a polygon-count preset like '18K-Quad' to (mesh_mode, quality_override). + + Falls back to ('Quad', 18000) for unknown labels; legacy parity. + """ + return _QUALITY_MESH_OPTIONS.get(poly_count, ("Quad", 18000)) + + +def tensor_to_filelike(tensor, max_pixels: int = 2048 * 2048): """ Converts a PyTorch tensor to a file-like object. @@ -96,8 +91,8 @@ def tensor_to_filelike(tensor, max_pixels: int = 2048*2048): - io.BytesIO: A file-like object containing the image data. """ array = tensor.cpu().numpy() - array = (array * 255).astype('uint8') - image = Image.fromarray(array, 'RGB') + array = (array * 255).astype("uint8") + image = Image.fromarray(array, "RGB") original_width, original_height = image.size original_pixels = original_width * original_height @@ -112,7 +107,7 @@ def tensor_to_filelike(tensor, max_pixels: int = 2048*2048): image = image.resize((new_width, new_height), Image.Resampling.LANCZOS) img_byte_arr = BytesIO() - image.save(img_byte_arr, format='PNG') # PNG is used for lossless compression + image.save(img_byte_arr, format="PNG") # PNG is used for lossless compression img_byte_arr.seek(0) return img_byte_arr @@ -145,11 +140,9 @@ async def create_generate_task( TAPose=ta_pose, ), files=[ - ( - "images", - open(image, "rb") if isinstance(image, str) else tensor_to_filelike(image) - ) - for image in images if image is not None + ("images", open(image, "rb") if isinstance(image, str) else tensor_to_filelike(image)) + for image in images + if image is not None ], content_type="multipart/form-data", ) @@ -177,6 +170,7 @@ def check_rodin_status(response: Rodin3DCheckStatusResponse) -> str: return "DONE" return "Generating" + def extract_progress(response: Rodin3DCheckStatusResponse) -> int | None: if not response.jobs: return None @@ -214,7 +208,7 @@ async def download_files(url_list, task_uuid: str) -> tuple[str | None, Types.Fi model_file_path = None file_3d = None - for i in url_list.list: + for i in url_list.items: file_path = os.path.join(save_path, i.name) if i.name.lower().endswith(".glb"): model_file_path = os.path.join(result_folder_name, i.name) @@ -489,7 +483,16 @@ class Rodin3D_Gen2(IO.ComfyNode): IO.Combo.Input("Material_Type", options=["PBR", "Shaded"], default="PBR", optional=True), IO.Combo.Input( "Polygon_count", - options=["4K-Quad", "8K-Quad", "18K-Quad", "50K-Quad", "2K-Triangle", "20K-Triangle", "150K-Triangle", "500K-Triangle"], + options=[ + "4K-Quad", + "8K-Quad", + "18K-Quad", + "50K-Quad", + "2K-Triangle", + "20K-Triangle", + "150K-Triangle", + "500K-Triangle", + ], default="500K-Triangle", optional=True, ), @@ -542,6 +545,566 @@ class Rodin3D_Gen2(IO.ComfyNode): return IO.NodeOutput(model_path, file_3d) +def _rodin_multipart_parser(data: dict[str, Any]) -> aiohttp.FormData: + """Convert a Rodin request dict to an aiohttp form, fixing bool/list serialization. + + Booleans --> "true"/"false". Lists --> one field per element. + """ + form = aiohttp.FormData(default_to_multipart=True) + for key, value in data.items(): + if value is None: + continue + if isinstance(value, bool): + form.add_field(key, "true" if value else "false") + elif isinstance(value, list): + for item in value: + form.add_field(key, str(item)) + elif isinstance(value, (bytes, bytearray)): + form.add_field(key, value) + else: + form.add_field(key, str(value)) + return form + + +async def _create_gen25_task( + cls: type[IO.ComfyNode], + request: Rodin3DGen25Request, + images: list | None, +) -> tuple[str, str]: + """Submit a Gen-2.5 generate job; returns (task_uuid, subscription_key).""" + + if images is not None and len(images) > 5: + raise ValueError("Rodin Gen-2.5 supports at most 5 input images.") + + files = None + if images: + files = [ + ( + "images", + open(image, "rb") if isinstance(image, str) else tensor_to_filelike(image), + ) + for image in images + if image is not None + ] + + response = await sync_op( + cls, + ApiEndpoint(path="/proxy/rodin/api/v2/rodin", method="POST"), + response_model=Rodin3DGenerateResponse, + data=request, + files=files, + content_type="multipart/form-data", + multipart_parser=_rodin_multipart_parser, + ) + + if not response.uuid or not response.jobs or not response.jobs.subscription_key: + raise RuntimeError(f"Rodin Gen-2.5 submit failed: message={response.message!r}") + return response.uuid, response.jobs.subscription_key + + +_PREVIEWABLE_3D_EXTS = {".glb", ".obj", ".fbx", ".stl", ".gltf"} + + +async def _download_gen25_files( + download_list: Rodin3DDownloadResponse, + task_uuid: str, + geometry_file_format: str, +) -> Types.File3D | None: + """Download every file in the list; return the File3D matching the chosen format.""" + + folder_name = f"Rodin3D_Gen25_{task_uuid}" + save_dir = os.path.join(comfy_paths.get_output_directory(), folder_name) + os.makedirs(save_dir, exist_ok=True) + + target_ext = f".{geometry_file_format.lower().lstrip('.')}" + file_3d: Types.File3D | None = None + + for item in download_list.items: + file_path = os.path.join(save_dir, item.name) + ext = os.path.splitext(item.name.lower())[1] + # Prefer the file matching the user's chosen format; fall back below. + if file_3d is None and ext == target_ext and ext in _PREVIEWABLE_3D_EXTS: + file_3d = await download_url_to_file_3d(item.url, target_ext.lstrip(".")) + with open(file_path, "wb") as f: + f.write(file_3d.get_bytes()) + continue + await download_url_to_bytesio(item.url, file_path) + + # If the chosen format wasn't found, surface any model file we did get. + if file_3d is None: + for item in download_list.items: + ext = os.path.splitext(item.name.lower())[1] + if ext in _PREVIEWABLE_3D_EXTS: + file_3d = await download_url_to_file_3d(item.url, ext.lstrip(".")) + break + return file_3d + + +_MODE_REGULAR = "Regular" +_MODE_FAST = "Fast" +_MODE_EXTREME_HIGH = "Extreme-High" + +_REGULAR_POLY_OPTIONS = [ + "Default", + "4K-Quad", + "8K-Quad", + "18K-Quad", + "50K-Quad", + "2K-Triangle", + "20K-Triangle", + "150K-Triangle", + "500K-Triangle", + "1M-Triangle", +] + +_TEXTURE_MODE_OPTIONS = ["Default", "legacy", "extreme-low", "low", "medium", "high"] +_GEOMETRY_FORMAT_OPTIONS = ["glb", "fbx", "obj", "stl"] +_MATERIAL_OPTIONS = ["PBR", "Shaded", "All", "None"] + + +def _build_mode_input(name: str = "mode") -> IO.DynamicCombo.Input: + return IO.DynamicCombo.Input( + name, + options=[ + IO.DynamicCombo.Option( + _MODE_REGULAR, + [ + IO.Combo.Input( + "tier", + options=["Gen-2.5-Low", "Gen-2.5-Medium", "Gen-2.5-High"], + default="Gen-2.5-High", + tooltip="Quality tier. Higher tiers produce higher-fidelity geometry.", + ), + IO.Combo.Input( + "polygon_count", + options=_REGULAR_POLY_OPTIONS, + default="Default", + tooltip="Preset face count. 'Default' uses the server's default for the selected tier.", + ), + IO.Boolean.Input( + "creative", + default=False, + tooltip="Creative mode (Medium/High only). Enhances generative robustness.", + ), + ], + ), + IO.DynamicCombo.Option( + _MODE_FAST, + [ + IO.Combo.Input( + "tier", + options=[ + "Gen-2.5-Extreme-Low", + "Gen-2.5-Low", + "Gen-2.5-Medium", + "Gen-2.5-High", + ], + default="Gen-2.5-Low", + ), + IO.Int.Input( + "mesh_faces", + default=20000, + min=1000, + max=20000, + display_mode=IO.NumberDisplay.number, + tooltip="Mesh face count (1K-20K in Fast mode).", + ), + ], + ), + IO.DynamicCombo.Option( + _MODE_EXTREME_HIGH, + [ + IO.Combo.Input("mesh_mode", options=["Raw", "Quad"], default="Raw"), + IO.Int.Input( + "mesh_faces", + default=1000000, + min=20000, + max=2000000, + display_mode=IO.NumberDisplay.number, + tooltip=( + "Mesh face count. Raw mode: 20K-2M. " + "Quad mode: keep under 200K (upstream may reject higher values)." + ), + ), + IO.Boolean.Input( + "is_micro", + default=False, + tooltip="Enable micro detail (Extreme-High only).", + ), + IO.Boolean.Input( + "creative", + default=False, + tooltip="Creative mode. Enhances generative robustness.", + ), + ], + ), + ], + tooltip=( + "Generation mode. Regular = balanced. Fast = 1K-20K faces for rapid prototyping. " + "Extreme-High = 20K-2M faces with optional micro details." + ), + ) + + +def _build_common_inputs(*, include_image_only: bool) -> list: + inputs: list = [ + IO.Combo.Input("material", options=_MATERIAL_OPTIONS, default="Shaded"), + IO.Combo.Input("geometry_file_format", options=_GEOMETRY_FORMAT_OPTIONS, default="glb"), + IO.Combo.Input( + "texture_mode", + options=_TEXTURE_MODE_OPTIONS, + default="Default", + optional=True, + tooltip="Texture quality preset. 'Default' uses the server's default for the selected tier.", + ), + IO.Int.Input( + "seed", + default=0, + min=0, + max=65535, + display_mode=IO.NumberDisplay.number, + control_after_generate=True, + optional=True, + ), + IO.Boolean.Input( + "TAPose", default=False, optional=True, advanced=True, tooltip="T/A pose for human-like models." + ), + IO.Boolean.Input( + "hd_texture", default=False, optional=True, advanced=True, tooltip="High-quality texture enhancement." + ), + IO.Boolean.Input( + "texture_delight", + default=False, + optional=True, + advanced=True, + tooltip="Remove baked lighting from textures.", + ), + ] + if include_image_only: + inputs.append( + IO.Boolean.Input( + "use_original_alpha", + default=False, + optional=True, + advanced=True, + tooltip="Preserve image transparency.", + ) + ) + inputs.extend( + [ + IO.Boolean.Input( + "addon_highpack", + default=False, + optional=True, + advanced=True, + tooltip="HighPack addon: 4K textures and ~16x faces in Quad mode.", + ), + IO.Int.Input( + "bbox_width", + default=0, + min=0, + max=300, + display_mode=IO.NumberDisplay.number, + optional=True, + advanced=True, + tooltip="Bounding-box width (Y axis). Set to 0 with the others to skip bbox.", + ), + IO.Int.Input( + "bbox_height", + default=0, + min=0, + max=300, + display_mode=IO.NumberDisplay.number, + optional=True, + advanced=True, + tooltip="Bounding-box height (Z axis).", + ), + IO.Int.Input( + "bbox_length", + default=0, + min=0, + max=300, + display_mode=IO.NumberDisplay.number, + optional=True, + advanced=True, + tooltip="Bounding-box length (X axis).", + ), + IO.Int.Input( + "height_cm", + default=0, + min=0, + max=10000, + display_mode=IO.NumberDisplay.number, + optional=True, + advanced=True, + tooltip="Approximate model height in centimeters (0 to skip).", + ), + ] + ) + return inputs + + +_PRICE_EXPR = """ +( + $baseCredits := widgets.mode = "extreme-high" ? 1.0 : 0.5; + $addonCredits := widgets.addon_highpack ? 1.0 : 0.0; + $total := ($baseCredits * 1.5) + ($addonCredits * 0.8); + {"type":"usd","usd": $total} +) +""" + + +def _resolve_mode_params(mode_input: dict) -> dict: + """Translate the DynamicCombo `mode` payload into Gen-2.5 request fields. + + Returns a dict with: tier, quality_override, mesh_mode, geometry_instruct_mode, is_micro. + Missing keys mean "do not send" (so we don't override server defaults). + """ + selected = mode_input["mode"] + out: dict = {} + + if selected == _MODE_REGULAR: + out["tier"] = mode_input["tier"] + polygon = mode_input.get("polygon_count", "Default") + if polygon != "Default": + mesh_mode, faces = get_quality_mode(polygon) + out["mesh_mode"] = mesh_mode + out["quality_override"] = faces + if mode_input.get("creative"): + out["geometry_instruct_mode"] = "creative" + + elif selected == _MODE_FAST: + out["tier"] = mode_input["tier"] + out["mesh_mode"] = "Raw" + out["quality_override"] = int(mode_input["mesh_faces"]) + + elif selected == _MODE_EXTREME_HIGH: + out["tier"] = "Gen-2.5-Extreme-High" + out["mesh_mode"] = mode_input["mesh_mode"] + out["quality_override"] = int(mode_input["mesh_faces"]) + if mode_input.get("is_micro"): + out["is_micro"] = True + if mode_input.get("creative"): + out["geometry_instruct_mode"] = "creative" + return out + + +def _build_request( + *, + mode_input: dict, + material: str, + geometry_file_format: str, + texture_mode: str, + seed: int, + TAPose: bool, + hd_texture: bool, + texture_delight: bool, + addon_highpack: bool, + bbox_width: int, + bbox_height: int, + bbox_length: int, + height_cm: int, + prompt: str | None = None, + use_original_alpha: bool = False, +) -> Rodin3DGen25Request: + mode_params = _resolve_mode_params(mode_input) + + bbox = None + if bbox_width and bbox_height and bbox_length: + bbox = [bbox_width, bbox_height, bbox_length] + + return Rodin3DGen25Request( + tier=mode_params["tier"], + prompt=prompt or None, + seed=seed, + material=material, + geometry_file_format=geometry_file_format, + texture_mode=None if texture_mode == "Default" else texture_mode, + mesh_mode=mode_params.get("mesh_mode"), + quality_override=mode_params.get("quality_override"), + geometry_instruct_mode=mode_params.get("geometry_instruct_mode"), + bbox_condition=bbox, + height=height_cm or None, + TAPose=TAPose or None, + hd_texture=hd_texture or None, + texture_delight=texture_delight or None, + is_micro=mode_params.get("is_micro"), + use_original_alpha=use_original_alpha or None, + addons=["HighPack"] if addon_highpack else None, + ) + + +class Rodin3D_Gen25_Image(IO.ComfyNode): + + @classmethod + def define_schema(cls) -> IO.Schema: + return IO.Schema( + node_id="Rodin3D_Gen25_Image", + display_name="Rodin 3D Gen-2.5 - Image to 3D", + category="api node/3d/Rodin", + description=( + "Generate a 3D model from 1-5 reference images via Rodin Gen-2.5. " + "Pick a mode (Fast / Regular / Extreme-High) to tune quality vs. cost." + ), + inputs=[ + IO.Autogrow.Input( + "images", + template=IO.Autogrow.TemplatePrefix(IO.Image.Input("image"), prefix="image", min=1, max=5), + tooltip="1-5 images. The first image is used for materials when multi-view.", + ), + _build_mode_input(), + *_build_common_inputs(include_image_only=True), + ], + outputs=[IO.File3DAny.Output(display_name="model_file")], + hidden=[ + IO.Hidden.auth_token_comfy_org, + IO.Hidden.api_key_comfy_org, + IO.Hidden.unique_id, + ], + is_api_node=True, + price_badge=IO.PriceBadge( + depends_on=IO.PriceBadgeDepends(widgets=["mode", "addon_highpack"]), + expr=_PRICE_EXPR, + ), + ) + + @classmethod + async def execute( + cls, + images: IO.Autogrow.Type, + mode: dict, + material: str, + geometry_file_format: str, + texture_mode: str, + seed: int, + TAPose: bool, + hd_texture: bool, + texture_delight: bool, + use_original_alpha: bool, + addon_highpack: bool, + bbox_width: int, + bbox_height: int, + bbox_length: int, + height_cm: int, + ) -> IO.NodeOutput: + image_tensors = [img for img in images.values() if img is not None] + if not image_tensors: + raise ValueError("Rodin Gen-2.5 Image-to-3D requires at least one image.") + + # Flatten multi-image tensors into individual frames; the API accepts each as a separate part. + flat_images: list = [] + for tensor in image_tensors: + if hasattr(tensor, "shape") and len(tensor.shape) == 4: + for i in range(tensor.shape[0]): + flat_images.append(tensor[i]) + else: + flat_images.append(tensor) + + if len(flat_images) > 5: + raise ValueError(f"Rodin Gen-2.5 accepts at most 5 images; received {len(flat_images)}.") + + request = _build_request( + mode_input=mode, + material=material, + geometry_file_format=geometry_file_format, + texture_mode=texture_mode, + seed=seed, + TAPose=TAPose, + hd_texture=hd_texture, + texture_delight=texture_delight, + addon_highpack=addon_highpack, + bbox_width=bbox_width, + bbox_height=bbox_height, + bbox_length=bbox_length, + height_cm=height_cm, + prompt=None, + use_original_alpha=use_original_alpha, + ) + + task_uuid, subscription_key = await _create_gen25_task(cls, request, flat_images) + await poll_for_task_status(subscription_key, cls) + download_list = await get_rodin_download_list(task_uuid, cls) + file_3d = await _download_gen25_files(download_list, task_uuid, geometry_file_format) + return IO.NodeOutput(file_3d) + + +class Rodin3D_Gen25_Text(IO.ComfyNode): + + @classmethod + def define_schema(cls) -> IO.Schema: + return IO.Schema( + node_id="Rodin3D_Gen25_Text", + display_name="Rodin 3D Gen-2.5 - Text to 3D", + category="api node/3d/Rodin", + description=( + "Generate a 3D model from a text prompt via Rodin Gen-2.5. " + "Pick a mode (Fast / Regular / Extreme-High) to tune quality vs. cost." + ), + inputs=[ + IO.String.Input( + "prompt", + multiline=True, + default="", + tooltip="Text prompt for the 3D model.", + ), + _build_mode_input(), + *_build_common_inputs(include_image_only=False), + ], + outputs=[IO.File3DAny.Output(display_name="model_file")], + hidden=[ + IO.Hidden.auth_token_comfy_org, + IO.Hidden.api_key_comfy_org, + IO.Hidden.unique_id, + ], + is_api_node=True, + price_badge=IO.PriceBadge( + depends_on=IO.PriceBadgeDepends(widgets=["mode", "addon_highpack"]), + expr=_PRICE_EXPR, + ), + ) + + @classmethod + async def execute( + cls, + prompt: str, + mode: dict, + material: str, + geometry_file_format: str, + texture_mode: str, + seed: int, + TAPose: bool, + hd_texture: bool, + texture_delight: bool, + addon_highpack: bool, + bbox_width: int, + bbox_height: int, + bbox_length: int, + height_cm: int, + ) -> IO.NodeOutput: + validate_string(prompt, field_name="prompt", min_length=1, max_length=2500) + request = _build_request( + mode_input=mode, + material=material, + geometry_file_format=geometry_file_format, + texture_mode=texture_mode, + seed=seed, + TAPose=TAPose, + hd_texture=hd_texture, + texture_delight=texture_delight, + addon_highpack=addon_highpack, + bbox_width=bbox_width, + bbox_height=bbox_height, + bbox_length=bbox_length, + height_cm=height_cm, + prompt=prompt, + ) + task_uuid, subscription_key = await _create_gen25_task(cls, request, images=None) + await poll_for_task_status(subscription_key, cls) + download_list = await get_rodin_download_list(task_uuid, cls) + file_3d = await _download_gen25_files(download_list, task_uuid, geometry_file_format) + return IO.NodeOutput(file_3d) + + class Rodin3DExtension(ComfyExtension): @override async def get_node_list(self) -> list[type[IO.ComfyNode]]: @@ -551,6 +1114,8 @@ class Rodin3DExtension(ComfyExtension): Rodin3D_Smooth, Rodin3D_Sketch, Rodin3D_Gen2, + Rodin3D_Gen25_Image, + Rodin3D_Gen25_Text, ] diff --git a/comfy_extras/nodes_logic.py b/comfy_extras/nodes_logic.py index c066064ac..342cadb69 100644 --- a/comfy_extras/nodes_logic.py +++ b/comfy_extras/nodes_logic.py @@ -8,6 +8,82 @@ from comfy_api.latest import _io MISSING = object() +class NotNode(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="ComfyNotNode", + display_name="Not", + category="utils/logic", + description="Logical NOT operation. Returns true if the value is falsy. Uses Python's rules for truthiness.", + search_aliases=["invert", "toggle", "negate", "flip boolean"], + inputs=[ + io.AnyType.Input("value"), + ], + outputs=[ + io.Boolean.Output(), + ], + ) + + @classmethod + def execute(cls, value) -> io.NodeOutput: + return io.NodeOutput(not value) + + +class AndNode(io.ComfyNode): + @classmethod + def define_schema(cls): + template = io.Autogrow.TemplatePrefix( + input=io.AnyType.Input("value"), + prefix="value", + min=1, + ) + return io.Schema( + node_id="ComfyAndNode", + display_name="And", + category="utils/logic", + description="Logical AND operation. Returns true if all of the values are truthy. Uses Python's rules for truthiness.", + search_aliases=["all", "every"], + inputs=[ + io.Autogrow.Input("values", template=template), + ], + outputs=[ + io.Boolean.Output(), + ], + ) + + @classmethod + def execute(cls, values: io.Autogrow.Type) -> io.NodeOutput: + return io.NodeOutput(all(values.values())) + + +class OrNode(io.ComfyNode): + @classmethod + def define_schema(cls): + template = io.Autogrow.TemplatePrefix( + input=io.AnyType.Input("value"), + prefix="value", + min=1, + ) + return io.Schema( + node_id="ComfyOrNode", + display_name="Or", + category="utils/logic", + description="Logical OR operation. Returns true if any of the values are truthy. Uses Python's rules for truthiness.", + search_aliases=["any", "some"], + inputs=[ + io.Autogrow.Input("values", template=template), + ], + outputs=[ + io.Boolean.Output(), + ], + ) + + @classmethod + def execute(cls, values: io.Autogrow.Type) -> io.NodeOutput: + return io.NodeOutput(any(values.values())) + + class SwitchNode(io.ComfyNode): @classmethod def define_schema(cls): @@ -15,7 +91,7 @@ class SwitchNode(io.ComfyNode): return io.Schema( node_id="ComfySwitchNode", display_name="Switch", - category="logic", + category="utils/logic", is_experimental=True, inputs=[ io.Boolean.Input("switch"), @@ -46,7 +122,7 @@ class SoftSwitchNode(io.ComfyNode): return io.Schema( node_id="ComfySoftSwitchNode", display_name="Soft Switch", - category="logic", + category="utils/logic", is_experimental=True, inputs=[ io.Boolean.Input("switch"), @@ -136,7 +212,7 @@ class DCTestNode(io.ComfyNode): return io.Schema( node_id="DCTestNode", display_name="DCTest", - category="logic", + category="utils/logic", is_output_node=True, inputs=[io.DynamicCombo.Input("combo", options=[ io.DynamicCombo.Option("option1", [io.String.Input("string")]), @@ -174,7 +250,7 @@ class AutogrowNamesTestNode(io.ComfyNode): return io.Schema( node_id="AutogrowNamesTestNode", display_name="AutogrowNamesTest", - category="logic", + category="utils/logic", inputs=[ _io.Autogrow.Input("autogrow", template=template) ], @@ -194,7 +270,7 @@ class AutogrowPrefixTestNode(io.ComfyNode): return io.Schema( node_id="AutogrowPrefixTestNode", display_name="AutogrowPrefixTest", - category="logic", + category="utils/logic", inputs=[ _io.Autogrow.Input("autogrow", template=template) ], @@ -213,7 +289,7 @@ class ComboOutputTestNode(io.ComfyNode): return io.Schema( node_id="ComboOptionTestNode", display_name="ComboOptionTest", - category="logic", + category="utils/logic", inputs=[io.Combo.Input("combo", options=["option1", "option2", "option3"]), io.Combo.Input("combo2", options=["option4", "option5", "option6"])], outputs=[io.Combo.Output(), io.Combo.Output()], @@ -230,7 +306,7 @@ class ConvertStringToComboNode(io.ComfyNode): node_id="ConvertStringToComboNode", search_aliases=["string to dropdown", "text to combo"], display_name="Convert String to Combo", - category="logic", + category="utils/logic", inputs=[io.String.Input("string")], outputs=[io.Combo.Output()], ) @@ -246,7 +322,7 @@ class InvertBooleanNode(io.ComfyNode): node_id="InvertBooleanNode", search_aliases=["not", "toggle", "negate", "flip boolean"], display_name="Invert Boolean", - category="logic", + category="utils/logic", inputs=[io.Boolean.Input("boolean")], outputs=[io.Boolean.Output()], ) @@ -261,6 +337,9 @@ class LogicExtension(ComfyExtension): return [ SwitchNode, CustomComboNode, + NotNode, + AndNode, + OrNode, # SoftSwitchNode, # ConvertStringToComboNode, # DCTestNode, diff --git a/comfy_extras/nodes_math.py b/comfy_extras/nodes_math.py index 6030ee9d8..06aefa475 100644 --- a/comfy_extras/nodes_math.py +++ b/comfy_extras/nodes_math.py @@ -70,7 +70,7 @@ class MathExpressionNode(io.ComfyNode): return io.Schema( node_id="ComfyMathExpression", display_name="Math Expression", - category="logic", + category="utils", search_aliases=[ "expression", "formula", "calculate", "calculator", "eval", "math", diff --git a/comfy_extras/nodes_toolkit.py b/comfy_extras/nodes_toolkit.py index 71faf7226..ae802896b 100644 --- a/comfy_extras/nodes_toolkit.py +++ b/comfy_extras/nodes_toolkit.py @@ -14,7 +14,7 @@ class CreateList(io.ComfyNode): return io.Schema( node_id="CreateList", display_name="Create List", - category="logic", + category="utils", is_input_list=True, search_aliases=["Image Iterator", "Text Iterator", "Iterator"], inputs=[io.Autogrow.Input("inputs", template=template_autogrow)], diff --git a/openapi.yaml b/openapi.yaml index 885231acc..2347bd659 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -104,6 +104,8 @@ paths: responses: "101": description: WebSocket upgrade successful + '401': + description: Unauthorized x-websocket-messages: - type: status schema: @@ -170,6 +172,18 @@ paths: application/json: schema: $ref: "#/components/schemas/PromptInfo" + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' post: operationId: executePrompt tags: [prompt] @@ -195,12 +209,36 @@ paths: schema: $ref: "#/components/schemas/PromptErrorResponse" + '402': + description: Payment required - Insufficient credits + content: + application/json: + schema: + $ref: '#/components/schemas/PromptErrorResponse' + '429': + description: Payment required - User has not paid + content: + application/json: + schema: + $ref: '#/components/schemas/PromptErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/PromptErrorResponse' + '503': + description: Service unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/PromptErrorResponse' # --------------------------------------------------------------------------- # Queue # --------------------------------------------------------------------------- /api/queue: get: - operationId: getQueue + operationId: getQueueInfo tags: [queue] summary: Get running and pending queue items description: Returns the server's current execution queue, split into the currently-running prompt and the list of pending prompts. @@ -211,6 +249,18 @@ paths: application/json: schema: $ref: "#/components/schemas/QueueInfo" + '400': + description: Invalid request parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Invalid request parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' post: operationId: manageQueue tags: [queue] @@ -226,9 +276,27 @@ paths: "200": description: Queue updated + '400': + description: Invalid request parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/interrupt: post: - operationId: interruptExecution + operationId: interruptJob tags: [queue] summary: Interrupt current execution description: Interrupts the prompt that is currently executing. The next queued prompt (if any) will start immediately after. @@ -247,6 +315,18 @@ paths: "200": description: Interrupt signal sent + '401': + description: Unauthorized - Authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/free: post: operationId: freeMemory @@ -327,9 +407,21 @@ paths: pagination: $ref: "#/components/schemas/PaginationInfo" + '401': + description: Unauthorized - Authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/jobs/{job_id}: get: - operationId: getJob + operationId: getJobDetail tags: [queue] summary: Get a single job by ID description: Returns the full record for a single completed prompt execution, including its outputs, status, and metadata. @@ -351,12 +443,30 @@ paths: "404": description: Job not found + '401': + description: Unauthorized - Authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Job does not belong to user + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' # --------------------------------------------------------------------------- # History # --------------------------------------------------------------------------- /api/history: get: - operationId: getHistory + operationId: getPromptHistory tags: [history] summary: Get execution history deprecated: true @@ -388,6 +498,8 @@ paths: type: object additionalProperties: $ref: "#/components/schemas/HistoryEntry" + '404': + description: "Not Found \u2014 use /api/history_v2 instead" post: operationId: manageHistory tags: [history] @@ -409,6 +521,24 @@ paths: "200": description: History updated + '400': + description: Invalid request parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - Authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/history/{prompt_id}: get: operationId: getHistoryByPromptId @@ -438,6 +568,8 @@ paths: additionalProperties: $ref: "#/components/schemas/HistoryEntry" + '404': + description: "Not Found \u2014 use /api/jobs/{prompt_id} instead" # --------------------------------------------------------------------------- # Upload # --------------------------------------------------------------------------- @@ -481,6 +613,18 @@ paths: "400": description: No file provided or invalid request + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/upload/mask: post: operationId: uploadMask @@ -539,6 +683,18 @@ paths: "400": description: No file provided or invalid request + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' # --------------------------------------------------------------------------- # View # --------------------------------------------------------------------------- @@ -601,6 +757,33 @@ paths: "404": description: File not found + '302': + description: Redirect to GCS signed URL + headers: + Location: + description: Signed URL to access the file in GCS + schema: + type: string + Cache-Control: + description: Cache directive for the redirect response + schema: + type: string + Vary: + description: Headers that affect response caching + schema: + type: string + '400': + description: Invalid request parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/view_metadata/{folder_name}: get: operationId: viewMetadata @@ -648,6 +831,12 @@ paths: schema: $ref: "#/components/schemas/SystemStatsResponse" + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/features: get: operationId: getFeatures @@ -706,7 +895,7 @@ paths: # --------------------------------------------------------------------------- /api/object_info: get: - operationId: getObjectInfo + operationId: getNodeInfo tags: [node] summary: Get all node definitions description: | @@ -782,6 +971,8 @@ paths: items: type: string + '404': + description: "Not Found \u2014 use /api/experiment/models instead" /api/models/{folder}: get: operationId: getModelsByFolder @@ -809,7 +1000,7 @@ paths: /api/experiment/models: get: - operationId: getExperimentModels + operationId: getModelFolders tags: [model] summary: List model folders with paths description: Returns an array of model folder objects with name and folder paths. @@ -823,9 +1014,15 @@ paths: items: $ref: "#/components/schemas/ModelFolder" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/experiment/models/{folder}: get: - operationId: getExperimentModelsByFolder + operationId: getModelsInFolder tags: [model] summary: List model files with metadata description: Returns the model files in the given folder with richer metadata (path index, mtime, size) than the legacy `/api/models/{folder}` endpoint. @@ -848,6 +1045,12 @@ paths: "404": description: Unknown folder type + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/experiment/models/preview/{folder}/{path_index}/{filename}: get: operationId: getModelPreview @@ -884,12 +1087,18 @@ paths: "404": description: Preview not found + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' # --------------------------------------------------------------------------- # Users # --------------------------------------------------------------------------- /api/users: get: - operationId: getUsers + operationId: getUsersInfo tags: [user] summary: Get user storage info description: | @@ -917,6 +1126,12 @@ paths: additionalProperties: type: string description: Map of user_id to directory name (multi-user) + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' post: operationId: createUser tags: [user] @@ -952,7 +1167,7 @@ paths: # --------------------------------------------------------------------------- /api/userdata: get: - operationId: listUserdata + operationId: getUserdata tags: [userdata] summary: List files in a userdata directory description: Lists files in the authenticated user's data directory. Returns either filename strings or full objects depending on the `full_info` query parameter. @@ -985,10 +1200,28 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ListUserdataResponse" + $ref: "#/components/schemas/GetUserDataResponseFull" "404": description: Directory not found + '400': + description: Bad request (e.g., invalid filename). + content: + text/plain: + schema: + type: string + '401': + description: Unauthorized. + content: + text/plain: + schema: + type: string + '500': + description: General error + content: + text/plain: + schema: + type: string /api/v2/userdata: get: operationId: listUserdataV2 @@ -1025,6 +1258,8 @@ paths: type: number description: Unix timestamp + '404': + description: "Not Found \u2014 use /api/userdata instead" /api/userdata/{file}: get: operationId: getUserdataFile @@ -1049,8 +1284,26 @@ paths: format: binary "404": description: File not found + '400': + description: Bad request (e.g., invalid filename). + content: + text/plain: + schema: + type: string + '401': + description: Unauthorized. + content: + text/plain: + schema: + type: string + '500': + description: General error + content: + text/plain: + schema: + type: string post: - operationId: writeUserdataFile + operationId: postUserdataFile tags: [userdata] summary: Write or create a userdata file description: Writes (creates or replaces) a file in the authenticated user's data directory. @@ -1087,9 +1340,33 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/UserDataResponse" + $ref: "#/components/schemas/UserDataResponseFull" "409": description: File exists and overwrite not set + '400': + description: Missing or invalid 'file' parameter. + content: + text/plain: + schema: + type: string + '401': + description: Unauthorized. + content: + text/plain: + schema: + type: string + '403': + description: The requested path is not allowed. + content: + text/plain: + schema: + type: string + '500': + description: General error + content: + text/plain: + schema: + type: string delete: operationId: deleteUserdataFile tags: [userdata] @@ -1109,6 +1386,18 @@ paths: "404": description: File not found + '401': + description: Unauthorized. + content: + text/plain: + schema: + type: string + '500': + description: Internal server error. + content: + text/plain: + schema: + type: string /api/userdata/{file}/move/{dest}: post: operationId: moveUserdataFile @@ -1145,18 +1434,36 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/UserDataResponse" + $ref: "#/components/schemas/UserDataResponseFull" "404": description: Source file not found "409": description: Destination exists and overwrite not set + '400': + description: Missing or invalid parameters. + content: + text/plain: + schema: + type: string + '401': + description: Unauthorized. + content: + text/plain: + schema: + type: string + '500': + description: General error + content: + text/plain: + schema: + type: string # --------------------------------------------------------------------------- # Settings # --------------------------------------------------------------------------- /api/settings: get: - operationId: getSettings + operationId: getAllSettings tags: [settings] summary: Get all user settings description: Returns all settings for the authenticated user. @@ -1170,8 +1477,14 @@ paths: schema: type: object additionalProperties: true + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' post: - operationId: updateSettings + operationId: updateMultipleSettings tags: [settings] summary: Update user settings (partial merge) description: Replaces the authenticated user's settings with the provided object. @@ -1189,9 +1502,21 @@ paths: "200": description: Settings updated + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/settings/{id}: get: - operationId: getSetting + operationId: getSettingById tags: [settings] summary: Get a single setting by key description: Returns the value of a single setting, identified by key. @@ -1211,8 +1536,20 @@ paths: schema: nullable: true description: The setting value (any JSON type), or null if not set + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Setting not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' post: - operationId: updateSetting + operationId: updateSettingById tags: [settings] summary: Set a single setting value description: Sets the value of a single setting, identified by key. @@ -1234,6 +1571,18 @@ paths: "200": description: Setting updated + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' # --------------------------------------------------------------------------- # Extensions / Templates / i18n # --------------------------------------------------------------------------- @@ -1308,6 +1657,12 @@ paths: additionalProperties: $ref: "#/components/schemas/GlobalSubgraphInfo" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/global_subgraphs/{id}: get: operationId: getGlobalSubgraph @@ -1331,6 +1686,12 @@ paths: "404": description: Subgraph not found + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' # --------------------------------------------------------------------------- # Node Replacements # --------------------------------------------------------------------------- @@ -1351,6 +1712,12 @@ paths: type: object additionalProperties: true + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' # --------------------------------------------------------------------------- # Internal (x-internal: true) # --------------------------------------------------------------------------- @@ -1454,7 +1821,7 @@ paths: /internal/files/{directory_type}: get: - operationId: getInternalFiles + operationId: getFiles tags: [internal] summary: List files in a directory type description: Lists the files present in one of ComfyUI's known directories (input, output, or temp). @@ -1476,6 +1843,12 @@ paths: items: type: string + '400': + description: Invalid directory type + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' # --------------------------------------------------------------------------- # Assets (x-feature-gate: enable-assets) # --------------------------------------------------------------------------- @@ -1499,6 +1872,24 @@ paths: "404": description: No asset with this hash + '400': + description: Invalid hash format + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/assets: get: operationId: listAssets @@ -1575,8 +1966,26 @@ paths: application/json: schema: $ref: "#/components/schemas/ListAssetsResponse" + '400': + description: Invalid request parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' post: - operationId: createAsset + operationId: uploadAsset tags: [assets] summary: Upload a new asset description: Uploads a new asset (binary content plus metadata) and registers it in the asset database. @@ -1664,6 +2073,60 @@ paths: schema: $ref: "#/components/schemas/AssetCreated" + '200': + description: Asset already exists (returned existing asset) + content: + application/json: + schema: + $ref: '#/components/schemas/AssetCreated' + '400': + description: Invalid request (bad file, invalid URL, invalid content type, etc.) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Source URL requires authentication or access denied + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Source URL not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '413': + description: File too large + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '415': + description: Unsupported media type + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: Download failed due to network error or timeout + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/assets/from-hash: post: operationId: createAssetFromHash @@ -1707,9 +2170,39 @@ paths: schema: $ref: "#/components/schemas/AssetCreated" + '200': + description: Asset reference already exists (returned existing) + content: + application/json: + schema: + $ref: '#/components/schemas/AssetCreated' + '400': + description: Invalid request (bad hash format, invalid tags, etc.) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Source asset with given hash not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/assets/{id}: get: - operationId: getAsset + operationId: getAssetById tags: [assets] summary: Get asset metadata description: Returns the metadata for a single asset. @@ -1731,6 +2224,18 @@ paths: $ref: "#/components/schemas/Asset" "404": description: Asset not found + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' put: operationId: updateAsset tags: [assets] @@ -1775,6 +2280,30 @@ paths: application/json: schema: $ref: "#/components/schemas/AssetUpdated" + '400': + description: Invalid request (no fields provided) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Asset not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' delete: operationId: deleteAsset tags: [assets] @@ -1798,6 +2327,30 @@ paths: "204": description: Asset deleted + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Asset not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: Asset cannot be deleted because it is referenced by another resource (e.g., workflow version) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/assets/{id}/content: get: operationId: getAssetContent @@ -1859,6 +2412,36 @@ paths: application/json: schema: $ref: "#/components/schemas/TagsModificationResponse" + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Asset not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: Validation error (e.g., reserved tag) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' delete: operationId: removeAssetTags tags: [assets] @@ -1894,6 +2477,36 @@ paths: schema: $ref: "#/components/schemas/TagsModificationResponse" + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Asset not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: Validation error (e.g., reserved tag) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/tags: get: operationId: listTags @@ -1923,9 +2536,27 @@ paths: schema: $ref: "#/components/schemas/ListTagsResponse" + '400': + description: Invalid request parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/assets/tags/refine: get: - operationId: refineAssetTags + operationId: getAssetTagHistogram tags: [assets] summary: Get tag counts for assets matching current filters description: Returns suggested additional tags that would refine a filtered asset query, together with the count of assets each tag would select. @@ -1986,6 +2617,24 @@ paths: schema: $ref: "#/components/schemas/AssetTagHistogramResponse" + '400': + description: Invalid request parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/assets/seed: post: operationId: seedAssets @@ -2103,7 +2752,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/CloudJobStatus" + $ref: "#/components/schemas/JobCancelResponse" "401": description: Unauthorized content: @@ -2117,9 +2766,21 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '400': + description: Bad Request - job_id is not a valid UUID (emitted by request validation before the handler runs) + content: + application/json: + schema: + $ref: '#/components/schemas/BindingErrorResponse' + '500': + description: Internal server error - cancellation failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/job/{job_id}/status: get: - operationId: getCloudJobStatus + operationId: getJobStatus tags: [queue] summary: Get status of a cloud job deprecated: true @@ -2142,7 +2803,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/CloudJobStatus" + $ref: "#/components/schemas/JobStatusResponse" "401": description: Unauthorized content: @@ -2156,6 +2817,18 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '403': + description: Forbidden - job belongs to another user + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/prompt/{prompt_id}: get: operationId: getCloudPrompt @@ -2193,7 +2866,7 @@ paths: /api/history_v2: get: - operationId: getHistoryV2 + operationId: getHistory tags: [history] summary: Get paginated execution history (v2) deprecated: true @@ -2226,7 +2899,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/HistoryV2Response" + $ref: "#/components/schemas/HistoryResponse" "401": description: Unauthorized content: @@ -2234,9 +2907,15 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/history_v2/{prompt_id}: get: - operationId: getHistoryV2ByPromptId + operationId: getHistoryForPrompt tags: [history] summary: Get v2 history for a specific prompt deprecated: true @@ -2259,7 +2938,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/HistoryV2Entry" + $ref: "#/components/schemas/HistoryDetailResponse" "401": description: Unauthorized content: @@ -2273,9 +2952,15 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/logs: get: - operationId: getCloudLogs + operationId: getLogs tags: [system] summary: Get cloud execution logs deprecated: true @@ -2309,7 +2994,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/CloudLogsResponse" + $ref: "#/components/schemas/LogsResponse" "401": description: Unauthorized content: @@ -2322,7 +3007,7 @@ paths: # --------------------------------------------------------------------------- /api/assets/download: post: - operationId: downloadAssets + operationId: createAssetDownload tags: [assets] summary: Download assets to cloud runtime description: "[cloud-only] Initiates a download of one or more assets to the cloud runtime environment. Returns a task ID for tracking download progress via WebSocket." @@ -2342,16 +3027,27 @@ paths: $ref: "#/components/schemas/AssetDownloadRequest" description: Assets to download responses: - "200": - description: Download initiated + "202": + description: Download task accepted content: application/json: schema: type: object + required: + - task_id + - status properties: task_id: type: string - description: Task ID for tracking progress via WebSocket + format: uuid + description: ID of the download task; use to poll status. + status: + type: string + enum: [created, running, completed, failed] + description: Current task status (typically `created` on initial creation). + message: + type: string + description: Human-readable task message. "400": description: Bad request content: @@ -2365,9 +3061,27 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '200': + description: File already exists in storage - asset created/returned immediately + content: + application/json: + schema: + $ref: '#/components/schemas/AssetCreated' + '422': + description: Validation errors + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/assets/export: post: - operationId: exportAssets + operationId: createAssetExport tags: [assets] summary: Export assets as a downloadable archive description: "[cloud-only] Initiates a bulk export of assets. Returns a task ID for tracking progress via WebSocket. When complete, the export can be downloaded via the exports endpoint." @@ -2391,17 +3105,27 @@ paths: type: string description: Name for the export archive responses: - "200": - description: Export initiated + "202": + description: Export task accepted content: application/json: schema: type: object + required: + - task_id + - status properties: task_id: type: string - export_name: + format: uuid + description: ID of the export task; use to poll status. + status: type: string + enum: [created, running, completed, failed] + description: Current task status (typically `created` on initial creation). + message: + type: string + description: Human-readable task message. "400": description: Bad request content: @@ -2415,6 +3139,12 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/assets/exports/{exportName}: get: operationId: getAssetExport @@ -2450,9 +3180,21 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '400': + description: Invalid export name + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/assets/from-workflow: post: - operationId: createAssetsFromWorkflow + operationId: postAssetsFromWorkflow tags: [assets] summary: Create asset records from a workflow execution description: "[cloud-only] Registers output files from a workflow execution as assets in the asset database." @@ -2476,8 +3218,8 @@ paths: type: string description: Tags to apply to the created assets responses: - "201": - description: Assets created + "200": + description: Assets created or referenced content: application/json: schema: @@ -2506,6 +3248,12 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/assets/import: post: operationId: importPublishedAssets @@ -2540,9 +3288,15 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/assets/remote-metadata: get: - operationId: getAssetRemoteMetadata + operationId: getRemoteAssetMetadata tags: [assets] summary: Fetch metadata for a remote asset URL description: "[cloud-only] Fetches and returns metadata (content type, size, filename) for a remote URL without downloading the full content." @@ -2561,7 +3315,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/RemoteAssetMetadata" + $ref: "#/components/schemas/AssetMetadataResponse" "400": description: Bad request content: @@ -2575,6 +3329,18 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '422': + description: Failed to retrieve metadata from source + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' # --------------------------------------------------------------------------- # Custom nodes / hub (cloud) # --------------------------------------------------------------------------- @@ -2730,7 +3496,7 @@ paths: /api/hub/assets/upload-url: post: - operationId: getHubAssetUploadUrl + operationId: createHubAssetUploadUrl tags: [hub] summary: Get a pre-signed upload URL for a hub asset description: "[cloud-only] Returns a pre-signed URL that can be used to upload an asset file directly to storage." @@ -2784,6 +3550,18 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/hub/labels: get: operationId: listHubLabels @@ -2801,6 +3579,18 @@ paths: items: $ref: "#/components/schemas/HubLabel" + '400': + description: Bad request (e.g. invalid type parameter) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/hub/profiles: get: operationId: listHubProfiles @@ -2884,6 +3674,12 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/hub/profiles/{username}: get: operationId: getHubProfile @@ -2912,9 +3708,15 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/hub/profiles/check: get: - operationId: checkHubProfileUsername + operationId: checkHubUsername tags: [hub] summary: Check if a hub username is available description: "[cloud-only] Returns whether the given username is available for registration." @@ -2939,6 +3741,24 @@ paths: username: type: string + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/hub/profiles/me: get: operationId: getMyHubProfile @@ -2959,6 +3779,18 @@ paths: application/json: schema: $ref: "#/components/schemas/CloudError" + '404': + description: No hub profile exists + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' put: operationId: updateMyHubProfile tags: [hub] @@ -3057,7 +3889,25 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/HubWorkflowList" + $ref: "#/components/schemas/HubWorkflowListResponse" + '400': + description: Bad request (e.g. malformed pagination cursor) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Profile not found (when filtering by username) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' post: operationId: publishHubWorkflow tags: [hub] @@ -3096,6 +3946,12 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/hub/workflows/{share_id}: get: operationId: getHubWorkflow @@ -3116,13 +3972,25 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/HubWorkflow" + $ref: "#/components/schemas/HubWorkflowDetail" "404": description: Not found content: application/json: schema: $ref: "#/components/schemas/CloudError" + '413': + description: Workflow JSON too large + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' delete: operationId: deleteHubWorkflow tags: [hub] @@ -3152,9 +4020,15 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/hub/workflows/index: get: - operationId: getHubWorkflowIndex + operationId: listHubWorkflowIndex tags: [hub] summary: Get the hub workflow index description: "[cloud-only] Returns the lightweight index of all hub workflows for client-side search and navigation." @@ -3169,12 +4043,18 @@ paths: items: $ref: "#/components/schemas/HubWorkflowIndexEntry" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' # --------------------------------------------------------------------------- # Workflows (cloud) # --------------------------------------------------------------------------- /api/workflows: get: - operationId: listCloudWorkflows + operationId: listWorkflows tags: [workflows] summary: List cloud workflows description: "[cloud-only] Returns a paginated list of the authenticated user's cloud workflows." @@ -3212,15 +4092,21 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/CloudWorkflowList" + $ref: "#/components/schemas/WorkflowListResponse" "401": description: Unauthorized content: application/json: schema: $ref: "#/components/schemas/CloudError" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' post: - operationId: createCloudWorkflow + operationId: createWorkflow tags: [workflows] summary: Create a new cloud workflow description: "[cloud-only] Creates a new cloud workflow with the provided name and optional initial content." @@ -3250,7 +4136,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/CloudWorkflow" + $ref: "#/components/schemas/WorkflowResponse" "400": description: Bad request content: @@ -3264,9 +4150,21 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '422': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/workflows/{workflow_id}: get: - operationId: getCloudWorkflow + operationId: getWorkflow tags: [workflows] summary: Get a cloud workflow by ID description: "[cloud-only] Returns the metadata for a cloud workflow." @@ -3285,7 +4183,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/CloudWorkflow" + $ref: "#/components/schemas/WorkflowResponse" "401": description: Unauthorized content: @@ -3298,8 +4196,20 @@ paths: application/json: schema: $ref: "#/components/schemas/CloudError" + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' patch: - operationId: updateCloudWorkflow + operationId: updateWorkflow tags: [workflows] summary: Update a cloud workflow description: "[cloud-only] Updates the metadata (name, description) of an existing cloud workflow." @@ -3329,7 +4239,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/CloudWorkflow" + $ref: "#/components/schemas/WorkflowResponse" "400": description: Bad request content: @@ -3348,8 +4258,20 @@ paths: application/json: schema: $ref: "#/components/schemas/CloudError" + '422': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' delete: - operationId: deleteCloudWorkflow + operationId: deleteWorkflow tags: [workflows] summary: Delete a cloud workflow description: "[cloud-only] Deletes a cloud workflow and all its versions." @@ -3378,9 +4300,15 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/workflows/{workflow_id}/content: get: - operationId: getCloudWorkflowContent + operationId: getWorkflowContent tags: [workflows] summary: Get the content of a cloud workflow description: "[cloud-only] Returns the full workflow graph JSON for the latest version of a cloud workflow." @@ -3419,6 +4347,18 @@ paths: application/json: schema: $ref: "#/components/schemas/CloudError" + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' put: operationId: updateCloudWorkflowContent tags: [workflows] @@ -3469,7 +4409,7 @@ paths: /api/workflows/{workflow_id}/fork: post: - operationId: forkCloudWorkflow + operationId: forkWorkflow tags: [workflows] summary: Fork a cloud workflow description: "[cloud-only] Creates a copy of a cloud workflow under the authenticated user's account." @@ -3498,7 +4438,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/CloudWorkflow" + $ref: "#/components/schemas/WorkflowResponse" "401": description: Unauthorized content: @@ -3512,6 +4452,24 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/workflows/{workflow_id}/versions: get: operationId: listCloudWorkflowVersions @@ -3566,7 +4524,7 @@ paths: schema: $ref: "#/components/schemas/CloudError" post: - operationId: createCloudWorkflowVersion + operationId: createWorkflowVersion tags: [workflows] summary: Create a new cloud workflow version description: "[cloud-only] Creates a new workflow version with updated workflow JSON. Uses optimistic concurrency via base_version." @@ -3617,6 +4575,18 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '422': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/workflows/published/{share_id}: get: operationId: getPublishedWorkflow @@ -3637,7 +4607,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/CloudWorkflow" + $ref: "#/components/schemas/PublishedWorkflowDetail" "404": description: Not found content: @@ -3645,6 +4615,24 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '413': + description: Workflow JSON too large + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' # --------------------------------------------------------------------------- # Auth / session (cloud) # --------------------------------------------------------------------------- @@ -3669,7 +4657,7 @@ paths: schema: $ref: "#/components/schemas/CloudError" post: - operationId: createAuthSession + operationId: createSession tags: [auth] summary: Create a session cookie description: "[cloud-only] Creates a session cookie from the bearer token in the Authorization header. Returns a Set-Cookie header with a secure HttpOnly session cookie. Cookie authentication is not allowed for this endpoint." @@ -3693,8 +4681,14 @@ paths: application/json: schema: $ref: "#/components/schemas/CloudError" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' delete: - operationId: deleteAuthSession + operationId: deleteSession tags: [auth] summary: Delete session cookie (logout) description: "[cloud-only] Clears the session cookie and optionally revokes the session on the server." @@ -3707,9 +4701,15 @@ paths: schema: $ref: "#/components/schemas/DeleteSessionResponse" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/auth/token: post: - operationId: createAuthToken + operationId: exchangeToken tags: [auth] summary: Exchange credentials for an access token description: "[cloud-only] Exchanges authentication credentials (e.g. an authorization code) for an access token." @@ -3743,7 +4743,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AuthTokenResponse" + $ref: "#/components/schemas/ExchangeTokenResponse" "400": description: Bad request content: @@ -3757,6 +4757,18 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '404': + description: Workspace not found or user not a member + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /.well-known/jwks.json: get: operationId: getJwks @@ -4077,7 +5089,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/BillingBalance" + $ref: "#/components/schemas/BillingBalanceResponse" "401": description: Unauthorized content: @@ -4085,9 +5097,15 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/billing/events: get: - operationId: listBillingEvents + operationId: getBillingEvents tags: [billing] summary: List billing events description: "[cloud-only] Returns a paginated list of billing events (charges, credits, refunds) for the authenticated user." @@ -4114,7 +5132,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/BillingEventList" + $ref: "#/components/schemas/BillingEventsResponse" "401": description: Unauthorized content: @@ -4122,9 +5140,15 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/billing/ops/{id}: get: - operationId: getBillingOp + operationId: getBillingOpStatus tags: [billing] summary: Get a billing operation by ID description: "[cloud-only] Returns details of a specific billing operation." @@ -4142,7 +5166,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/BillingOp" + $ref: "#/components/schemas/BillingOpStatusResponse" "401": description: Unauthorized content: @@ -4156,9 +5180,15 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/billing/payment-portal: post: - operationId: createPaymentPortalSession + operationId: getPaymentPortal tags: [billing] summary: Create a payment portal session description: "[cloud-only] Creates a Stripe customer portal session for managing payment methods and invoices. Returns a URL to redirect the user to." @@ -4182,9 +5212,21 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '400': + description: Bad request (e.g., missing return_url) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/billing/plans: get: - operationId: listBillingPlans + operationId: getBillingPlans tags: [billing] summary: List available billing plans description: "[cloud-only] Returns the list of available subscription plans and their pricing." @@ -4199,9 +5241,21 @@ paths: items: $ref: "#/components/schemas/BillingPlan" + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/billing/preview-subscribe: post: - operationId: previewSubscription + operationId: previewSubscribe tags: [billing] summary: Preview a subscription change description: "[cloud-only] Returns a preview of what a subscription change would cost, including prorations." @@ -4224,7 +5278,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/SubscriptionPreview" + $ref: "#/components/schemas/PreviewSubscribeResponse" "400": description: Bad request content: @@ -4238,6 +5292,12 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/billing/status: get: operationId: getBillingStatus @@ -4251,7 +5311,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/BillingStatus" + $ref: "#/components/schemas/BillingStatusResponse" "401": description: Unauthorized content: @@ -4259,9 +5319,21 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '404': + description: Workspace not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/billing/subscribe: post: - operationId: createSubscription + operationId: subscribe tags: [billing] summary: Subscribe to a billing plan description: "[cloud-only] Creates a new subscription to the specified billing plan." @@ -4287,7 +5359,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/BillingSubscription" + $ref: "#/components/schemas/SubscribeResponse" "400": description: Bad request content: @@ -4301,6 +5373,12 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/billing/subscription/cancel: post: operationId: cancelSubscription @@ -4314,7 +5392,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/BillingSubscription" + $ref: "#/components/schemas/CancelSubscriptionResponse" "401": description: Unauthorized content: @@ -4322,6 +5400,18 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '400': + description: Invalid request (e.g., no active subscription) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/billing/subscription/resubscribe: post: operationId: resubscribe @@ -4335,7 +5425,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/BillingSubscription" + $ref: "#/components/schemas/ResubscribeResponse" "401": description: Unauthorized content: @@ -4343,9 +5433,21 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '400': + description: Invalid request (e.g., no active subscription, not in cancellation grace period) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/billing/topup: post: - operationId: topUpCredits + operationId: createTopup tags: [billing] summary: Purchase additional credits description: "[cloud-only] Purchases a one-time credit top-up using the user's payment method on file." @@ -4368,7 +5470,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/BillingBalance" + $ref: "#/components/schemas/CreateTopupResponse" "400": description: Bad request content: @@ -4382,12 +5484,18 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' # --------------------------------------------------------------------------- # Workspace (cloud) # --------------------------------------------------------------------------- /api/workspace/api-keys: get: - operationId: listWorkspaceApiKeys + operationId: listWorkspaceAPIKeys tags: [workspace] summary: List workspace API keys description: "[cloud-only] Returns the list of API keys for the current workspace." @@ -4413,8 +5521,14 @@ paths: application/json: schema: $ref: "#/components/schemas/CloudError" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' post: - operationId: createWorkspaceApiKey + operationId: createWorkspaceAPIKey tags: [workspace] summary: Create a workspace API key description: "[cloud-only] Creates a new API key for the current workspace." @@ -4441,7 +5555,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/WorkspaceApiKeyCreated" + $ref: "#/components/schemas/CreateWorkspaceAPIKeyResponse" "400": description: Bad request content: @@ -4461,9 +5575,33 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '404': + description: Workspace not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '429': + description: Key limit reached + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/workspace/api-keys/{id}: delete: - operationId: deleteWorkspaceApiKey + operationId: revokeWorkspaceAPIKey tags: [workspace] summary: Delete a workspace API key description: "[cloud-only] Revokes and deletes a workspace API key." @@ -4497,6 +5635,12 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/workspace/invites: get: operationId: listWorkspaceInvites @@ -4525,6 +5669,12 @@ paths: application/json: schema: $ref: "#/components/schemas/CloudError" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' post: operationId: createWorkspaceInvite tags: [workspace] @@ -4554,7 +5704,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/WorkspaceInvite" + $ref: "#/components/schemas/PendingInvite" "400": description: Bad request content: @@ -4580,9 +5730,27 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '404': + description: Workspace not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/workspace/invites/{inviteId}: delete: - operationId: deleteWorkspaceInvite + operationId: revokeWorkspaceInvite tags: [workspace] summary: Cancel a workspace invite description: "[cloud-only] Cancels a pending workspace invitation." @@ -4616,6 +5784,12 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/workspace/leave: post: operationId: leaveWorkspace @@ -4639,6 +5813,18 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '404': + description: Workspace not found or not a member + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/workspace/members: get: operationId: listWorkspaceMembers @@ -4668,6 +5854,24 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '404': + description: Workspace not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/workspace/members/{user_id}/api-keys: get: operationId: listMemberApiKeys @@ -4710,7 +5914,7 @@ paths: schema: $ref: "#/components/schemas/CloudError" delete: - operationId: bulkRevokeMemberApiKeys + operationId: bulkRevokeWorkspaceMemberAPIKeys tags: [workspace] summary: Bulk revoke a member's API keys description: "[cloud-only] Revokes all active API keys for a specific workspace member. Only workspace owners can perform this action." @@ -4743,6 +5947,18 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '422': + description: Validation error (e.g. empty user_id) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/workspace/members/{userId}: patch: operationId: updateWorkspaceMember @@ -4836,6 +6052,12 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/workspaces: get: operationId: listWorkspaces @@ -4858,6 +6080,18 @@ paths: application/json: schema: $ref: "#/components/schemas/CloudError" + '404': + description: Feature not enabled for user + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' post: operationId: createWorkspace tags: [workspace] @@ -4896,6 +6130,24 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '404': + description: Feature not enabled for user + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/workspaces/{id}: get: operationId: getWorkspace @@ -4935,6 +6187,12 @@ paths: application/json: schema: $ref: "#/components/schemas/CloudError" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' patch: operationId: updateWorkspace tags: [workspace] @@ -4989,6 +6247,18 @@ paths: application/json: schema: $ref: "#/components/schemas/CloudError" + '422': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' delete: operationId: deleteWorkspace tags: [workspace] @@ -5024,6 +6294,12 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' # --------------------------------------------------------------------------- # User / settings / misc (cloud) # --------------------------------------------------------------------------- @@ -5039,24 +6315,9 @@ paths: content: application/json: schema: - type: object - required: - - message - properties: - message: - type: string - description: Feedback message - rating: - type: integer - minimum: 1 - maximum: 5 - description: Optional satisfaction rating - context: - type: object - additionalProperties: true - description: Additional context metadata + $ref: "#/components/schemas/FeedbackRequest" responses: - "200": + "201": description: Feedback submitted content: application/json: @@ -5080,6 +6341,12 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/files/mask-layers: get: operationId: getMaskLayers @@ -5178,9 +6445,15 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/invites/{token}/accept: post: - operationId: acceptInvite + operationId: acceptWorkspaceInvite tags: [workspace] summary: Accept a workspace invitation description: "[cloud-only] Accepts a workspace invitation using the invite token. The authenticated user is added to the workspace." @@ -5198,7 +6471,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/Workspace" + $ref: "#/components/schemas/AcceptInviteResponse" "400": description: Bad request content: @@ -5218,6 +6491,24 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '403': + description: Email does not match invite + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: Already a member of this workspace + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/secrets: get: operationId: listSecrets @@ -5240,6 +6531,18 @@ paths: application/json: schema: $ref: "#/components/schemas/CloudError" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '503': + description: Service unavailable - feature is disabled + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' post: operationId: createSecret tags: [settings] @@ -5268,7 +6571,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/SecretMeta" + $ref: "#/components/schemas/SecretResponse" "400": description: Bad request content: @@ -5282,6 +6585,30 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '409': + description: Conflict - secret with this name or provider already exists + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '503': + description: Service unavailable - secrets feature disabled + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/secrets/{id}: get: operationId: getSecret @@ -5303,7 +6630,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/SecretMeta" + $ref: "#/components/schemas/SecretResponse" "401": description: Unauthorized content: @@ -5316,6 +6643,24 @@ paths: application/json: schema: $ref: "#/components/schemas/CloudError" + '403': + description: Forbidden - user does not own this secret + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '503': + description: Service unavailable - secrets feature disabled + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' patch: operationId: updateSecret tags: [settings] @@ -5342,7 +6687,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/SecretMeta" + $ref: "#/components/schemas/SecretResponse" "400": description: Bad request content: @@ -5367,6 +6712,24 @@ paths: application/json: schema: $ref: "#/components/schemas/CloudError" + '403': + description: Forbidden - user does not own this secret + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '503': + description: Service unavailable - secrets feature disabled + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' delete: operationId: deleteSecret tags: [settings] @@ -5396,9 +6759,27 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '403': + description: Forbidden - user does not own this secret + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '503': + description: Service unavailable - secrets feature disabled + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/user: get: - operationId: getCloudUser + operationId: getUser tags: [user] summary: Get the authenticated cloud user description: "[cloud-only] Returns the profile and account information for the currently authenticated user." @@ -5409,7 +6790,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/CloudUser" + $ref: "#/components/schemas/UserResponse" "401": description: Unauthorized content: @@ -5487,8 +6868,14 @@ paths: application/json: schema: $ref: "#/components/schemas/CloudError" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' post: - operationId: publishUserdataFile + operationId: postUserdataFilePublish tags: [userdata] summary: Publish a userdata file to the cloud description: "[cloud-only] Makes a userdata file available via a public URL for sharing or embedding." @@ -5525,9 +6912,21 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/vhs/queryvideo: get: - operationId: queryVhsVideo + operationId: getVhsQueryVideo tags: [view] summary: Query VHS video metadata description: "[cloud-only] Returns metadata about a video file processed by the VHS (Video Helper Suite) integration." @@ -5571,6 +6970,15 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '400': + description: 'Missing required query parameter. Produced by the oapi-codegen + wrapper via echo.NewHTTPError, so the body shape matches Echo''s + default HTTPError serialization rather than ErrorResponse. + ' + content: + application/json: + schema: + $ref: '#/components/schemas/BindingErrorResponse' /api/vhs/viewaudio: get: operationId: viewVhsAudio @@ -5791,6 +7199,12 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/tasks/{task_id}: get: operationId: getTask @@ -5826,6 +7240,12 @@ paths: schema: $ref: "#/components/schemas/CloudError" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' components: parameters: ComfyUserHeader: @@ -6100,16 +7520,25 @@ components: description: Unique job identifier (same as prompt_id) status: type: string + enum: + - pending + - in_progress + - completed + - failed + - cancelled description: Current job status create_time: - type: number - description: Job creation timestamp + type: integer + format: int64 + description: Job creation timestamp (Unix milliseconds). execution_start_time: - type: number - description: Workflow execution start timestamp + type: integer + format: int64 + description: Workflow execution start timestamp (Unix milliseconds, terminal states only). execution_end_time: - type: number - description: Workflow execution end timestamp + type: integer + format: int64 + description: Workflow execution end timestamp (Unix milliseconds, terminal states only). preview_output: type: object additionalProperties: true @@ -6130,6 +7559,12 @@ components: format: uuid status: type: string + enum: + - pending + - in_progress + - completed + - failed + - cancelled workflow: type: object additionalProperties: true @@ -6141,13 +7576,21 @@ components: execution_error: $ref: "#/components/schemas/ExecutionError" create_time: - type: number + type: integer + format: int64 + description: Job creation timestamp (Unix milliseconds). update_time: - type: number + type: integer + format: int64 + description: Last state-change timestamp (Unix milliseconds). execution_start_time: - type: number + type: integer + format: int64 + description: Workflow execution start timestamp (Unix milliseconds, terminal states only). execution_end_time: - type: number + type: integer + format: int64 + description: Workflow execution end timestamp (Unix milliseconds, terminal states only). preview_output: type: object additionalProperties: true @@ -8152,6 +9595,12 @@ components: $ref: "#/components/schemas/BillingBalance" has_payment_method: type: boolean + enum: + - awaiting_payment_method + - pending_payment + - paid + - payment_failed + - inactive BillingSubscription: type: object @@ -8213,6 +9662,12 @@ components: type: string name: type: string + type: + type: string + enum: + - personal + - team + description: Workspace type (personal vs. team). owner_id: type: string member_count: @@ -8723,4 +10178,1521 @@ components: items: $ref: "#/components/schemas/TaskEntry" pagination: - $ref: "#/components/schemas/PaginationInfo" \ No newline at end of file + $ref: "#/components/schemas/PaginationInfo" + + # ===== Cloud-only schemas (Comfy-Org/cloud runtime, BE-1106) ===== + AssetDownloadResponse: + type: object + x-runtime: [cloud] + description: '[cloud-only] Acknowledgement of an async asset download task; clients poll GET /api/tasks/{task_id} for status.' + required: + - task_id + - status + properties: + task_id: + type: string + format: uuid + description: Task ID for tracking download progress via GET /api/tasks/{task_id} + status: + type: string + enum: + - created + - running + - completed + - failed + description: Current task status + message: + type: string + description: Human-readable message + example: Download task created. Use task_id to track progress. + + AssetMetadataResponse: + type: object + x-runtime: [cloud] + description: '[cloud-only] Metadata for a remotely hosted asset resolved by URL.' + required: + - content_length + properties: + content_length: + type: integer + format: int64 + description: Size of the asset in bytes (-1 if unknown) + example: 4294967296 + content_type: + type: string + description: MIME type of the asset + example: application/octet-stream + filename: + type: string + description: Suggested filename for the asset from source + example: realistic-vision-v5.safetensors + name: + type: string + description: Display name or title for the asset from source + example: Realistic Vision v5.0 + tags: + type: array + items: + type: string + description: Tags for categorization from source + example: + - models + - checkpoint + preview_image: + type: string + description: Preview image as base64-encoded data URL + example: data:image/jpeg;base64,/9j/4AAQSkZJRg... + validation: + description: Validation results for the file + allOf: + - $ref: '#/components/schemas/ValidationResult' + + BillingBalanceResponse: + type: object + x-runtime: [cloud] + description: '[cloud-only] Current credit balance and usage details for a workspace.' + required: + - amount_micros + - currency + properties: + amount_micros: + type: number + format: double + description: The total remaining balance in microamount (1/1,000,000 of the currency unit) + prepaid_balance_micros: + type: number + format: double + description: The remaining balance from prepaid commits in microamount + cloud_credit_balance_micros: + type: number + format: double + description: The remaining balance from cloud credits in microamount + pending_charges_micros: + type: number + format: double + description: The total amount of pending/unbilled charges from draft invoices in microamount + effective_balance_micros: + type: number + format: double + description: The effective balance (total balance minus pending charges). Can be negative if pending charges exceed + the balance. + currency: + type: string + example: usd + description: Currency code + + BillingPlansResponse: + type: object + x-runtime: [cloud] + description: '[cloud-only] List of available billing plans for subscription.' + required: + - plans + properties: + current_plan_slug: + type: string + description: Current plan slug if subscribed + plans: + type: array + items: + $ref: '#/components/schemas/Plan' + + BillingStatusResponse: + type: object + x-runtime: [cloud] + description: '[cloud-only] Current billing and subscription status for a workspace.' + required: + - is_active + - has_funds + properties: + is_active: + type: boolean + description: Whether the workspace has an active subscription + subscription_status: + type: string + enum: + - active + - ended + - canceled + description: Subscription activity status (scheduled subscriptions are not returned) + subscription_tier: + $ref: '#/components/schemas/SubscriptionTier' + subscription_duration: + $ref: '#/components/schemas/SubscriptionDuration' + plan_slug: + type: string + description: Plan identifier (e.g., standard-monthly, team-pro-annual) + billing_status: + $ref: '#/components/schemas/BillingStatus' + has_funds: + type: boolean + description: Whether the workspace has available credits + cancel_at: + type: string + format: date-time + description: When the subscription will become inactive (if canceled) + renewal_date: + type: string + format: date-time + description: When the current billing period ends and the next one begins + + GetUserDataResponseFull: + type: array + x-runtime: [cloud] + description: '[cloud-only] List of user data file entries (each with path, size, and modification time) returned when full_info=true.' + items: + $ref: '#/components/schemas/GetUserDataResponseFullFile' + + HistoryDetailEntry: + type: object + x-runtime: [cloud] + description: '[cloud-only] History entry with full prompt data' + properties: + prompt: + type: object + description: Full prompt execution data + properties: + priority: + type: number + format: double + description: Execution priority + prompt_id: + type: string + description: The prompt ID + prompt: + type: object + description: The workflow nodes + additionalProperties: true + extra_data: + type: object + description: Additional execution data + additionalProperties: true + outputs_to_execute: + type: array + items: + type: string + description: Output nodes to execute + outputs: + type: object + description: Output data from execution (generated images, files, etc.) + additionalProperties: true + status: + type: object + description: Execution status and timeline information + additionalProperties: true + meta: + type: object + description: Metadata about the execution and nodes + additionalProperties: true + + HistoryDetailResponse: + type: object + x-runtime: [cloud] + description: '[cloud-only] Detailed execution history response for a specific prompt. + + Returns a dictionary with prompt_id as key and full history data as value. + + ' + additionalProperties: + $ref: '#/components/schemas/HistoryDetailEntry' + + HistoryResponse: + type: object + x-runtime: [cloud] + description: '[cloud-only] Execution history response with history array. + + Returns an object with a "history" key containing an array of history entries. + + Each entry includes prompt_id as a property along with execution data. + + ' + required: + - history + properties: + history: + type: array + description: Array of history entries ordered by creation time (newest first) + items: + $ref: '#/components/schemas/HistoryEntry' + + HubLabelInfo: + type: object + x-runtime: [cloud] + description: '[cloud-only] Metadata for a single Hub label.' + required: + - name + - display_name + - type + properties: + name: + type: string + description: Slug identifier. + display_name: + type: string + description: Human-readable display name. + description: + type: string + description: Optional description of the label. + type: + type: string + enum: + - tag + - model + - custom_node + description: Label category. + + HubProfileSummary: + type: object + x-runtime: [cloud] + description: '[cloud-only] Abbreviated Hub profile used in workflow listings.' + required: + - username + properties: + username: + type: string + display_name: + type: string + avatar_url: + type: string + description: Public URL of the profile avatar image. + + HubWorkflowListResponse: + type: object + x-runtime: [cloud] + description: '[cloud-only] Paginated list of Hub workflows matching search criteria.' + required: + - workflows + properties: + workflows: + type: array + items: + anyOf: + - $ref: '#/components/schemas/HubWorkflowSummary' + - $ref: '#/components/schemas/HubWorkflowDetail' + description: Array of HubWorkflowSummary (default) or HubWorkflowDetail (when detail=true). + next_cursor: + type: string + description: Cursor for the next page, empty if no more results. + + HubWorkflowStatus: + type: string + x-runtime: [cloud] + description: '[cloud-only] Public workflow status. NULL in the database is represented as pending in API responses.' + enum: + - pending + - approved + - rejected + - deprecated + + HubWorkflowSummary: + type: object + x-runtime: [cloud] + description: '[cloud-only] Abbreviated Hub workflow metadata used in search and listing results.' + required: + - share_id + - name + - profile + - status + properties: + share_id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/HubWorkflowStatus' + description: + type: string + tags: + type: array + items: + $ref: '#/components/schemas/LabelRef' + models: + type: array + items: + $ref: '#/components/schemas/LabelRef' + custom_nodes: + type: array + items: + $ref: '#/components/schemas/LabelRef' + thumbnail_type: + type: string + enum: + - image + - video + - image_comparison + thumbnail_url: + type: string + thumbnail_comparison_url: + type: string + publish_time: + type: string + format: date-time + nullable: true + profile: + $ref: '#/components/schemas/HubProfileSummary' + metadata: + type: object + additionalProperties: true + tutorial_url: + type: string + sample_image_urls: + type: array + items: + type: string + + HubWorkflowTemplateEntry: + type: object + x-runtime: [cloud] + description: '[cloud-only] Entry in the curated workflow template gallery shown on the home page.' + required: + - name + - title + - status + properties: + name: + type: string + description: Slug identifier for the template + title: + type: string + status: + $ref: '#/components/schemas/HubWorkflowStatus' + description: + type: string + tags: + type: array + items: + type: string + models: + type: array + items: + type: string + requiresCustomNodes: + type: array + items: + type: string + thumbnailVariant: + type: string + mediaType: + type: string + mediaSubtype: + type: string + size: + type: integer + format: int64 + description: Workflow asset size in bytes. + vram: + type: integer + format: int64 + description: Approximate VRAM requirement in bytes. + usage: + type: integer + format: int64 + description: Usage count reported upstream. + searchRank: + type: integer + format: int64 + description: Search ranking score reported upstream. + isEssential: + type: boolean + description: Whether the template belongs to a module marked as essential. + openSource: + type: boolean + profile: + $ref: '#/components/schemas/HubProfileSummary' + tutorialUrl: + type: string + logos: + type: array + items: + type: object + additionalProperties: true + date: + type: string + description: Publication date in YYYY-MM-DD format + io: + type: object + properties: + inputs: + type: array + items: + type: object + additionalProperties: true + outputs: + type: array + items: + type: object + additionalProperties: true + includeOnDistributions: + type: array + items: + type: string + thumbnailUrl: + type: string + description: Public URL of the primary thumbnail + thumbnailComparisonUrl: + type: string + description: Public URL of the comparison thumbnail + shareId: + type: string + description: Share ID for linking to the hub workflow detail + extendedDescription: + type: string + description: AI-generated extended description of the workflow + metaDescription: + type: string + description: AI-generated SEO meta description (under 160 chars) + howToUse: + type: array + items: + type: string + description: AI-generated step-by-step usage instructions + suggestedUseCases: + type: array + items: + type: string + description: AI-generated suggested use cases + faqItems: + type: array + items: + type: object + required: + - question + - answer + properties: + question: + type: string + answer: + type: string + description: AI-generated FAQ items + contentTemplate: + type: string + description: Content template used for generation (tutorial, showcase, comparison, breakthrough) + + JobStatusResponse: + type: object + x-runtime: [cloud] + description: '[cloud-only] Job status information' + properties: + id: + type: string + format: uuid + description: The job ID + status: + type: string + enum: + - waiting_to_dispatch + - pending + - in_progress + - completed + - error + - cancelled + description: Current job status + created_at: + type: string + format: date-time + description: When the job was created + updated_at: + type: string + format: date-time + description: When the job was last updated + last_state_update: + type: string + format: date-time + description: When the job status was last changed + assigned_inference: + type: string + nullable: true + description: The inference instance assigned to this job (if any) + error_message: + type: string + nullable: true + description: Error message if the job failed + required: + - id + - status + - created_at + - updated_at + + JobsListResponse: + type: object + x-runtime: [cloud] + description: '[cloud-only] Paginated list of jobs for the authenticated user.' + required: + - jobs + - pagination + properties: + jobs: + type: array + description: Array of jobs ordered by specified sort field + items: + $ref: '#/components/schemas/JobEntry' + pagination: + $ref: '#/components/schemas/PaginationInfo' + + LabelRef: + type: object + x-runtime: [cloud] + description: '[cloud-only] Reference to a Hub label by ID.' + required: + - name + - display_name + properties: + name: + type: string + description: Slug identifier (e.g. "video-generation", "flux"). + display_name: + type: string + description: Human-readable display name (e.g. "Video Generation", "Flux"). + + LogsResponse: + type: array + x-runtime: [cloud] + description: '[cloud-only] System logs response' + items: + type: object + properties: + timestamp: + type: string + format: date-time + description: When the log entry was created + level: + type: string + enum: + - debug + - info + - warn + - error + description: Log level + message: + type: string + description: Log message + source: + type: string + description: Source of the log entry + metadata: + type: object + additionalProperties: true + description: Additional log metadata + + Member: + type: object + x-runtime: [cloud] + description: '[cloud-only] Workspace member with profile and role information.' + required: + - id + - name + - email + - role + - joined_at + properties: + id: + type: string + description: User ID + name: + type: string + description: User's display name + email: + type: string + format: email + description: User's email address + role: + type: string + enum: + - owner + - member + description: User's role in the workspace + joined_at: + type: string + format: date-time + description: When the user joined the workspace + + OAuthRegisterBadRequestResponse: + x-runtime: [cloud] + description: "[cloud-only] Union of the two 400 shapes /oauth/register can emit. `OAuthRegisterError` is the handler-shaped\ + \ RFC 7591 \xA73.2.2 error; `BindingErrorResponse` is the strict-server binding-layer error fired when the request body\ + \ fails OpenAPI-schema validation before the handler runs.\n" + oneOf: + - $ref: '#/components/schemas/OAuthRegisterError' + - $ref: '#/components/schemas/BindingErrorResponse' + + PendingInvite: + type: object + x-runtime: [cloud] + description: '[cloud-only] An outstanding workspace invitation that has not yet been accepted.' + required: + - id + - email + - invited_at + - expires_at + properties: + id: + type: string + description: Invite ID + email: + type: string + format: email + description: Email address of the invited user + token: + type: string + description: Invite token for constructing invite links. Empty for expired invites. + invited_at: + type: string + format: date-time + description: When the invite was created + expires_at: + type: string + format: date-time + description: When the invite expires + + Plan: + type: object + x-runtime: [cloud] + description: '[cloud-only] Billing plan details including pricing, limits, and features.' + required: + - slug + - tier + - duration + - price_cents + - credits_cents + - max_seats + - availability + - seat_summary + properties: + slug: + type: string + description: Plan identifier (e.g., "pro-monthly", "team-standard-annual") + example: pro-monthly + tier: + $ref: '#/components/schemas/SubscriptionTier' + duration: + $ref: '#/components/schemas/SubscriptionDuration' + price_cents: + type: integer + format: int64 + description: Per-member price in cents (base + one seat) + example: 10000 + credits_cents: + type: integer + format: int64 + description: Per-member credits in cents (base + one seat) + example: 10000 + max_seats: + type: integer + format: int64 + description: Maximum number of seats allowed for this plan + example: 20 + availability: + $ref: '#/components/schemas/PlanAvailability' + seat_summary: + $ref: '#/components/schemas/PlanSeatSummary' + + PlanAvailability: + type: object + x-runtime: [cloud] + description: '[cloud-only] Availability and eligibility information for a billing plan.' + required: + - available + properties: + available: + type: boolean + description: Whether the workspace can subscribe to this plan + reason: + $ref: '#/components/schemas/PlanAvailabilityReason' + + PlanAvailabilityReason: + type: string + x-runtime: [cloud] + enum: + - same_plan + - incompatible_transition + - requires_team + - requires_personal + - exceeds_max_seats + description: '[cloud-only] Reason why a plan is unavailable' + + PlanSeatSummary: + type: object + x-runtime: [cloud] + description: '[cloud-only] Summary of seat costs based on current workspace members' + required: + - seat_count + - total_cost_cents + - total_credits_cents + properties: + seat_count: + type: integer + description: Total number of seats (owner + members) that would be charged + example: 5 + total_cost_cents: + type: integer + format: int64 + description: Total cost for all seats in cents + example: 50000 + total_credits_cents: + type: integer + format: int64 + description: Total credits granted for all seats in cents + example: 50000 + + PreviewPlanInfo: + type: object + x-runtime: [cloud] + description: '[cloud-only] Plan information for preview display' + required: + - slug + - tier + - duration + - price_cents + - credits_cents + - seat_summary + properties: + slug: + type: string + description: Plan slug + example: team-pro-monthly + tier: + $ref: '#/components/schemas/SubscriptionTier' + duration: + $ref: '#/components/schemas/SubscriptionDuration' + price_cents: + type: integer + format: int64 + description: Per-seat price in cents + example: 10000 + credits_cents: + type: integer + format: int64 + description: Per-seat credits in cents + example: 10000 + seat_summary: + $ref: '#/components/schemas/PlanSeatSummary' + period_start: + type: string + format: date-time + description: Current billing period start (only for current_plan) + period_end: + type: string + format: date-time + description: Current billing period end (only for current_plan) + + PreviewSubscribeResponse: + type: object + x-runtime: [cloud] + description: '[cloud-only] Itemized cost preview for a pending subscription change.' + required: + - allowed + - transition_type + - effective_at + - is_immediate + - cost_today_cents + - cost_next_period_cents + - credits_today_cents + - credits_next_period_cents + - new_plan + properties: + allowed: + type: boolean + description: Whether this subscription change is allowed + reason: + type: string + description: Reason why the change is not allowed (only present if allowed=false) + transition_type: + type: string + enum: + - new_subscription + - upgrade + - downgrade + - duration_change + description: Type of subscription transition + effective_at: + type: string + format: date-time + description: When the change takes effect + is_immediate: + type: boolean + description: Whether the change takes effect immediately (true) or at period end (false) + cost_today_cents: + type: integer + format: int64 + description: Amount to charge today in cents (0 for downgrades) + example: 5000 + cost_next_period_cents: + type: integer + format: int64 + description: Amount that will be charged at next billing period in cents + example: 10000 + credits_today_cents: + type: integer + format: int64 + description: Credits granted today in cents (prorated for mid-period upgrades) + example: 5000 + credits_next_period_cents: + type: integer + format: int64 + description: Credits that will be granted at next billing period in cents + example: 10000 + current_plan: + $ref: '#/components/schemas/PreviewPlanInfo' + new_plan: + $ref: '#/components/schemas/PreviewPlanInfo' + + PublishedWorkflowDetail: + type: object + x-runtime: [cloud] + description: '[cloud-only] Full detail of a publicly published workflow on the Hub.' + required: + - share_id + - workflow_id + - name + - listed + - workflow_json + - assets + properties: + share_id: + type: string + workflow_id: + type: string + name: + type: string + description: Human-readable workflow name. + listed: + type: boolean + publish_time: + type: string + format: date-time + nullable: true + workflow_json: + type: object + additionalProperties: true + description: The workflow JSON content at publish time. + assets: + type: array + description: Published assets with their library status for the caller. + items: + $ref: '#/components/schemas/AssetInfo' + + SecretResponse: + type: object + x-runtime: [cloud] + description: '[cloud-only] User secret metadata (the secret value itself is never returned after creation).' + required: + - id + - name + - created_at + - updated_at + properties: + id: + type: string + format: uuid + description: Unique identifier for the secret + name: + type: string + description: User-provided label for the secret + provider: + type: string + description: Provider identifier (e.g., huggingface, civitai) + last_used_at: + type: string + format: date-time + description: When the secret was last used for decryption + created_at: + type: string + format: date-time + description: When the secret was created + updated_at: + type: string + format: date-time + description: When the secret was last updated + + SubscriptionDuration: + type: string + x-runtime: [cloud] + enum: + - MONTHLY + - ANNUAL + description: '[cloud-only] Billing period (uppercase to match comfy-api)' + + SubscriptionTier: + type: string + x-runtime: [cloud] + enum: + - FREE + - STANDARD + - CREATOR + - PRO + - FOUNDERS_EDITION + description: '[cloud-only] Subscription tier (uppercase to match comfy-api)' + + UserDataResponseFull: + type: object + x-runtime: [cloud] + description: '[cloud-only] User data listing entry with file metadata (path, size, modification time).' + properties: + path: + type: string + size: + type: integer + modified: + type: integer + format: int64 + description: UNIX timestamp of the last modification in milliseconds. + + ValidationError: + type: object + x-runtime: [cloud] + description: '[cloud-only] Details of a single validation error encountered during asset operations.' + required: + - code + - message + - field + properties: + code: + type: string + description: Machine-readable error code + example: FORMAT_NOT_ALLOWED + message: + type: string + description: Human-readable error message + example: 'File format "PickleTensor" is not allowed. Allowed formats: [SafeTensor]' + field: + type: string + description: Field that failed validation + example: format + + ValidationResult: + type: object + x-runtime: [cloud] + description: '[cloud-only] Result of validating a set of asset operations.' + required: + - is_valid + properties: + is_valid: + type: boolean + description: Overall validation status (true if all checks passed) + example: true + errors: + type: array + items: + $ref: '#/components/schemas/ValidationError' + description: Blocking validation errors that prevent download + warnings: + type: array + items: + $ref: '#/components/schemas/ValidationError' + description: Non-blocking validation warnings (informational only) + + WorkflowForkedFrom: + type: object + x-runtime: [cloud] + description: '[cloud-only] Reference to the parent workflow from which this workflow was forked.' + properties: + workflow_id: + type: string + workflow_version_id: + type: string + + WorkflowResponse: + type: object + x-runtime: [cloud] + description: '[cloud-only] Full workflow entity including metadata and version history.' + required: + - id + - latest_version + - created_by + - created_at + - updated_at + properties: + id: + type: string + name: + type: string + description: + type: string + default_view: + type: string + enum: + - workflow + - app + latest_version: + type: integer + forked_from: + $ref: '#/components/schemas/WorkflowForkedFrom' + created_by: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + WorkflowVersionContentResponse: + type: object + x-runtime: [cloud] + description: '[cloud-only] Full workflow version including the serialized workflow JSON.' + required: + - id + - version + - workflow_json + - created_by + - created_at + properties: + id: + type: string + version: + type: integer + workflow_json: + type: object + additionalProperties: true + created_by: + type: string + created_at: + type: string + format: date-time + dependency_asset_ids: + type: array + items: + type: string + + WorkspaceAPIKeyInfo: + type: object + x-runtime: [cloud] + description: '[cloud-only] Metadata for a workspace-scoped API key (secret is never returned).' + required: + - id + - workspace_id + - user_id + - name + - description + - key_prefix + - created_at + properties: + id: + type: string + format: uuid + description: API key ID + workspace_id: + type: string + description: Workspace this key belongs to + user_id: + type: string + description: User who created this key + name: + type: string + description: User-provided label + description: + type: string + description: User-provided description of the key's purpose. Limit is byte-based (UTF-8 encoding); 5000 bytes equals + 5000 ASCII characters or fewer multi-byte characters. + maxLength: 5000 + key_prefix: + type: string + description: First 8 chars after prefix for display + expires_at: + type: string + format: date-time + description: When the key expires (if set) + last_used_at: + type: string + format: date-time + description: Last time the key was used + revoked_at: + type: string + format: date-time + description: When the key was revoked (if revoked) + created_at: + type: string + format: date-time + description: When the key was created + + WorkspaceSummary: + type: object + x-runtime: [cloud] + description: '[cloud-only] Abbreviated workspace metadata used in list responses.' + required: + - id + - name + - type + properties: + id: + type: string + example: w-a1b2c3d4-5678-90ab-cdef-1234567890ab + name: + type: string + example: My Team + type: + type: string + enum: + - personal + - team + + WorkspaceWithRole: + type: object + x-runtime: [cloud] + description: '[cloud-only] Workspace entity annotated with the requesting user''s role.' + required: + - id + - name + - type + - role + - created_at + - joined_at + properties: + id: + type: string + example: w-a1b2c3d4-5678-90ab-cdef-1234567890ab + name: + type: string + example: My Team + type: + type: string + enum: + - personal + - team + role: + type: string + enum: + - owner + - member + created_at: + type: string + format: date-time + description: When the workspace was created + joined_at: + type: string + format: date-time + description: When the user joined the workspace (same as created_at for the workspace creator) + subscription_tier: + $ref: '#/components/schemas/SubscriptionTier' + + BindingErrorResponse: + type: object + x-runtime: [cloud] + description: '[cloud-only] Error shape returned when request binding or validation fails before the handler runs.' + required: + - message + properties: + message: + type: string + + ErrorResponse: + type: object + x-runtime: [cloud] + description: '[cloud-only] Standard error response from cloud endpoints with a machine-readable code and human-readable message.' + required: + - code + - message + properties: + code: + type: string + description: Machine-readable error code + message: + type: string + description: Human-readable error message + + AcceptInviteResponse: + type: object + x-runtime: [cloud] + description: '[cloud-only] Response returned after successfully accepting a workspace invitation.' + required: + - workspace_id + - workspace_name + properties: + workspace_id: + type: string + description: ID of the workspace joined + workspace_name: + type: string + description: Name of the workspace joined + + BillingEventsResponse: + type: object + x-runtime: [cloud] + description: '[cloud-only] Paginated list of billing events for a workspace.' + required: + - total + - events + - page + - limit + - totalPages + properties: + total: + type: integer + description: Total number of events + events: + type: array + items: + $ref: '#/components/schemas/BillingEvent' + page: + type: integer + description: Current page number (1-indexed) + limit: + type: integer + description: Items per page + totalPages: + type: integer + description: Total number of pages + + BillingOpStatusResponse: + type: object + x-runtime: [cloud] + description: '[cloud-only] Status of an asynchronous billing operation.' + required: + - id + - status + - started_at + properties: + id: + type: string + description: Unique identifier for the billing operation + status: + type: string + enum: + - pending + - succeeded + - failed + description: Current status of the operation + error_message: + type: string + description: Error message if status is failed + started_at: + type: string + format: date-time + description: When the operation was initiated + completed_at: + type: string + format: date-time + description: When the operation completed (success or failure) + + CancelSubscriptionResponse: + type: object + x-runtime: [cloud] + description: '[cloud-only] Response after successfully cancelling a subscription.' + required: + - cancel_at + - billing_op_id + properties: + billing_op_id: + type: string + description: Billing operation ID to poll for status via GET /api/billing/ops/{id} + cancel_at: + type: string + format: date-time + description: The date when the subscription will end (end of current billing period) + + CreateTopupResponse: + type: object + x-runtime: [cloud] + description: '[cloud-only] Response after successfully purchasing a credit top-up.' + required: + - topup_id + - status + - amount_cents + - billing_op_id + properties: + billing_op_id: + type: string + description: Billing operation ID to poll for status via GET /api/billing/ops/{id} + topup_id: + type: string + description: Unique identifier for the top-up request (same as billing_op_id, deprecated) + status: + type: string + enum: + - pending + - completed + - failed + description: Current status of the top-up + amount_cents: + type: integer + format: int64 + description: Amount being charged in cents + + CreateWorkspaceAPIKeyResponse: + type: object + x-runtime: [cloud] + description: '[cloud-only] Response containing the newly created workspace API key.' + required: + - id + - name + - description + - key + - key_prefix + - created_at + properties: + id: + type: string + format: uuid + description: API key ID + name: + type: string + description: User-provided label + description: + type: string + description: User-provided description of the key's purpose. Limit is byte-based (UTF-8 encoding); 5000 bytes equals + 5000 ASCII characters or fewer multi-byte characters. + maxLength: 5000 + key: + type: string + description: The full plaintext API key (only shown once) + key_prefix: + type: string + description: First 8 chars after prefix for display + expires_at: + type: string + format: date-time + description: When the key expires (if set) + created_at: + type: string + format: date-time + description: When the key was created + + ExchangeTokenResponse: + type: object + x-runtime: [cloud] + description: '[cloud-only] Response containing the issued Cloud JWT and its expiry.' + required: + - token + - expires_at + - workspace + - role + - permissions + properties: + token: + type: string + description: Cloud JWT token + expires_at: + type: string + format: date-time + description: Token expiration time (RFC 3339) + workspace: + $ref: '#/components/schemas/WorkspaceSummary' + role: + type: string + enum: + - owner + - member + description: User's role in the workspace + permissions: + type: array + items: + type: string + description: Permission strings for the role + example: + - owner:* + + JobCancelResponse: + type: object + x-runtime: [cloud] + description: '[cloud-only] Response for POST /api/jobs/{job_id}/cancel. Returned on both fresh cancels and idempotent no-ops.' + required: + - cancelled + properties: + cancelled: + type: boolean + description: "True when a cancel event was successfully dispatched by this call.\nFalse when the job was already in\ + \ a terminal or cancelling state,\nin which case the call is a no-op (still 200 \u2014 idempotent).\n" + + ResubscribeResponse: + type: object + x-runtime: [cloud] + description: '[cloud-only] Response after successfully resubscribing to a billing plan.' + required: + - status + - billing_op_id + properties: + billing_op_id: + type: string + description: Billing operation ID to poll for status via GET /api/billing/ops/{id} + status: + type: string + enum: + - active + description: The subscription status after resubscribing + message: + type: string + description: Human-readable confirmation message + + SubscribeResponse: + type: object + x-runtime: [cloud] + description: '[cloud-only] Response after successfully subscribing to a billing plan.' + required: + - status + - billing_op_id + properties: + billing_op_id: + type: string + description: Billing operation ID to poll for status via GET /api/billing/ops/{id} + status: + type: string + enum: + - subscribed + - needs_payment_method + - pending_payment + description: 'Status of the subscription operation: + + - subscribed: Subscription is active immediately + + - needs_payment_method: User must add payment method via payment_method_url + + - pending_payment: Upgrade initiated, waiting for payment to complete + + ' + effective_at: + type: string + format: date-time + description: When the subscription became/becomes active (present when status=subscribed or pending_payment) + payment_method_url: + type: string + description: URL to redirect user to add payment method (present when status=needs_payment_method) + + UserResponse: + type: object + x-runtime: [cloud] + description: '[cloud-only] User information response' + required: + - id + - status + properties: + id: + type: string + description: Firebase UID of the authenticated user + status: + type: string + description: User status (always "active" for authenticated users) + + WorkflowListResponse: + type: object + x-runtime: [cloud] + description: '[cloud-only] Paginated list of saved workflows.' + required: + - data + - pagination + properties: + data: + type: array + items: + $ref: '#/components/schemas/WorkflowResponse' + pagination: + $ref: '#/components/schemas/PaginationInfo' + + FeedbackRequest: + type: object + x-runtime: [cloud] + description: "[cloud-only] User feedback submission body." + required: + - message + properties: + type: + type: string + enum: + - missing_nodes + - general + - missing_models + description: Feedback category + category: + type: string + description: Additional category metadata + message: + type: string + description: User-provided feedback message