Repository: yoland68/comfy-cli Branch: main Commit: 0f8298c7ec3c Files: 145 Total size: 2.3 MB Directory structure: gitextract_3k1yj69h/ ├── .coveragerc ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ ├── codecov.yml │ ├── dependabot.yml │ └── workflows/ │ ├── build-and-test.yml │ ├── publish_package.yml │ ├── pytest.yml │ ├── ruff_check.yml │ ├── run-on-gpu.yml │ ├── test-mac.yml │ └── test-windows.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .pylintrc ├── DEV_README.md ├── LICENSE ├── README.md ├── comfy_cli/ │ ├── __init__.py │ ├── __main__.py │ ├── cmdline.py │ ├── command/ │ │ ├── __init__.py │ │ ├── code_search.py │ │ ├── custom_nodes/ │ │ │ ├── __init__.py │ │ │ ├── bisect_custom_nodes.py │ │ │ ├── cm_cli_util.py │ │ │ └── command.py │ │ ├── generate/ │ │ │ ├── __init__.py │ │ │ ├── adapters.py │ │ │ ├── app.py │ │ │ ├── client.py │ │ │ ├── output.py │ │ │ ├── poll.py │ │ │ ├── schema.py │ │ │ ├── spec/ │ │ │ │ └── openapi.yml │ │ │ ├── spec.py │ │ │ └── upload.py │ │ ├── github/ │ │ │ └── pr_info.py │ │ ├── install.py │ │ ├── launch.py │ │ ├── models/ │ │ │ └── models.py │ │ ├── pr_command.py │ │ └── run.py │ ├── config_manager.py │ ├── constants.py │ ├── cuda_detect.py │ ├── env_checker.py │ ├── file_utils.py │ ├── git_utils.py │ ├── logging.py │ ├── pr_cache.py │ ├── registry/ │ │ ├── __init__.py │ │ ├── api.py │ │ ├── config_parser.py │ │ └── types.py │ ├── resolve_python.py │ ├── standalone.py │ ├── tracking.py │ ├── typing.py │ ├── ui.py │ ├── update.py │ ├── utils.py │ ├── uv.py │ ├── workflow_to_api.py │ └── workspace_manager.py ├── conda.listing.txt ├── docs/ │ ├── DESIGN-uv-compile.md │ ├── PRD-uv-compile.md │ └── TESTING-e2e.md ├── pylock.toml ├── pyproject.toml ├── pyrightconfig.json └── tests/ ├── comfy_cli/ │ ├── command/ │ │ ├── generate/ │ │ │ ├── __init__.py │ │ │ ├── test_adapters.py │ │ │ ├── test_app.py │ │ │ ├── test_client.py │ │ │ ├── test_output.py │ │ │ ├── test_poll.py │ │ │ ├── test_schema.py │ │ │ ├── test_spec.py │ │ │ ├── test_upload.py │ │ │ └── test_video_poll.py │ │ ├── github/ │ │ │ └── test_pr.py │ │ ├── models/ │ │ │ └── test_models.py │ │ ├── nodes/ │ │ │ ├── test_bisect_custom_nodes.py │ │ │ ├── test_node_init.py │ │ │ ├── test_node_install.py │ │ │ ├── test_pack.py │ │ │ └── test_publish.py │ │ ├── test_bisect_parse.py │ │ ├── test_cm_cli_util.py │ │ ├── test_code_search.py │ │ ├── test_command.py │ │ ├── test_frontend_pr.py │ │ ├── test_launch_frontend_pr.py │ │ ├── test_manager_gui.py │ │ ├── test_npm_help.py │ │ └── test_run.py │ ├── conftest.py │ ├── fixtures/ │ │ ├── sd15_expected_api.json │ │ ├── sd15_object_info.json │ │ └── sd15_ui_workflow.json │ ├── registry/ │ │ ├── test_api.py │ │ └── test_config_parser.py │ ├── test_aria2_download.py │ ├── test_cm_cli_python_resolution.py │ ├── test_cmdline_python_resolution.py │ ├── test_config_manager.py │ ├── test_cuda_detect.py │ ├── test_cuda_detect_real.py │ ├── test_custom_nodes_python_resolution.py │ ├── test_env_checker.py │ ├── test_file_utils.py │ ├── test_global_python_install.py │ ├── test_install.py │ ├── test_install_python_resolution.py │ ├── test_launch_python_resolution.py │ ├── test_models_python_resolution.py │ ├── test_resolve_python.py │ ├── test_standalone.py │ ├── test_tracking.py │ ├── test_ui.py │ ├── test_update.py │ ├── test_utils.py │ ├── test_workflow_to_api.py │ └── test_workspace_manager.py ├── e2e/ │ ├── test_e2e.py │ ├── test_e2e_uv_compile.py │ └── workflow.json ├── test_file_utils_network.py └── uv/ ├── mock_comfy/ │ ├── custom_nodes/ │ │ ├── x/ │ │ │ ├── pyproject.toml │ │ │ ├── requirements.txt │ │ │ ├── setup.cfg │ │ │ └── setup.py │ │ ├── y/ │ │ │ ├── setup.cfg │ │ │ └── setup.py │ │ └── z/ │ │ └── setup.py │ ├── pyproject.toml │ ├── setup.cfg │ └── setup.py ├── mock_requirements/ │ ├── core_reqs.txt │ ├── x_reqs.txt │ └── y_reqs.txt ├── test_torch_backend_compile.py └── test_uv.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .coveragerc ================================================ [run] source = comfy_cli omit = tests/* [report] exclude_lines = pragma: no cover def __repr__ if self.debug: if __name__ == .__main__.: raise NotImplementedError pass except ImportError: def parse_args @abstractmethod ignore_errors = True [html] directory = coverage_html_report [xml] output = coverage.xml ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a bug report to help us improve. title: '' labels: bug assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior **Expected behavior** A clear and concise description of what you expected to happen. **Nice to have** - [ ] Terminal output - [ ] Screenshots **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Submit a feature request for this repo. title: '' labels: enhancement assignees: '' --- **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/codecov.yml ================================================ comment: layout: "diff, files" coverage: status: project: default: threshold: 0.1% patch: off ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "github-actions" directory: "/.github/workflows" schedule: interval: "monthly" open-pull-requests-limit: 1 groups: ci-dependencies: patterns: - "*" ================================================ FILE: .github/workflows/build-and-test.yml ================================================ name: "Test CLI Tool on Multiple Platforms" on: push: branches: - main paths: - "comfy_cli/**" - "tests/e2e/**" - "!.github/**" - "!.coveragerc" - "!.gitignore" pull_request: branches: - main paths: - "comfy_cli/**" - "tests/e2e/**" - "!.github/**" - "!.coveragerc" - "!.gitignore" permissions: contents: read jobs: test: name: "Run Tests on Multiple Platforms" runs-on: ${{ matrix.os }} env: PYTHONIOENCODING: "utf8" strategy: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] python-version: ["3.10"] steps: - name: Check out code uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install Dependencies run: | python -m pip install --upgrade pip pip install pytest pip install -e . - name: Test CUDA auto-detection (skips if no GPU) run: | pytest tests/comfy_cli/test_cuda_detect_real.py -v - name: Test e2e env: PYTHONPATH: ${{ github.workspace }} TEST_E2E: true run: | pytest tests/e2e - name: Test torch backend compilation env: TEST_TORCH_BACKEND: "true" run: | pytest tests/uv/test_torch_backend_compile.py -xvs ================================================ FILE: .github/workflows/publish_package.yml ================================================ name: Publish to PyPI on: release: types: [ created ] permissions: contents: read jobs: build-n-publish-pypi: name: Build and publish Python distributions to PyPI runs-on: ubuntu-latest permissions: id-token: write # IMPORTANT: this permission is mandatory for trusted publishing steps: - uses: actions/checkout@v6 - name: Set up Python 3.12 uses: actions/setup-python@v6 with: python-version: '3.12' - name: Install build and tomlkit dependencies run: python -m pip install --upgrade pip build tomlkit - name: Extract version from tag id: get_version run: | VERSION="${GITHUB_REF#refs/tags/}" VERSION="${VERSION#v}" echo "VERSION=${VERSION}" >> $GITHUB_ENV - name: Update version in pyproject.toml run: | python -c " import tomlkit with open('pyproject.toml', 'r') as f: content = tomlkit.load(f) content['project']['version'] = '${{ env.VERSION }}' with open('pyproject.toml', 'w') as f: tomlkit.dump(content, f) " - name: Build distribution run: python -m build --sdist --wheel --outdir dist/ # - name: Publish distribution to TestPyPI for Validation # uses: pypa/gh-action-pypi-publish@v1.8.14 # with: # repository_url: https://test.pypi.org/legacy/ # - name: Clear pip cache # run: pip cache purge # - name: Install Comfy CLI from Test Pypi and Test # run: | # for i in {1..3}; do # pip install -i https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple comfy-cli==${{env.VERSION}} && break || sleep 5 # done # comfy --help - name: Publish distribution to Official PyPI uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 test-pip-installation: name: Test Comfy CLI Installation via pip needs: build-n-publish-pypi # This job runs after build-n-publish completes successfully runs-on: ubuntu-latest steps: - name: Set up Python uses: actions/setup-python@v6 with: python-version: '3.10' - name: Extract version from tag id: get_version run: | VERSION="${GITHUB_REF#refs/tags/}" VERSION="${VERSION#v}" echo "VERSION=${VERSION}" >> $GITHUB_ENV - name: Install Comfy CLI via pip and Test run: | # PyPI's index can lag behind a successful upload by a minute or # two, so retry before failing the job. for i in 1 2 3 4 5 6 7 8; do pip install --no-cache-dir "comfy-cli==${VERSION}" && exit 0 echo "Attempt $i: package not yet available on PyPI, waiting 15s..." sleep 15 done echo "::error::Failed to install comfy-cli==${VERSION} after 8 attempts" exit 1 - name: Test Comfy CLI Help run: comfy --help ================================================ FILE: .github/workflows/pytest.yml ================================================ name: Run pytest on: push: branches: - main pull_request: branches: - main permissions: contents: read statuses: write pull-requests: write jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: python-version: '3.10' # Follow the min version in pyproject.toml - name: Install dependencies run: | python -m pip install --upgrade pip pip install pytest pytest-cov pip install -e . - name: Run tests env: PYTHONPATH: ${{ github.workspace }} run: | pytest --cov=comfy_cli --cov-report=xml . - name: Upload coverage to Codecov uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: true verbose: true ================================================ FILE: .github/workflows/ruff_check.yml ================================================ name: ruff_check on: push: branches: - main pull_request: branches: - main permissions: contents: read jobs: ruff_check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: python-version: "3.x" - name: install ruff run: | python -m pip install --upgrade pip pip install ruff - name: lint check and then format check with ruff run: | ruff check ruff format --diff ================================================ FILE: .github/workflows/run-on-gpu.yml ================================================ name: "Test CLI Tool on GPU runners" on: push: branches: - main paths: - "comfy_cli/**" - "!comfy_cli/test_**" - "!.github/**" - "!tests/**" - "!.coveragerc" - "!.gitignore" pull_request: branches: - main paths: - "comfy_cli/**" - "!comfy_cli/test_**" - "!.github/**" - "!tests/**" - "!.coveragerc" - "!.gitignore" permissions: contents: read jobs: test-cli-gpu: name: "Run Tests on GPU Runners" runs-on: group: gpu-runners labels: ${{ matrix.os }}-x64-gpu # strategy: fail-fast: false matrix: os: [linux] steps: - name: Check out code uses: actions/checkout@v6 - name: Check Nvidia run: | nvidia-smi - name: Set up Python uses: actions/setup-python@v6 with: python-version: 3.12 - name: Check disk space run: | df -h - name: Install Dependencies run: | python -m pip install --upgrade pip pip install pytest pip install -e . - name: Test CUDA auto-detection (real hardware) run: | pytest tests/comfy_cli/test_cuda_detect_real.py -v - name: Test e2e id: test-e2e env: PYTHONPATH: ${{ github.workspace }} TEST_E2E: true TEST_E2E_COMFY_INSTALL_FLAGS: --nvidia TEST_E2E_COMFY_LAUNCH_FLAGS_EXTRA: "" run: | pytest tests/e2e - name: Retry test e2e but without gpu if: ${{ failure() && steps.test-e2e.conclusion == 'failure' }} env: PYTHONPATH: ${{ github.workspace }} TEST_E2E: true run: | pytest tests/e2e ================================================ FILE: .github/workflows/test-mac.yml ================================================ name: "Mac Specific Commands" on: pull_request: branches: - main paths: - comfy_cli/** permissions: contents: read jobs: test: runs-on: macos-latest env: PYTHONIOENCODING: "utf8" steps: - name: Check out code uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: python-version: 3.12 - name: Install Dependencies run: | python -m venv venv source venv/bin/activate python -m pip install --upgrade pip pip install -e . comfy --skip-prompt --workspace ./ComfyUI install --fast-deps --m-series --skip-manager comfy --here launch -- --cpu --quick-test-for-ci ================================================ FILE: .github/workflows/test-windows.yml ================================================ name: "Windows Specific Commands" on: pull_request: branches: - main paths: - comfy_cli/** permissions: contents: read jobs: test: runs-on: windows-latest env: PYTHONIOENCODING: "utf8" steps: - name: Check out code uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: python-version: 3.12 - name: Install Dependencies run: | python -m venv venv .\venv\Scripts\Activate.ps1 Get-Command python python -m pip install --upgrade pip pip install pytest pip install -e . comfy --skip-prompt --workspace ./ComfyUI install --fast-deps --nvidia --cuda-version 12.6 --skip-manager comfy --here launch -- --cpu --quick-test-for-ci ================================================ FILE: .gitignore ================================================ __pycache__/ *.py[cod] #COMMON CONFIGs .DS_Store .src_port .webpack_watch.log *.swp *.swo .vscode/settings.json .idea/ .vscode/ *.code-workspace .history # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # temporary files created by linting, tests, etc .pytest_cache/ .ruff_cache/ tests/temp/ venv/ bisect_state.json python* cpython* requirements.compiled override.txt .coverage coverage.xml ================================================ FILE: .pre-commit-config.yaml ================================================ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - id: check-yaml exclude: ^tests/.*$ - id: check-toml exclude: ^tests/.*$ - id: end-of-file-fixer exclude: >- (^.*\.(json|txt)$)|(^tests/.*\.toml$)|(.github/.*TEMPLATE) - id: trailing-whitespace exclude: >- (^.*\.(json|txt)$) - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.12.4 hooks: # Run the linter. - id: ruff args: [ --fix ] # Run the formatter. - id: ruff-format - repo: https://github.com/tox-dev/pyproject-fmt rev: v2.6.0 hooks: - id: pyproject-fmt exclude: ^tests/.*$ - repo: https://github.com/astral-sh/uv-pre-commit rev: 0.8.5 hooks: - id: uv-lock - id: uv-export args: [ "--output-file=pylock.toml" ] - id: uv-sync args: [ "--all-extras" ] ================================================ FILE: .pylintrc ================================================ # .pylintrc or pylintrc [MAIN] max-line-length=120 ================================================ FILE: DEV_README.md ================================================ # Development Guide This guide provides an overview of how to develop in this repository. ## General guide 1. Clone the repo, create and activate a conda env. Minimum Python version is 3.9. 2. Install the package to your conda env. `pip install -e .` 3. Set ENVIRONMENT variable to DEV. `export ENVIRONMENT=dev` 4. Check if the "comfy" package can run. `comfy --help` 5. Install the pre-commit hook to ensure that your code won't need reformatting later. `pre-commit install` 6. To save time during code review, it's recommended that you also manually run the unit tests before submitting a pull request (see below). ## Running the unit tests 1. Install pytest into your conda env. You should preferably be using Python 3.9 in your conda env, since it's the version we are targeting for compatibility. `pip install pytest pytest-cov` 2. Verify that all unit tests run successfully. `pytest --cov=comfy_cli --cov-report=xml .` ## Debugging You can add following config to your VSCode `launch.json` to launch debugger. ```json { "name": "Python Debugger: Run", "type": "debugpy", "request": "launch", "module": "comfy_cli.__main__", "args": [], "console": "integratedTerminal" } ``` ## Making changes to the code base There is a potential need for you to reinstall the package. You can do this by either run `pip install -e .` again (which will reinstall), or manually uninstall `pip uninstall comfy-cli` and reinstall, or even cleaning your conda env and reinstalling the package (`pip install -e .`) ## Packaging custom nodes with `.comfyignore` `comfy node pack` and `comfy node publish` now read an optional `.comfyignore` file in the project root. The syntax matches `.gitignore` (implemented with `PathSpec`'s `gitwildmatch` rules), so you can reuse familiar patterns to keep development-only artifacts out of your published archive. - Patterns are evaluated against paths relative to the directory you run the command from (usually the repo root). - Files required by the pack command itself (e.g. `__init__.py`, `web/*`) are still forced into the archive even if they match an ignore pattern. - If no `.comfyignore` is present the command falls back to the original behavior and zips every git-tracked file. Example `.comfyignore`: ```gitignore docs/ frontend/ tests/ *.psd ``` Commit the file alongside your node so teammates and CI pipelines produce the same trimmed package. ## Adding a new command - Register it under `comfy_cli/cmdline.py` If it's contains subcommand, create folder under comfy_cli/command/[new_command] and add the following boilerplate `comfy_cli/command/[new_command]/__init__.py` ``` from .command import app ``` `comfy_cli/command/[new_command]command.py` ``` import typer app = typer.Typer() @app.command() def option_a(name: str): """Add a new custom node""" print(f"Adding a new custom node: {name}") @app.command() def remove(name: str): """Remove a custom node""" print(f"Removing a custom node: {name}") ``` ## Important notes - Use `typer` for all command args management - Use `rich` for all console output - For progress reporting, use either [`rich.progress`](https://rich.readthedocs.io/en/stable/progress.html) ## Develop comfy-cli and ComfyUI-Manager (cm-cli) together ComfyUI-Manager is now installed as a pip package (via `manager_requirements.txt` in the ComfyUI root) rather than being git-cloned into `custom_nodes/`. ### Making changes to both 1. Fork your own branches of `comfy-cli` and `ComfyUI-Manager`, make changes. 2. Live-install `comfy-cli`: - `pip install -e /path/to/comfy-cli` 3. Live-install your fork of `ComfyUI-Manager` in editable mode: - `pip install -e /path/to/ComfyUI-Manager` 4. This makes the `cm-cli` entry point available and points it at your local source. ### Trying changes to both 1. Install both packages in editable mode as described above. 2. Go to a test dir and run: - `comfy --here install` 3. The `cm-cli` command will resolve to your locally installed editable package. ### Debugging both simultaneously 1. Follow instructions above to get working install with changes. 2. Add breakpoints directly to code: `import ipdb; ipdb.set_trace()` 3. Execute relevant `comfy-cli` command. ## Running E2E tests E2E tests perform real `comfy install`, `comfy launch`, and `comfy node` operations. They are **disabled by default** and must be explicitly enabled. ```bash TEST_E2E=true pytest tests/e2e/ ``` For pre-release testing against alternate ComfyUI repositories (e.g. Manager v4): ```bash TEST_E2E=true \ TEST_E2E_COMFY_URL="https://github.com/ltdrdata/ComfyUI.git@dr-bump-manager" \ pytest tests/e2e/ -v ``` See [docs/TESTING-e2e.md](docs/TESTING-e2e.md) for the full guide including environment variables, test suite details, and scenario descriptions. ## Contact If you have any questions or need further assistance, please contact the project maintainer at [???](mailto:???@drip.art). Happy coding! ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: README.md ================================================ # comfy-cli: A Command Line Tool for ComfyUI [![Run pytest](https://github.com/Comfy-Org/comfy-cli/actions/workflows/pytest.yml/badge.svg)](https://github.com/Comfy-Org/comfy-cli/actions/workflows/pytest.yml) [![codecov](https://codecov.io/github/Comfy-Org/comfy-cli/graph/badge.svg?token=S64WJWD2ZX)](https://codecov.io/github/Comfy-Org/comfy-cli) [![PyPI](https://img.shields.io/pypi/v/comfy-cli.svg)](https://pypi.org/project/comfy-cli/) [![Downloads](https://static.pepy.tech/badge/comfy-cli/month)](https://pepy.tech/project/comfy-cli) [![Python](https://img.shields.io/pypi/pyversions/comfy-cli)](https://pypi.org/project/comfy-cli/) [![License](https://img.shields.io/pypi/l/comfy-cli)](https://github.com/Comfy-Org/comfy-cli/blob/main/LICENSE) comfy-cli is a command-line tool for installing, running, and extending [ComfyUI](https://github.com/comfyanonymous/ComfyUI) — the open-source generative-media engine. Set up ComfyUI, install custom nodes and models, run workflows, and call hosted partner image models, all from your terminal. ## Demo Comfy Command Demo ## Features - 🚀 One-command ComfyUI install and launch - 🎨 Direct calls to partner image and video nodes (Flux, Ideogram, DALL·E, Recraft, Stability, Gemini/nano-banana, Kling, Luma, Runway, Pika, Vidu, Hailuo, Seedance, …) via `comfy generate`, no workflow JSON required - 🔧 Custom node management — install, update, snapshot, bisect - 📦 Fast dependency resolution with `uv` (`--fast-deps`, `--uv-compile`) - 🗄️ Model downloads from CivitAI, Hugging Face, and direct URLs - 🎬 Run workflows against a local ComfyUI server, including auto-conversion of UI-format JSON - 🧪 Test ComfyUI and frontend pull requests with one flag - 💻 Cross-platform: Windows, macOS, Linux ## Installation 1. (Recommended) Activate a virtual environment ([venv](https://docs.python.org/3/library/venv.html) or [conda](https://conda.io/projects/conda/en/latest/user-guide/getting-started.html)). 2. Install with `pip` (requires Python 3.10+): ```bash pip install comfy-cli ``` ### Shell Autocomplete Install shell completion so `comfy ` expands commands and options: ```bash comfy --install-completion ``` ## Usage ### Installing ComfyUI To install ComfyUI using comfy, simply run: `comfy install` This command will download and set up the latest version of ComfyUI and ComfyUI-Manager on your system. If you run in a ComfyUI repo that has already been setup. The command will simply update the comfy.yaml file to reflect the local setup - `comfy install --skip-manager`: Install ComfyUI without ComfyUI-Manager. To use a custom Manager fork or specific version, skip the default installation and install your own into the workspace venv: ```bash comfy install --skip-manager # Then install your custom Manager: pip install -e /path/to/your-manager-fork # editable install # or pip install comfyui-manager==4.1b8 # specific version ``` - `comfy --workspace= install`: Install ComfyUI into `/ComfyUI`. - `comfy install --fast-deps`: Use `uv` instead of `pip` for faster dependency resolution during initial ComfyUI installation. comfy-cli's built-in resolver compiles all requirements (core + custom nodes) into a single lockfile and installs from it. Also handles GPU-specific PyTorch wheel selection automatically. - For `comfy install`, if no path specification like `--workspace, --recent, or --here` is provided, it will be implicitly installed in `/comfy`. #### Python environment handling When you run `comfy install`, comfy-cli picks a Python environment for ComfyUI dependencies using the following precedence: 1. An **active virtualenv or conda** environment (`VIRTUAL_ENV` / `CONDA_PREFIX`) is used as-is. 2. An **existing `.venv` or `venv`** directory inside the workspace is reused. 3. Otherwise the choice depends on how comfy-cli was installed: - **`pip install comfy-cli`** (global / system Python): dependencies go directly into the same Python environment. This is the typical Docker setup. - **`pipx install comfy-cli`** or **`uv tool install comfy-cli`** (isolated tool environment): a `.venv` is created inside the ComfyUI workspace. Use `comfy launch` to start ComfyUI with the correct Python. ### Specifying execution path - You can specify the path of ComfyUI where the command will be applied through path indicators as follows: - `comfy --workspace=`: Run from the ComfyUI installed in the specified workspace. - `comfy --recent`: Run from the recently executed or installed ComfyUI. - `comfy --here`: Run from the ComfyUI located in the current directory. - --workspace, --recent, and --here options cannot be used simultaneously. - If there is no path indicator, the following priority applies: - Run from the default ComfyUI at the path specified by `comfy set-default `. - Run from the recently executed or installed ComfyUI. - Run from the ComfyUI located in the current directory. - Example 1: To run the recently executed ComfyUI: - `comfy --recent launch` - Example 2: To install a package on the ComfyUI in the current directory: - `comfy --here node install comfyui-impact-pack` - Example 3: To update the automatically selected path of ComfyUI and custom nodes based on priority: - `comfy node update all` - You can use the `comfy which` command to check the path of the target workspace. - e.g `comfy --recent which`, `comfy --here which`, `comfy which`, ... ### Default Setup The default sets the option that will be executed by default when no specific workspace's ComfyUI has been set for the command. `comfy set-default ?[--launch-extras=""]` - `--launch-extras` option specifies extra args that are applied only during launch by default. However, if extras are specified at the time of launch, this setting is ignored. ### Launch ComfyUI Comfy provides commands that allow you to easily run the installed ComfyUI. `comfy launch` - To run with default ComfyUI options: `comfy launch -- ` `comfy launch -- --cpu --listen 0.0.0.0` - When you manually configure the extra options, the extras set by set-default will be overridden. - To run background `comfy launch --background` `comfy --workspace=~/comfy launch --background -- --listen 10.0.0.10 --port 8000` - Instances launched with `--background` are displayed in the "Background ComfyUI" section of `comfy env`, providing management functionalities for a single background instance only. - Since "Comfy Server Running" in `comfy env` only shows the default port 8188, it doesn't display ComfyUI running on a different port. - Background-running ComfyUI can be stopped with `comfy stop`. - to run ComfyUI with a specific pull request: `comfy install --pr "#1234"` `comfy install --pr "jtydhr88:load-3d-nodes"` `comfy install --pr "https://github.com/comfyanonymous/ComfyUI/pull/1234"` - If you want to run ComfyUI with a specific pull request, you can use the `--pr` option. This will automatically install the specified pull request and run ComfyUI with it. - Important: The --pr option cannot be combined with --version or --commit and will be rejected if used together. - To test a frontend pull request: ``` comfy launch --frontend-pr "#456" comfy launch --frontend-pr "username:branch-name" comfy launch --frontend-pr "https://github.com/Comfy-Org/ComfyUI_frontend/pull/456" ``` - The `--frontend-pr` option allows you to test frontend PRs by automatically cloning, building, and using the frontend for that session. - Requirements: Node.js and npm must be installed to build the frontend. - Builds are cached for quick switching between PRs - subsequent uses of the same PR are instant. - Each PR is used only for that launch session. Normal launches use the default frontend. **Managing PR cache**: ``` comfy pr-cache list # List cached PR builds comfy pr-cache clean # Clean all cached builds comfy pr-cache clean 456 # Clean specific PR cache ``` - Cache automatically expires after 7 days - Maximum of 10 PR builds are kept (oldest are removed automatically) - Cache limits help manage disk space while keeping recent builds available ### Managing Custom Nodes comfy provides a convenient way to manage custom nodes for extending ComfyUI's functionality. Here are some examples: - Show custom nodes' information: ``` comfy node [show|simple-show] [installed|enabled|not-installed|disabled|all|snapshot|snapshot-list] ?[--channel ] ?[--mode [remote|local|cache]] ``` - `comfy node show all --channel recent` `comfy node simple-show installed` `comfy node update all` `comfy node install comfyui-impact-pack` - Managing snapshot: `comfy node save-snapshot` `comfy node restore-snapshot ` - Install dependencies: `comfy node install-deps --deps=` `comfy node install-deps --workflow=` - Generate deps: `comfy node deps-in-workflow --workflow= --output=` #### Unified Dependency Resolution (--uv-compile) Requires ComfyUI-Manager v4.1+. Instead of installing dependencies per-node with `pip install`, `--uv-compile` delegates to ComfyUI-Manager's unified resolver which batch-resolves all custom node dependencies via `uv pip compile` with **cross-node conflict detection** — it can identify which node packs have incompatible dependencies and why. - Install with unified resolution: `comfy node install comfyui-impact-pack --uv-compile` - Available on: `install`, `reinstall`, `update`, `fix`, `restore-snapshot`, `restore-dependencies`, `install-deps` - Run standalone (resolve all existing custom node dependencies): `comfy node uv-sync` - `--uv-compile` is mutually exclusive with `--fast-deps` and `--no-deps`. - To make `--uv-compile` the default for all commands, see [uv-compile default](#uv-compile-default) below. - Use `--no-uv-compile` to override the default for a single command: `comfy node install comfyui-impact-pack --no-uv-compile` #### --fast-deps vs --uv-compile Both flags use `uv` for faster dependency resolution, but they work differently: | | `--fast-deps` | `--uv-compile` | |-----------------------|-------------------------------------------------|-----------------------------------------------| | **Resolver** | comfy-cli built-in (`DependencyCompiler`) | ComfyUI-Manager (`UnifiedDepResolver`) | | **Scope** | `comfy install`, `comfy node install/reinstall` | Custom node commands only | | **Conflict handling** | Interactive prompt to pick a version | Automatic detection with node attribution | | **Config default** | No | Yes (`comfy manager uv-compile-default true`) | | **Requires** | Only `uv` | ComfyUI-Manager v4.1+ | **When to use which:** - For initial ComfyUI installation with uv: `comfy install --fast-deps` - For custom node management with Manager v4.1+: `--uv-compile` (recommended) - For custom node management with older Manager: `--fast-deps` #### Bisect custom nodes If you encounter bugs only with custom nodes enabled, and want to find out which custom node(s) causes the bug, the bisect tool can help you pinpoint the custom node that causes the issue. - `comfy node bisect start`: Start a new bisect session with optional ComfyUI launch args. It automatically marks the starting state as bad, and takes all enabled nodes when the command executes as the test set. - `comfy node bisect good`: Mark the current active set as good, indicating the problem is not within the test set. - `comfy node bisect bad`: Mark the current active set as bad, indicating the problem is within the test set. - `comfy node bisect reset`: Reset the current bisect session. ### Managing Models - Model downloading `comfy model download --url ?[--relative-path ] ?[--set-civitai-api-token ] ?[--set-hf-api-token ]` - URL: CivitAI page, Hugging Face file URL, etc... - You can also specify your API tokens via the `CIVITAI_API_TOKEN` and `HF_API_TOKEN` environment variables. The order of priority is `--set-X-token` (always highest priority), then the environment variables if they exist, and lastly your config's stored tokens from previous `--set-X-token` usage (which remembers your most recently set token values). - Tokens provided via the environment variables are never stored persistently in your config file. They are intended as a way to easily and safely provide transient secrets. - Model remove `comfy model remove ?[--relative-path ] --model-names ` - Model list `comfy model list ?[--relative-path ]` ### Calling partner nodes (`comfy generate`) `comfy generate` calls Comfy's partner nodes directly from the terminal — no local ComfyUI or workflow JSON required. It hits the same hosted partner nodes you'd otherwise wire into a ComfyUI workflow, but as one-shot CLI calls. Image models (Flux, Ideogram, DALL·E, Recraft, Stability, Runway, Reve, xAI Grok, Google Gemini Flash Image aka **nano-banana**, …) and video models (Kling, Luma, Runway Gen-3, Pika, Vidu, Moonvalley, Hailuo, Grok video, ByteDance **Seedance**) are all covered; video jobs run async and the CLI polls until the result is ready. Prerequisites — a Comfy API key and a credit balance: - [Create an API key](https://docs.comfy.org/development/comfyui-server/api-key-integration) - [Browse partner nodes and per-call credit costs](https://docs.comfy.org/tutorials/partner-nodes/overview) · [pricing table](https://docs.comfy.org/tutorials/partner-nodes/pricing) - [Add credits](https://docs.comfy.org/interface/credits) Set the key once, then go: ```bash export COMFY_API_KEY=comfyui-... # or pass --api-key on each call comfy generate list # browse available models comfy generate schema flux-pro # see params for one model comfy generate flux-pro --prompt "a cat on the moon" \ --width 1024 --height 1024 --download cat.png ``` Reference images can be passed as local paths — the CLI uploads them through the cloud's storage endpoint (or base64-encodes inline, as each partner requires): ```bash comfy generate flux-kontext --prompt "add a top hat" \ --input_image ./photo.jpg --download out.png comfy generate upload ./photo.jpg # explicit upload ``` Async models (every video model plus the Flux family) block until ready by default. Pass `--async` to return immediately with a job id, then resume later with `comfy generate resume `. Examples: ```bash comfy generate kling --prompt "a paper boat drifting on a river at dusk" \ --duration 5 --download boat.mp4 comfy generate luma --prompt "..." --aspect_ratio 16:9 --async # → prints job id; resume with: comfy generate resume luma --download out.mp4 ``` **Gemini Flash Image (nano-banana)** — text-to-image and image edits in one alias. Pass `--image` (repeatable) for reference images. The response is inline base64, so `--download` is required to save: ```bash comfy generate nano-banana --prompt "a watercolor of a sleeping fox" \ --download fox.png # Image edit — reference accepted as a local path, http(s) URL, or data URI: comfy generate nano-banana --prompt "add a top hat" \ --image ./cat.png --download out.png # Switch model variants: comfy generate nano-banana --prompt "..." --model gemini-3-pro-image-preview \ --download out.png ``` **Seedance** — text-to-video and image-to-video, up to 1080p / 12s clips. Resolution, ratio, duration, fps, etc. get passed through as flags; the CLI inlines them into Seedance's prompt syntax for you: ```bash comfy generate seedance --prompt "a hummingbird hovering over a flower" \ --resolution 1080p --duration 5 --download bird.mp4 # Image-to-video: pick a lite/i2v variant and pass a first frame. comfy generate seedance --model seedance-1-0-lite-i2v-250428 \ --prompt "the wave crests and crashes" \ --image ./still.jpg --download wave.mp4 ``` ### Managing ComfyUI-Manager - Disable ComfyUI-Manager completely (no manager flags passed to ComfyUI): `comfy manager disable` - Enable ComfyUI-Manager with new GUI: `comfy manager enable-gui` - Enable ComfyUI-Manager without GUI (manager runs but UI is hidden): `comfy manager disable-gui` - Enable ComfyUI-Manager with legacy GUI: `comfy manager enable-legacy-gui` - Clear reserved startup action: `comfy manager clear` - Migrate legacy git-cloned ComfyUI-Manager to pip package: `comfy manager migrate-legacy` #### uv-compile default Set `--uv-compile` as the default behavior for all custom node operations: `comfy manager uv-compile-default true` When enabled, all node commands (`install`, `reinstall`, `update`, `fix`, `restore-snapshot`, `restore-dependencies`, `install-deps`) will automatically use `--uv-compile`. Use `--no-uv-compile` on any individual command to override. To disable: `comfy manager uv-compile-default false` ## Beta Feature: format of comfy-lock.yaml (WIP) ``` basic: models: - model: [name of the model] url: [url of the source, e.g. https://huggingface.co/...] paths: [list of paths to the model] - path: [path to the model] - path: [path to the model] hashes: [hashes for the model] - hash: [hash] type: [AutoV1, AutoV2, SHA256, CRC32, and Blake3] type: [type of the model, e.g. diffuser, lora, etc.] - model: ... # compatible with ComfyUI-Manager's .yaml snapshot custom_nodes: comfyui: [commit hash] file_custom_nodes: - disabled: [bool] filename: [.py filename] ... git_custom_nodes: [git-url]: disabled: [bool] hash: [commit hash] ... ``` ## Analytics We track analytics using Mixpanel to help us understand usage patterns and know where to prioritize our efforts. When you first download the cli, it will ask you to give consent. If at any point you wish to opt out: ``` comfy tracking disable ``` Check out the usage here: [Mixpanel Board](https://mixpanel.com/p/13hGfPfEPdRkjPtNaS7BYQ) ## Contributing We welcome contributions to comfy-cli! For ideas, suggestions, or bug reports, open an issue at [Comfy-Org/comfy-cli](https://github.com/Comfy-Org/comfy-cli/issues). For code changes, fork the repo and open a pull request. See the [Dev Guide](/DEV_README.md) for setup details. ## License Released under the [GNU General Public License v3.0](https://github.com/Comfy-Org/comfy-cli/blob/main/LICENSE). ## Support Questions or issues? [Open an issue](https://github.com/Comfy-Org/comfy-cli/issues) or reach us on [Discord](https://discord.com/invite/comfyorg). Happy diffusing with ComfyUI and comfy-cli! 🎉 ================================================ FILE: comfy_cli/__init__.py ================================================ ================================================ FILE: comfy_cli/__main__.py ================================================ from comfy_cli.cmdline import main if __name__ == "__main__": # pragma: nocover main() ================================================ FILE: comfy_cli/cmdline.py ================================================ import os import subprocess import sys import webbrowser from typing import Annotated import questionary import typer from rich import print as rprint from rich.console import Console from comfy_cli import constants, env_checker, logging, tracking, ui, utils from comfy_cli.command import code_search, custom_nodes, pr_command from comfy_cli.command import generate as generate_command from comfy_cli.command import install as install_inner from comfy_cli.command import run as run_inner from comfy_cli.command.install import validate_version from comfy_cli.command.launch import launch as launch_command from comfy_cli.command.models import models as models_command from comfy_cli.config_manager import ConfigManager from comfy_cli.constants import GPU_OPTION, CUDAVersion, ROCmVersion from comfy_cli.cuda_detect import DEFAULT_CUDA_TAG, detect_cuda_driver_version, resolve_cuda_wheel from comfy_cli.env_checker import EnvChecker from comfy_cli.resolve_python import resolve_workspace_python from comfy_cli.standalone import StandalonePython from comfy_cli.update import check_for_updates from comfy_cli.uv import DependencyCompiler from comfy_cli.workspace_manager import WorkspaceManager, check_comfy_repo logging.setup_logging() app = typer.Typer() workspace_manager = WorkspaceManager() console = Console() def main(): app() class MutuallyExclusiveValidator: def __init__(self): self.group = [] def reset_for_testing(self): self.group.clear() def validate(self, _ctx: typer.Context, param: typer.CallbackParam, value: str): # Add cli option to group if it was called with a value if value is not None and param.name not in self.group: self.group.append(param.name) if len(self.group) > 1: raise typer.BadParameter(f"option `{param.name}` is mutually exclusive with option `{self.group.pop()}`") return value g_exclusivity = MutuallyExclusiveValidator() g_gpu_exclusivity = MutuallyExclusiveValidator() @app.command(help="Display help for commands") def help(ctx: typer.Context): rprint(ctx.find_root().get_help()) ctx.exit(0) @app.callback(invoke_without_command=True) def entry( ctx: typer.Context, workspace: Annotated[ str | None, typer.Option( show_default=False, help="Path to ComfyUI workspace", callback=g_exclusivity.validate, ), ] = None, recent: Annotated[ bool | None, typer.Option( show_default=False, help="Execute from recent path", callback=g_exclusivity.validate, ), ] = None, here: Annotated[ bool | None, typer.Option( show_default=False, help="Execute from current path", callback=g_exclusivity.validate, ), ] = None, skip_prompt: Annotated[ bool, typer.Option( show_default=False, help="Do not prompt user for input, use default options", ), ] = False, enable_telemetry: Annotated[ bool, typer.Option( show_default=False, hidden=True, help="Enable tracking", ), ] = False, version: bool = typer.Option( False, "--version", "-v", help="Print version and exit", ), ): if version: rprint(ConfigManager().get_cli_version()) ctx.exit(0) workspace_manager.setup_workspace_manager(workspace, here, recent, skip_prompt) tracking.prompt_tracking_consent(skip_prompt, default_value=enable_telemetry) if ctx.invoked_subcommand is None: rprint("[bold yellow]Welcome to Comfy CLI![/bold yellow]: https://github.com/Comfy-Org/comfy-cli") rprint(ctx.get_help()) ctx.exit() # TODO: Move this to proper place # start_time = time.time() # workspace_manager.scan_dir() # end_time = time.time() # # logging.info(f"scan_dir took {end_time - start_time:.2f} seconds to run") def validate_commit_and_version(commit: str | None, ctx: typer.Context) -> str | None: """ Validate that the commit is not specified unless the version is 'nightly'. """ version = ctx.params.get("version") if commit and version != "nightly": raise typer.BadParameter("You can only specify the commit if the version is 'nightly'.") return commit def _resolve_cuda( gpu: GPU_OPTION | None, cuda_version: CUDAVersion | None, ) -> tuple[CUDAVersion | None, str | None]: """Resolve the CUDA wheel tag for an NVIDIA install. Returns (cuda_version_enum_or_None, cuda_tag_string_or_None). When the user passed an explicit --cuda-version, that is used as-is. Otherwise auto-detection is attempted. """ if gpu != GPU_OPTION.NVIDIA: return cuda_version, None if cuda_version is not None: tag = f"cu{cuda_version.value.replace('.', '')}" rprint(f"[bold]Using explicit CUDA version:[/bold] {cuda_version.value} ({tag})") return cuda_version, tag drv = detect_cuda_driver_version() if drv is not None: tag = resolve_cuda_wheel(drv) if tag is not None: rprint(f"[bold green]Detected CUDA driver version:[/bold green] {drv[0]}.{drv[1]} → using {tag}") return None, tag rprint( f"[bold yellow]Warning:[/bold yellow] CUDA driver {drv[0]}.{drv[1]} is too old for any known PyTorch wheel. " f"Falling back to {DEFAULT_CUDA_TAG}. Use `--cuda-version` to override." ) return None, DEFAULT_CUDA_TAG rprint( f"[bold yellow]Warning:[/bold yellow] Could not detect CUDA driver version. " f"Falling back to {DEFAULT_CUDA_TAG}. Use `--cuda-version` to override." ) return None, DEFAULT_CUDA_TAG @app.command(help="Download and install ComfyUI and ComfyUI-Manager") @tracking.track_command() def install( url: Annotated[ str, typer.Option( show_default=False, help="url or local path pointing to the ComfyUI core git repo to be installed. A specific branch can optionally be specified using a setuptools-like syntax, eg https://foo.git@bar", ), ] = constants.COMFY_GITHUB_URL, version: Annotated[ str, typer.Option( show_default=False, help="Specify version of ComfyUI to install. Default is nightl, which is the latest commit on master branch. Other options include: latest, which is the latest stable release. Or a specific version number, eg. 0.2.0", callback=validate_version, ), ] = "nightly", restore: Annotated[ bool, typer.Option( show_default=False, help="Restore dependencies for installed ComfyUI if not installed", ), ] = False, skip_manager: Annotated[ bool, typer.Option(show_default=False, help="Skip installing the manager component"), ] = False, skip_torch_or_directml: Annotated[ bool, typer.Option(show_default=False, help="Skip installing PyTorch Or DirectML"), ] = False, skip_requirement: Annotated[ bool, typer.Option(show_default=False, help="Skip installing requirements.txt") ] = False, nvidia: Annotated[ bool | None, typer.Option( show_default=False, help="Install for Nvidia gpu", callback=g_gpu_exclusivity.validate, ), ] = None, cuda_version: Annotated[CUDAVersion | None, typer.Option(show_default=False)] = None, rocm_version: Annotated[ROCmVersion, typer.Option(show_default=True)] = ROCmVersion.v6_3, amd: Annotated[ bool | None, typer.Option( show_default=False, help="Install for AMD gpu", callback=g_gpu_exclusivity.validate, ), ] = None, m_series: Annotated[ bool | None, typer.Option( show_default=False, help="Install for Mac M-Series gpu", callback=g_gpu_exclusivity.validate, ), ] = None, intel_arc: Annotated[ bool | None, typer.Option( hidden=True, show_default=False, help="Install for Intel Arc gpu", callback=g_gpu_exclusivity.validate, ), ] = None, cpu: Annotated[ bool | None, typer.Option( show_default=False, help="Install for CPU", callback=g_gpu_exclusivity.validate, ), ] = None, commit: Annotated[ str | None, typer.Option(help="Specify commit hash for ComfyUI", callback=validate_commit_and_version) ] = None, fast_deps: Annotated[ bool, typer.Option( "--fast-deps", show_default=False, help="Use uv instead of pip for dependency resolution (comfy-cli built-in resolver)", ), ] = False, pr: Annotated[ str | None, typer.Option( show_default=False, help="Install from a specific PR. Supports formats: username:branch, #123, or PR URL", ), ] = None, ): check_for_updates() checker = EnvChecker() comfy_path, _ = workspace_manager.get_workspace_path() is_comfy_installed_at_path, resolved_path = check_comfy_repo(comfy_path) if is_comfy_installed_at_path and not restore: rprint(f"[bold red]ComfyUI is already installed at the specified path:[/bold red] {comfy_path}\n") rprint( "[bold yellow]If you want to restore dependencies, add the '--restore' option.[/bold yellow]", ) raise typer.Exit(code=1) if resolved_path is not None: comfy_path = resolved_path if checker.python_version.major < 3 or checker.python_version.minor < 9: rprint("[bold red]Python version 3.9 or higher is required to run ComfyUI.[/bold red]") rprint(f"You are currently using Python version {env_checker.format_python_version(checker.python_version)}.") platform = utils.get_os() if pr and (version not in {None, "nightly"} or commit): rprint("--pr cannot be used with --version or --commit") raise typer.Exit(code=1) if cpu: rprint("[bold yellow]Installing for CPU[/bold yellow]") install_inner.execute( url, comfy_path, restore, skip_manager, commit=commit, version=version, gpu=None, cuda_version=cuda_version, cuda_tag=None, rocm_version=rocm_version, plat=platform, skip_torch_or_directml=skip_torch_or_directml, skip_requirement=skip_requirement, fast_deps=fast_deps, pr=pr, ) rprint(f"ComfyUI is installed at: {comfy_path}") return None if nvidia and platform == constants.OS.MACOS: rprint("[bold red]Nvidia GPU is never on MacOS. What are you smoking? 🤔[/bold red]") raise typer.Exit(code=1) if platform != constants.OS.MACOS and m_series: rprint(f"[bold red]You are on {platform} bruh [/bold red]") gpu = None if nvidia: gpu = GPU_OPTION.NVIDIA elif amd: gpu = GPU_OPTION.AMD elif m_series: gpu = GPU_OPTION.MAC_M_SERIES elif intel_arc: gpu = GPU_OPTION.INTEL_ARC else: if platform == constants.OS.MACOS: gpu = ui.prompt_select_enum( "What type of Mac do you have?", [GPU_OPTION.MAC_M_SERIES, GPU_OPTION.MAC_INTEL], ) else: gpu = ui.prompt_select_enum( "What GPU do you have?", [GPU_OPTION.NVIDIA, GPU_OPTION.AMD, GPU_OPTION.INTEL_ARC], ) if gpu is None and not cpu: rprint( "[bold red]No GPU option selected or `--cpu` enabled, use --\\[gpu option] flag (e.g. --nvidia) to pick GPU. use `--cpu` to install for CPU. Exiting...[/bold red]" ) raise typer.Exit(code=1) cuda_version, cuda_tag = _resolve_cuda(gpu, cuda_version) if not skip_torch_or_directml else (cuda_version, None) install_inner.execute( url, comfy_path, restore, skip_manager, commit=commit, gpu=gpu, version=version, cuda_version=cuda_version, cuda_tag=cuda_tag, rocm_version=rocm_version, plat=platform, skip_torch_or_directml=skip_torch_or_directml, skip_requirement=skip_requirement, fast_deps=fast_deps, pr=pr, ) rprint(f"ComfyUI is installed at: {comfy_path}") @app.command(help="Update ComfyUI Environment [all|comfy]") @tracking.track_command() def update( target: str = typer.Argument( "comfy", help="[all|comfy]", autocompletion=utils.create_choice_completer(["all", "comfy"]), ), ): if target not in ["all", "comfy"]: typer.echo( f"Invalid target: {target}. Allowed targets are 'all', 'comfy'.", err=True, ) raise typer.Exit(code=1) comfy_path = workspace_manager.workspace_path if "all" == target: custom_nodes.command.execute_cm_cli(["update", "all"]) else: rprint(f"Updating ComfyUI in {comfy_path}...") if comfy_path is None: rprint("ComfyUI path is not found.") raise typer.Exit(code=1) os.chdir(comfy_path) subprocess.run(["git", "pull"], check=True) python = resolve_workspace_python(comfy_path) subprocess.run( [python, "-m", "pip", "install", "-r", "requirements.txt"], check=True, ) try: custom_nodes.command.update_node_id_cache() except (FileNotFoundError, subprocess.CalledProcessError) as e: rprint(f"[yellow]Failed to update node id cache: {e}[/yellow]") @app.command(help="Run API workflow file using the ComfyUI launched by `comfy launch --background`") @tracking.track_command() def run( workflow: Annotated[str, typer.Option(help="Path to the workflow API json file.")], wait: Annotated[ bool, typer.Option(help="If the command should wait until execution completes."), ] = True, verbose: Annotated[ bool, typer.Option(help="Enables verbose output of the execution process."), ] = False, host: Annotated[ str | None, typer.Option(help="The IP/hostname where the ComfyUI instance is running, e.g. 127.0.0.1 or localhost."), ] = None, port: Annotated[ int | None, typer.Option(help="The port where the ComfyUI instance is running, e.g. 8188."), ] = None, timeout: Annotated[ int | None, typer.Option(help="The timeout in seconds for the workflow execution."), ] = 30, api_key: Annotated[ str | None, typer.Option( "--api-key", envvar="COMFY_API_KEY", help=( "Comfy API key for API Nodes (Partner Nodes). " "Embedded in the prompt body as extra_data.api_key_comfy_org on POST /prompt. " "For scripting, prefer the COMFY_API_KEY environment variable so the secret " "stays out of shell history." ), ), ] = None, ): if api_key: api_key = api_key.strip() or None config = ConfigManager() if host: s = host.split(":") host = s[0] if not port and len(s) == 2: port = int(s[1]) local_paths = False if config.background: if not host: host = config.background[0] local_paths = True if port: local_paths = False else: port = config.background[1] if not host: host = "127.0.0.1" if not port: port = 8188 run_inner.execute(workflow, host, port, wait, verbose, local_paths, timeout, api_key=api_key) def validate_comfyui(_env_checker): if _env_checker.comfy_repo is None: rprint("[bold red]If ComfyUI is not installed, this feature cannot be used.[/bold red]") raise typer.Exit(code=1) @app.command(help="Stop background ComfyUI") @tracking.track_command() def stop(): if constants.CONFIG_KEY_BACKGROUND not in ConfigManager().config["DEFAULT"]: rprint("[bold red]No ComfyUI is running in the background.[/bold red]\n") raise typer.Exit(code=1) bg_info = ConfigManager().background if not bg_info: rprint("[bold red]No ComfyUI is running in the background.[/bold red]\n") raise typer.Exit(code=1) is_killed = utils.kill_all(bg_info[2]) if not is_killed: rprint("[bold red]Failed to stop ComfyUI in the background.[/bold red]\n") else: rprint(f"[bold yellow]Background ComfyUI is stopped.[/bold yellow] ({bg_info[0]}:{bg_info[1]})") ConfigManager().remove_background() @app.command(help="Launch ComfyUI: ?[--background] ?[-- ]") @tracking.track_command() def launch( extra: list[str] = typer.Argument(None), background: Annotated[bool, typer.Option(help="Launch ComfyUI in background")] = False, frontend_pr: Annotated[ str | None, typer.Option( "--frontend-pr", show_default=False, help="Use a specific frontend PR. Supports formats: username:branch, #123, or PR URL", ), ] = None, ): launch_command(background, extra, frontend_pr) @app.command("set-default", help="Set default ComfyUI path") @tracking.track_command() def set_default( workspace_path: str, launch_extras: Annotated[str, typer.Option(help="Specify extra options for launch")] = "", ): comfy_path = os.path.abspath(os.path.expanduser(workspace_path)) if not os.path.exists(comfy_path): rprint( f"\nPath not found: {comfy_path}.\n", file=sys.stderr, ) raise typer.Exit(code=1) is_comfy_repo, resolved_path = check_comfy_repo(comfy_path) if not is_comfy_repo: rprint( f"\nSpecified path is not a ComfyUI path: {comfy_path}.\n", file=sys.stderr, ) raise typer.Exit(code=1) comfy_path = resolved_path rprint(f"Specified path is set as default ComfyUI path: {comfy_path} ") workspace_manager.set_default_workspace(comfy_path) workspace_manager.set_default_launch_extras(launch_extras) @app.command(help="Show which ComfyUI is selected.") @tracking.track_command() def which(): comfy_path = workspace_manager.workspace_path if comfy_path is None: rprint( "ComfyUI not found, please run 'comfy install', run 'comfy' in a ComfyUI directory, or specify the workspace path with '--workspace'." ) raise typer.Exit(code=1) rprint(f"Target ComfyUI path: {comfy_path}") @app.command(help="Print out current environment variables.") @tracking.track_command() def env(): check_for_updates() env_data = EnvChecker().fill_print_table() workspace_data = workspace_manager.fill_print_table() all_data = env_data + workspace_data ui.display_table( data=all_data, column_names=[":laptop_computer: Environment", "Value"], title="Environment Information", ) @app.command(hidden=True) @tracking.track_command() def nodes(): rprint("\n[bold red] No such command, did you mean 'comfy node' instead?[/bold red]\n") @app.command(hidden=True) @tracking.track_command() def models(): rprint("\n[bold red] No such command, did you mean 'comfy model' instead?[/bold red]\n") @app.command(help="Provide feedback on the Comfy CLI tool.") @tracking.track_command() def feedback(): rprint("Feedback Collection for Comfy CLI Tool\n") # General Satisfaction general_satisfaction_score = ui.prompt_select( question="On a scale of 1 to 5, how satisfied are you with the Comfy CLI tool? (1 being very dissatisfied and 5 being very satisfied)", choices=["1", "2", "3", "4", "5"], force_prompting=True, ) tracking.track_event("feedback_general_satisfaction", {"score": general_satisfaction_score}) # Usability and User Experience usability_satisfaction_score = ui.prompt_select( question="On a scale of 1 to 5, how satisfied are you with the usability and user experience of the Comfy CLI tool? (1 being very dissatisfied and 5 being very satisfied)", choices=["1", "2", "3", "4", "5"], force_prompting=True, ) tracking.track_event("feedback_usability_satisfaction", {"score": usability_satisfaction_score}) # Additional Feature-Specific Feedback if questionary.confirm("Do you want to provide additional feature-specific feedback on our GitHub page?").ask(): tracking.track_event("feedback_additional") webbrowser.open("https://github.com/Comfy-Org/comfy-cli/issues/new/choose") rprint("Thank you for your feedback!") @app.command(hidden=True) @app.command( help="Given an existing installation of comfy core and any custom nodes, installs any needed python dependencies" ) @tracking.track_command() def dependency(): comfy_path, _ = workspace_manager.get_workspace_path() python = resolve_workspace_python(comfy_path) depComp = DependencyCompiler(cwd=comfy_path, executable=python) depComp.compile_deps() depComp.install_deps() @app.command(help="Download a standalone Python interpreter and dependencies based on an existing comfyui workspace") @tracking.track_command() def standalone( cli_spec: Annotated[ str, typer.Option( show_default=False, help="setuptools-style requirement specificer pointing to an instance of comfy-cli", ), ] = "comfy-cli", pack_wheels: Annotated[ bool, typer.Option( show_default=False, help="Pack requirement wheels in archive when creating standalone bundle", ), ] = False, platform: Annotated[ constants.OS | None, typer.Option( show_default=False, help="Create standalone Python for specified platform", ), ] = None, proc: Annotated[ constants.PROC | None, typer.Option( show_default=False, help="Create standalone Python for specified processor", ), ] = None, rehydrate: Annotated[ bool, typer.Option( show_default=False, help="Create standalone Python for CPU", ), ] = False, ): comfy_path, _ = workspace_manager.get_workspace_path() platform = utils.get_os() if platform is None else platform proc = utils.get_proc() if proc is None else proc if rehydrate: sty = StandalonePython.FromTarball(fpath="python.tgz") sty.rehydrate_comfy_deps(packWheels=pack_wheels) else: sty = StandalonePython.FromDistro(platform=platform, proc=proc) sty.dehydrate_comfy_deps(comfyDir=comfy_path, extraSpecs=[], packWheels=pack_wheels) sty.to_tarball() generate_command.register_with(app) app.add_typer(models_command.app, name="model", help="Manage models.") app.add_typer(custom_nodes.app, name="node", help="Manage custom nodes.") app.add_typer(custom_nodes.manager_app, name="manager", help="Manage ComfyUI-Manager.") app.add_typer(pr_command.app, name="pr-cache", help="Manage PR cache.") app.add_typer(code_search.app, name="code-search", help="Search code across ComfyUI repositories.") app.add_typer(code_search.app, name="cs", hidden=True) app.add_typer(tracking.app, name="tracking", help="Manage analytics tracking settings.") ================================================ FILE: comfy_cli/command/__init__.py ================================================ from . import custom_nodes, install __all__ = ["custom_nodes", "install"] ================================================ FILE: comfy_cli/command/code_search.py ================================================ """CLI commands for searching code across ComfyUI repositories.""" import json import re import sys from typing import Annotated from urllib.parse import quote import requests import typer from rich.console import Console from rich.text import Text from comfy_cli import tracking app = typer.Typer() console = Console() API_URL = "https://comfy-codesearch.vercel.app/api/search/code" DEFAULT_COUNT = 20 REQUEST_TIMEOUT = 30 _TYPE_FILTER_RE = re.compile(r"(^|\s)type:") def _build_query(query: str, repo: str | None, count: int) -> str: parts = [] if repo: if "/" not in repo: repo = f"Comfy-Org/{repo}" parts.append(f"repo:^{re.escape(repo)}$") # Only default to file matches when the user hasn't specified their own # type: filter — otherwise respect whatever they passed (e.g. type:commit). if not _TYPE_FILTER_RE.search(query): parts.append("type:file") parts.append(f"count:{count}") parts.append(query) return " ".join(parts) def _fetch_results(query: str) -> dict: response = requests.get(API_URL, params={"query": query}, timeout=REQUEST_TIMEOUT) response.raise_for_status() return response.json() def _format_results(search: dict) -> list[dict]: raw_results = search.get("results", {}).get("results", []) formatted = [] for result in raw_results: repo_info = result.get("repository") or {} repo_name = repo_info.get("name", "") clean_name = repo_name.removeprefix("github.com/") file_info = result.get("file") or {} file_path = file_info.get("path", "") if not clean_name or not file_path: continue default_branch = repo_info.get("defaultBranch") or {} branch_name = default_branch.get("displayName", "main") commit_hash = (default_branch.get("target") or {}).get("commit", {}).get("oid", "") ref = commit_hash or branch_name encoded_path = quote(file_path, safe="/") file_url = f"https://github.com/{clean_name}/blob/{ref}/{encoded_path}" line_matches = result.get("lineMatches") or [] matches = [] for m in line_matches: line = m.get("lineNumber", 0) + 1 preview = m.get("preview", "").rstrip() matches.append({"line": line, "preview": preview, "url": f"{file_url}#L{line}"}) formatted.append( { "repository": clean_name, "file": file_path, "file_url": file_url, "branch": branch_name, "commit": commit_hash, "matches": matches, } ) return formatted def _get_stats(search: dict) -> dict: return { "approximate_count": search.get("stats", {}).get("approximateResultCount", "0"), "match_count": search.get("results", {}).get("matchCount", 0), "limit_hit": search.get("results", {}).get("limitHit", False), } def _print_results(results: list[dict], stats: dict, json_output: bool) -> None: if json_output: print(json.dumps({"stats": stats, "results": results}, indent=2)) return if not results: console.print("[yellow]No results found.[/yellow]") return # Use raw isatty() rather than Rich's console.is_terminal: Rich treats # FORCE_COLOR=1 / TTY_COMPATIBLE=1 as terminal-capable even when stdout # is redirected, but OSC 8 escapes in a piped stream defeat the whole # point of this branch (hiding URLs from humans, exposing them to AI). is_tty = sys.stdout.isatty() for file_result in results: repo = file_result["repository"] path = file_result["file"] file_url = file_result["file_url"] header = Text() if is_tty: # Humans: clickable OSC 8 hyperlink, URL hidden from visible output. header.append(f"{repo} / {path}", style=f"bold cyan link {file_url}") else: # Non-TTY (pipes, AI agents): print the raw URL once per file so # agents can synthesize #L anchors themselves. header.append(f"{repo} / {path}\n") header.append(f" {file_url}", style="dim") console.print(header) for match in file_result["matches"]: line_text = Text(" ") line_style = f"green link {match['url']}" if is_tty else "green" line_text.append(f"L{match['line']:>5}", style=line_style) line_text.append(f" {match['preview']}") console.print(line_text) console.print() limit_msg = " (limit hit — use --count to fetch more)" if stats.get("limit_hit") else "" console.print( f"[dim]{stats['approximate_count']} approximate results, {stats['match_count']} matches returned{limit_msg}[/dim]" ) @app.callback(invoke_without_command=True) @tracking.track_command() def code_search( query: Annotated[ str, typer.Argument( help=( "Search query (supports Sourcegraph syntax). Defaults to file matches; " "pass your own `type:` filter (e.g. `type:commit`) to override." ), ), ], repo: Annotated[ str | None, typer.Option("--repo", "-r", help="Filter by repository (e.g. ComfyUI, Comfy-Org/ComfyUI)"), ] = None, count: Annotated[ int, typer.Option("--count", "-n", help="Maximum number of results"), ] = DEFAULT_COUNT, json_output: Annotated[ bool, typer.Option("--json", "-j", help="Output results as JSON"), ] = False, ): """Search code across ComfyUI repositories.""" built_query = _build_query(query, repo, count) try: data = _fetch_results(built_query) except requests.ConnectionError: console.print("[bold red]Error: Could not connect to the code search service.[/bold red]") raise typer.Exit(code=1) except requests.Timeout: console.print("[bold red]Error: Request timed out.[/bold red]") raise typer.Exit(code=1) except requests.HTTPError as e: status = e.response.status_code if e.response is not None else "unknown" console.print(f"[bold red]Error: HTTP {status}[/bold red]") raise typer.Exit(code=1) search = data.get("data", {}).get("search", {}) results = _format_results(search) stats = _get_stats(search) _print_results(results, stats, json_output=json_output) ================================================ FILE: comfy_cli/command/custom_nodes/__init__.py ================================================ from .command import app, manager_app __all__ = ["app", "manager_app"] ================================================ FILE: comfy_cli/command/custom_nodes/bisect_custom_nodes.py ================================================ from __future__ import annotations import json import os from pathlib import Path from typing import Annotated, Literal, NamedTuple import typer from comfy_cli.command.custom_nodes.cm_cli_util import execute_cm_cli from comfy_cli.command.launch import launch as launch_command bisect_app = typer.Typer() # File to store the state of bisect default_state_file = Path("bisect_state.json") class BisectState(NamedTuple): status: Literal["idle", "running", "resolved"] # All nodes in the current bisect session all: list[str] # The range of nodes that contains the bad node range: list[str] # The active set of nodes to test active: list[str] # The arguments to pass to the ComfyUI launch command launch_args: list[str] = [] def good(self) -> BisectState: """The active set of nodes is good, narrowing down the potential problem area.""" if self.status != "running": raise ValueError("No bisect session running.") new_range = list(set(self.range) - set(self.active)) if len(new_range) == 1: return BisectState( status="resolved", all=self.all, launch_args=self.launch_args, range=new_range, active=[], ) return BisectState( status="running", all=self.all, launch_args=self.launch_args, range=new_range, active=new_range[len(new_range) // 2 :], ) def bad(self) -> BisectState: """The active set of nodes is bad, indicating the problem is within this set.""" if self.status != "running": raise ValueError("No bisect session running.") new_range = self.active if len(new_range) == 1: return BisectState( status="resolved", all=self.all, launch_args=self.launch_args, range=new_range, active=[], ) return BisectState( status="running", all=self.all, launch_args=self.launch_args, range=new_range, active=new_range[len(new_range) // 2 :], ) def save(self, state_file=None): self.set_custom_node_enabled_states() state_file = state_file or default_state_file with state_file.open("w") as f: json.dump(self._asdict(), f) # pylint: disable=no-member def reset(self): BisectState( "idle", all=self.all, launch_args=self.launch_args, range=self.all, active=self.all, ).set_custom_node_enabled_states() return BisectState("idle", self.all, self.all, self.all, self.launch_args) @classmethod def load(cls, state_file=None) -> BisectState: state_file = state_file or default_state_file if state_file.exists(): with state_file.open() as f: return BisectState(**json.load(f)) return BisectState("idle", [], [], []) @property def inactive_nodes(self) -> list[str]: return list(set(self.all) - set(self.active)) def set_custom_node_enabled_states(self): if self.active: execute_cm_cli(["enable", *self.active]) if self.inactive_nodes: execute_cm_cli(["disable", *self.inactive_nodes]) def __str__(self): active_list = "\n".join([f"{i + 1:3}. {node}" for i, node in enumerate(self.active)]) return f"""BisectState(status={self.status}) set of nodes with culprit: {len(self.range)} set of nodes to test: {len(self.active)} -------------------------- {active_list}""" def parse_cm_output(cm_output: str, pinned_nodes: set[str] | None = None) -> list[str]: """Parse cm_cli simple-show output into a list of node names. cm_cli simple-show always formats node entries as ``name@version`` (see ComfyUI-Manager cm_cli show_list). We whitelist on the ``@`` separator so any progress/status lines are ignored regardless of their prefix. """ pinned = pinned_nodes or set() return [ stripped for line in cm_output.strip().split("\n") if (stripped := line.strip()) and "@" in stripped and stripped not in pinned ] @bisect_app.command( help="Start a new bisect session with optionally pinned nodes to always enable, and optional ComfyUI launch args." + "?[--pinned-nodes PINNED_NODES]" + "?[-- ]" ) def start( pinned_nodes: Annotated[str, typer.Option(help="Pinned nodes always enable during the bisect")] = "", extra: list[str] = typer.Argument(None), ): """Start a new bisect session. The initial state is bad with all custom nodes enabled, good with all custom nodes disabled.""" if BisectState.load().status != "idle": typer.echo("A bisect session is already running.") raise typer.Exit() pinned_nodes = {s.strip() for s in pinned_nodes.split(",") if s} cm_output: str | None = execute_cm_cli(["simple-show", "enabled"]) if cm_output is None: typer.echo("Failed to fetch the list of nodes.") raise typer.Exit() nodes_list = parse_cm_output(cm_output, pinned_nodes) state = BisectState( status="running", all=nodes_list, range=nodes_list, active=nodes_list, launch_args=extra or [], ) state.save() typer.echo(f"Bisect session started.\n{state}") if pinned_nodes: typer.echo(f"Pinned nodes: {', '.join(pinned_nodes)}") bad() @bisect_app.command(help="Mark the current active set as good, indicating the problem is outside the test set.") def good(): state = BisectState.load() if state.status != "running": typer.echo("No bisect session running or no active nodes to process.") raise typer.Exit() new_state = state.good() if new_state.status == "resolved": assert len(new_state.range) == 1 typer.echo(f"Problematic node identified: {new_state.range[0]}") reset() else: new_state.save() typer.echo(new_state) launch_command(background=False, extra=state.launch_args) @bisect_app.command(help="Mark the current active set as bad, indicating the problem is within the test set.") def bad(): state = BisectState.load() if state.status != "running": typer.echo("No bisect session running or no active nodes to process.") raise typer.Exit() new_state = state.bad() if new_state.status == "resolved": assert len(new_state.range) == 1 typer.echo(f"Problematic node identified: {new_state.range[0]}") reset() else: new_state.save() typer.echo(new_state) launch_command(background=False, extra=state.launch_args) @bisect_app.command(help="Reset the current bisect session.") def reset(): if default_state_file.exists(): BisectState.load().reset() os.unlink(default_state_file) typer.echo("Bisect session reset.") else: typer.echo("No bisect session to reset.") ================================================ FILE: comfy_cli/command/custom_nodes/cm_cli_util.py ================================================ from __future__ import annotations import importlib.util import os import subprocess import sys import threading import uuid from functools import lru_cache import typer from rich import print from comfy_cli.config_manager import ConfigManager from comfy_cli.resolve_python import resolve_workspace_python from comfy_cli.uv import DependencyCompiler from comfy_cli.workspace_manager import WorkspaceManager, check_comfy_repo workspace_manager = WorkspaceManager() # set of commands that invalidate (ie require an update of) dependencies after they are run _dependency_cmds = { "install", "reinstall", } @lru_cache(maxsize=1) def find_cm_cli() -> bool: """Check if cm_cli module is available in the workspace Python. First checks the workspace venv Python (primary path — matches the Python used by execute_cm_cli). Falls back to the current Python environment only when the workspace Python is the same as sys.executable. Results are cached for the session lifetime. """ ws = workspace_manager.workspace_path if ws: python = resolve_workspace_python(ws) if python != sys.executable: # Workspace uses a different Python — check that one try: result = subprocess.run( [python, "-c", "import cm_cli"], capture_output=True, timeout=10, ) return result.returncode == 0 except (subprocess.TimeoutExpired, OSError): return False # Same Python or no workspace — check current environment return importlib.util.find_spec("cm_cli") is not None def resolve_manager_gui_mode(not_installed_value: str | None = None) -> str | None: """Resolve manager GUI mode from config, with legacy migration. Priority: CONFIG_KEY_MANAGER_GUI_MODE > CONFIG_KEY_MANAGER_GUI_ENABLED > auto-detect. Args: not_installed_value: Value to return when manager is not installed and no config exists. Callers use None (launch — means "no flags") or "not-installed" (display). """ from comfy_cli import constants config_manager = ConfigManager() mode = config_manager.get(constants.CONFIG_KEY_MANAGER_GUI_MODE) if mode is not None: return mode # Legacy migration old_value = config_manager.get(constants.CONFIG_KEY_MANAGER_GUI_ENABLED) if old_value is not None: old_str = str(old_value).lower() if old_str in ("false", "0", "off"): return "disable" if old_str in ("true", "1", "on"): return "enable-gui" # No config at all — check manager availability if not find_cm_cli(): return not_installed_value return "enable-gui" def execute_cm_cli( args, channel=None, fast_deps=False, no_deps=False, uv_compile=False, mode=None, raise_on_error=False ) -> str | None: _config_manager = ConfigManager() workspace_path = workspace_manager.workspace_path if not workspace_path: print("\n[bold red]ComfyUI path is not resolved.[/bold red]\n", file=sys.stderr) raise typer.Exit(code=1) if not check_comfy_repo(workspace_path)[0]: print( f"\n[bold red]'{workspace_path}' is not a valid ComfyUI workspace.[/bold red]\n" "Run [bold]comfy install[/bold] to set up ComfyUI, or use [bold]--workspace [/bold] to specify a valid path.\n", file=sys.stderr, ) raise typer.Exit(code=1) if not find_cm_cli(): print( "\n[bold red]ComfyUI-Manager not found. 'cm-cli' command is not available.[/bold red]\n", file=sys.stderr, ) raise typer.Exit(code=1) python = resolve_workspace_python(workspace_path) cmd = [python, "-m", "cm_cli"] + args if channel is not None: cmd += ["--channel", channel] if uv_compile: cmd += ["--uv-compile"] elif fast_deps or no_deps: cmd += ["--no-deps"] if mode is not None: cmd += ["--mode", mode] new_env = os.environ.copy() session_path = os.path.join(_config_manager.get_config_path(), "tmp", str(uuid.uuid4())) new_env["__COMFY_CLI_SESSION__"] = session_path new_env["COMFYUI_PATH"] = workspace_path new_env["PYTHONUNBUFFERED"] = "1" print(f"Execute from: {workspace_path}") print(f"Command: {cmd}") try: process = subprocess.Popen( cmd, env=new_env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", ) # Read stderr in a background thread to avoid pipe deadlock on Windows. # Windows pipe buffers are small (4 KB); if stderr fills up while the main # thread is blocked reading stdout line-by-line, the child process blocks # on stderr writes and never closes stdout — classic deadlock. stderr_lines: list[str] = [] def _drain_stderr(): for line in process.stderr: sys.stderr.write(line) sys.stderr.flush() stderr_lines.append(line) stderr_thread = threading.Thread(target=_drain_stderr, daemon=True) stderr_thread.start() stdout_lines = [] for line in process.stdout: sys.stdout.write(line) sys.stdout.flush() stdout_lines.append(line) stderr_thread.join(timeout=10) return_code = process.wait() stdout_output = "".join(stdout_lines) stderr_output = "".join(stderr_lines) if return_code != 0: raise subprocess.CalledProcessError(return_code, cmd, output=stdout_output, stderr=stderr_output) if fast_deps and args[0] in _dependency_cmds: # we're using the fast_deps behavior and just ran a command that invalidated the dependencies depComp = DependencyCompiler(cwd=workspace_path, executable=python) depComp.compile_deps() depComp.install_deps() workspace_manager.set_recent_workspace(workspace_path) return stdout_output except subprocess.CalledProcessError as e: if raise_on_error: raise e if e.returncode == 1: print(f"\n[bold red]Execution error: {cmd}[/bold red]\n", file=sys.stderr) return None if e.returncode == 2: return None raise e ================================================ FILE: comfy_cli/command/custom_nodes/command.py ================================================ import os import pathlib import platform import shutil import subprocess import sys import uuid from enum import Enum from typing import Annotated import typer from rich import print from rich.console import Console from comfy_cli import constants, logging, tracking, ui, utils from comfy_cli.command.custom_nodes.bisect_custom_nodes import bisect_app from comfy_cli.command.custom_nodes.cm_cli_util import execute_cm_cli, find_cm_cli from comfy_cli.config_manager import ConfigManager from comfy_cli.constants import NODE_ZIP_FILENAME from comfy_cli.file_utils import ( DownloadException, download_file, extract_package_as_zip, upload_file_to_signed_url, zip_files, ) from comfy_cli.registry import ( RegistryAPI, extract_node_configuration, initialize_project_config, ) from comfy_cli.resolve_python import resolve_workspace_python from comfy_cli.workspace_manager import WorkspaceManager console = Console() app = typer.Typer() app.add_typer(bisect_app, name="bisect", help="Bisect custom nodes for culprit node.") manager_app = typer.Typer() workspace_manager = WorkspaceManager() registry_api = RegistryAPI() # Enum for show command target class ShowTarget(str, Enum): INSTALLED = "installed" ENABLED = "enabled" NOT_INSTALLED = "not-installed" DISABLED = "disabled" ALL = "all" SNAPSHOT = "snapshot" SNAPSHOT_LIST = "snapshot-list" def _resolve_uv_compile(uv_compile: bool | None, fast_deps: bool = False, no_deps: bool = False) -> bool: """Resolve effective uv_compile value from explicit flag, config default, and conflicting flags. Priority: explicit --uv-compile/--no-uv-compile > config default > False. When config default is True, explicit --fast-deps or --no-deps silently override it. """ if uv_compile is not None: return uv_compile config_manager = ConfigManager() config_value = config_manager.get(constants.CONFIG_KEY_UV_COMPILE_DEFAULT) if config_value is not None and config_value.lower() == "true": if fast_deps or no_deps: return False return True return False def validate_comfyui_manager(): if not find_cm_cli(): print("[bold red]ComfyUI-Manager is not installed. 'cm-cli' command is not available.[/bold red]") raise typer.Exit(code=1) def run_script(cmd, cwd="."): if len(cmd) > 0 and cmd[0].startswith("#"): print(f"[ComfyUI-Manager] Unexpected behavior: `{cmd}`") return 0 subprocess.check_call(cmd, cwd=cwd) return 0 pip_map = None def get_installed_packages(): global pip_map if pip_map is None: try: python = resolve_workspace_python(workspace_manager.workspace_path) result = subprocess.check_output([python, "-m", "pip", "list"], universal_newlines=True) pip_map = {} for line in result.split("\n"): x = line.strip() if x: y = line.split() if y[0] == "Package" or y[0].startswith("-"): continue pip_map[y[0]] = y[1] except subprocess.CalledProcessError: print("[ComfyUI-Manager] Failed to retrieve the information of installed pip packages.") return set() return pip_map def try_install_script(repo_path, install_cmd, instant_execution=False): startup_script_path = os.path.join(workspace_manager.workspace_path, "startup-scripts") if not instant_execution and ( (len(install_cmd) > 0 and install_cmd[0].startswith("#")) or ( platform.system() == "Windows" # From Yoland: disable commit compare # and comfy_ui_commit_datetime.date() # >= comfy_ui_required_commit_datetime.date() ) ): if not os.path.exists(startup_script_path): os.makedirs(startup_script_path) script_path = os.path.join(startup_script_path, "install-scripts.txt") with open(script_path, "a", encoding="utf-8") as file: obj = [repo_path] + install_cmd file.write(f"{obj}\n") return True else: # From Yoland: Disable blacklisting # if len(install_cmd) == 5 and install_cmd[2:4] == ['pip', 'install']: # if is_blacklisted(install_cmd[4]): # print(f"[ComfyUI-Manager] skip black listed pip installation: '{install_cmd[4]}'") # return True print(f"\n## ComfyUI-Manager: EXECUTE => {install_cmd}") code = run_script(install_cmd, cwd=repo_path) # From Yoland: Disable warning # if platform.system() != "Windows": # try: # if comfy_ui_commit_datetime.date() < comfy_ui_required_commit_datetime.date(): # print("\n\n###################################################################") # print(f"[WARN] ComfyUI-Manager: Your ComfyUI version ({comfy_ui_revision})[{comfy_ui_commit_datetime.date()}] is too old. Please update to the latest version.") # print(f"[WARN] The extension installation feature may not work properly in the current installed ComfyUI version on Windows environment.") # print("###################################################################\n\n") # except: # pass if code != 0: print("install script failed") return False def execute_install_script(repo_path): install_script_path = os.path.join(repo_path, "install.py") requirements_path = os.path.join(repo_path, "requirements.txt") # From Yoland: disable lazy mode # if lazy_mode: # install_cmd = ["#LAZY-INSTALL-SCRIPT", sys.executable] # try_install_script(repo_path, install_cmd) # else: if os.path.exists(requirements_path): print("Install: pip packages") python = resolve_workspace_python(workspace_manager.workspace_path) # Absolute path so pip doesn't re-resolve it against cwd=repo_path # in try_install_script, which would double the path if repo_path # is relative. install_cmd = [python, "-m", "pip", "install", "-r", os.path.abspath(requirements_path)] try_install_script(repo_path, install_cmd) if os.path.exists(install_script_path): print("Install: install script") python = resolve_workspace_python(workspace_manager.workspace_path) install_cmd = [python, "install.py"] try_install_script(repo_path, install_cmd) @app.command("save-snapshot", help="Save a snapshot of the current ComfyUI environment") @tracking.track_command("node") def save_snapshot( output: Annotated[ str | None, typer.Option(show_default=False, help="Specify the output file path. (.json/.yaml)"), ] = None, ): if output is None: execute_cm_cli(["save-snapshot"]) else: output = os.path.abspath(output) # to compensate chdir execute_cm_cli(["save-snapshot", "--output", output]) @app.command("restore-snapshot", help="Restore snapshot from snapshot file") @tracking.track_command("node") def restore_snapshot( path: str, pip_non_url: bool | None = typer.Option( default=None, show_default=False, help="Restore for pip packages registered on PyPI.", ), pip_non_local_url: bool | None = typer.Option( default=None, show_default=False, help="Restore for pip packages registered at web URLs.", ), pip_local_url: bool | None = typer.Option( default=None, show_default=False, help="Restore for pip packages specified by local paths.", ), uv_compile: Annotated[ bool | None, typer.Option( "--uv-compile/--no-uv-compile", show_default=False, help="After restoring, batch-resolve all dependencies via uv pip compile (requires ComfyUI-Manager v4.1+)", ), ] = None, ): extras = [] if pip_non_url: extras += ["--pip-non-url"] if pip_non_local_url: extras += ["--pip-non-local-url"] if pip_local_url: extras += ["--pip-local-url"] path = os.path.abspath(path) execute_cm_cli(["restore-snapshot", path] + extras, uv_compile=_resolve_uv_compile(uv_compile)) @app.command("restore-dependencies", help="Restore dependencies from installed custom nodes") @tracking.track_command("node") def restore_dependencies( uv_compile: Annotated[ bool | None, typer.Option( "--uv-compile/--no-uv-compile", show_default=False, help="After restoring, batch-resolve all dependencies via uv pip compile (requires ComfyUI-Manager v4.1+)", ), ] = None, ): execute_cm_cli(["restore-dependencies"], uv_compile=_resolve_uv_compile(uv_compile)) @manager_app.command("disable", help="Disable ComfyUI-Manager completely") @tracking.track_command("node") def disable_manager(): """Disable ComfyUI-Manager. No manager flags will be passed to ComfyUI.""" config_manager = ConfigManager() config_manager.set(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable") print("[bold yellow]ComfyUI-Manager has been disabled.[/bold yellow]") print("No manager flags will be passed to ComfyUI on next launch.") @manager_app.command("enable-gui", help="Enable ComfyUI-Manager with new GUI") @tracking.track_command("node") def enable_gui(): """Enable ComfyUI-Manager with new GUI.""" config_manager = ConfigManager() config_manager.set(constants.CONFIG_KEY_MANAGER_GUI_MODE, "enable-gui") print("[bold green]ComfyUI-Manager GUI has been enabled.[/bold green]") print("[dim]ComfyUI will launch with: --enable-manager[/dim]") @manager_app.command("disable-gui", help="Enable ComfyUI-Manager without GUI") @tracking.track_command("node") def disable_gui(): """Enable ComfyUI-Manager but disable its GUI.""" config_manager = ConfigManager() config_manager.set(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable-gui") print("[bold green]ComfyUI-Manager enabled with GUI disabled.[/bold green]") print("[dim]ComfyUI will launch with: --enable-manager --disable-manager-ui[/dim]") @manager_app.command("enable-legacy-gui", help="Enable ComfyUI-Manager with legacy GUI") @tracking.track_command("node") def enable_legacy_gui(): """Enable ComfyUI-Manager with legacy GUI.""" config_manager = ConfigManager() config_manager.set(constants.CONFIG_KEY_MANAGER_GUI_MODE, "enable-legacy-gui") print("[bold green]ComfyUI-Manager legacy GUI has been enabled.[/bold green]") print("[dim]ComfyUI will launch with: --enable-manager --enable-manager-legacy-ui[/dim]") @manager_app.command("migrate-legacy", help="Migrate legacy git-cloned ComfyUI-Manager to .disabled") @tracking.track_command("node") def migrate_legacy( yes: Annotated[ bool, typer.Option("--yes", "-y", help="Skip confirmation prompt"), ] = False, ): """ Migrate legacy ComfyUI-Manager from custom_nodes/ to custom_nodes/.disabled/ Detects .enable-cli-only-mode file to set appropriate mode: - If .enable-cli-only-mode exists → mode = disable - Otherwise → mode = enable-gui """ if not workspace_manager.workspace_path: print("[bold red]ComfyUI workspace is not set.[/bold red]") print("[dim]Use --workspace or run from a ComfyUI directory.[/dim]") raise typer.Exit(code=1) custom_nodes_path = pathlib.Path(workspace_manager.workspace_path) / "custom_nodes" # Find legacy manager with case-insensitive matching (must be a real directory, not symlink) legacy_manager_path = None if custom_nodes_path.exists(): for item in custom_nodes_path.iterdir(): if item.is_dir() and not item.is_symlink() and item.name.lower() == "comfyui-manager": legacy_manager_path = item break # Check if legacy manager exists if legacy_manager_path is None: print("[bold yellow]No legacy ComfyUI-Manager found in custom_nodes/[/bold yellow]") print("Nothing to migrate.") return # Verify it's a git-cloned repository git_dir = legacy_manager_path / ".git" if not git_dir.exists(): print(f"[bold yellow]Warning: {legacy_manager_path.name} does not appear to be a git repository.[/bold yellow]") print("[dim]Expected a git-cloned ComfyUI-Manager. Skipping migration.[/dim]") return # Detect CLI-only mode before any changes cli_only_mode_file = legacy_manager_path / ".enable-cli-only-mode" cli_only_mode = cli_only_mode_file.exists() # Show what will happen and ask for confirmation print(f"[bold]Found legacy ComfyUI-Manager:[/bold] {legacy_manager_path}") print(f"[dim]CLI-only mode: {cli_only_mode}[/dim]") print() print("[bold]This will:[/bold]") print(f" 1. Move {legacy_manager_path.name} to custom_nodes/.disabled/") print(f" 2. Set manager mode to: {'disable' if cli_only_mode else 'enable-gui'}") print(" 3. Install manager_requirements.txt (if present)") print() if not yes: confirm = ui.prompt_confirm_action("Proceed with migration?", False) if not confirm: print("[dim]Migration cancelled.[/dim]") return # Create .disabled directory disabled_path = custom_nodes_path / ".disabled" disabled_path.mkdir(exist_ok=True) # Check if target already exists (case-insensitive) existing_target = None for item in disabled_path.iterdir(): if item.is_dir() and item.name.lower() == "comfyui-manager": existing_target = item break if existing_target is not None: print(f"[bold red]Target path already exists: {existing_target}[/bold red]") print("Please remove it manually and try again.") raise typer.Exit(code=1) # Move legacy manager (preserve original directory name) target_path = disabled_path / legacy_manager_path.name try: shutil.move(str(legacy_manager_path), str(target_path)) except OSError as e: print(f"[bold red]Failed to move legacy manager: {e}[/bold red]") raise typer.Exit(code=1) # Install manager_requirements.txt if present workspace_path = pathlib.Path(workspace_manager.workspace_path) manager_req_path = workspace_path / constants.MANAGER_REQUIREMENTS_FILE python = resolve_workspace_python(str(workspace_path)) install_success = False # Default to failure, set True only on success if manager_req_path.exists(): print("[dim]Installing ComfyUI-Manager dependencies...[/dim]") result = subprocess.run( [python, "-m", "pip", "install", "-r", str(manager_req_path)], check=False, ) if result.returncode != 0: print("[bold yellow]Warning: Failed to install ComfyUI-Manager dependencies.[/bold yellow]") print("[dim]You may need to run: pip install -r manager_requirements.txt[/dim]") else: install_success = True else: print("[bold yellow]Warning: manager_requirements.txt not found (older ComfyUI version?).[/bold yellow]") print("[dim]ComfyUI-Manager pip package not installed.[/dim]") # Set config mode config_manager = ConfigManager() if cli_only_mode or not install_success: config_manager.set(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable") print("[bold green]Legacy ComfyUI-Manager migrated to .disabled/[/bold green]") if cli_only_mode: print("[dim]Detected .enable-cli-only-mode → Manager set to: disable[/dim]") else: print("[dim]Manager installation failed → Manager set to: disable[/dim]") print("[dim]After fixing installation, run: comfy manager enable-gui[/dim]") else: config_manager.set(constants.CONFIG_KEY_MANAGER_GUI_MODE, "enable-gui") print("[bold green]Legacy ComfyUI-Manager migrated to .disabled/[/bold green]") print("[dim]Manager set to: enable-gui (new GUI)[/dim]") print("\n[bold]The new pip-installed ComfyUI-Manager will be used on next launch.[/bold]") @manager_app.command( "uv-compile-default", help="Set whether --uv-compile is used by default for custom node operations" ) @tracking.track_command("node") def uv_compile_default( enabled: Annotated[ bool, typer.Argument(help="true to enable, false to disable"), ], ): config_manager = ConfigManager() config_manager.set(constants.CONFIG_KEY_UV_COMPILE_DEFAULT, str(enabled)) if enabled: print("[bold green]uv-compile is now enabled by default.[/bold green]") print("[dim]Use --no-uv-compile to override for individual commands.[/dim]") else: print("[bold yellow]uv-compile default has been disabled.[/bold yellow]") print("[dim]Use --uv-compile to enable for individual commands.[/dim]") @manager_app.command(help="Clear reserved startup action in ComfyUI-Manager") @tracking.track_command("node") def clear(): execute_cm_cli(["clear"]) @app.command("update-cache", help="Force-fetch remote data and populate local Manager cache (blocking)") @tracking.track_command("node") def update_cache(): execute_cm_cli(["update-cache"]) # completers mode_completer = utils.create_choice_completer(["remote", "local", "cache"]) channel_completer = utils.create_choice_completer(["default", "recent", "dev", "forked", "tutorial", "legacy"]) def node_completer(incomplete: str) -> list[str]: try: config_manager = ConfigManager() tmp_path = os.path.join(config_manager.get_config_path(), "tmp", "node-cache.list") with open(tmp_path, encoding="UTF-8", errors="ignore") as cache_file: return [node_id for node_id in cache_file.readlines() if node_id.startswith(incomplete)] except Exception: return [] def node_or_all_completer(incomplete: str) -> list[str]: try: config_manager = ConfigManager() tmp_path = os.path.join(config_manager.get_config_path(), "tmp", "node-cache.list") all_opt = [] if "all".startswith(incomplete): all_opt = ["all"] with open(tmp_path, encoding="UTF-8", errors="ignore") as cache_file: return [node_id for node_id in cache_file.readlines() if node_id.startswith(incomplete)] + all_opt except Exception: return [] def validate_mode(mode): valid_modes = ["remote", "local", "cache"] if mode and mode.lower() not in valid_modes: typer.echo( f"Invalid mode: {mode}. Allowed modes are 'remote', 'local', 'cache'.", err=True, ) raise typer.Exit(code=1) @app.command(help="Show node list") @tracking.track_command("node") def show( arg: ShowTarget = typer.Argument( help="Target to display", ), channel: Annotated[ str | None, typer.Option( show_default=False, help="Specify the operation mode", autocompletion=channel_completer, ), ] = None, mode: str = typer.Option( None, help="[remote|local|cache]", autocompletion=mode_completer, ), ): validate_mode(mode) execute_cm_cli(["show", arg.value], channel=channel, mode=mode) @app.command("simple-show", help="Show node list (simple mode)") @tracking.track_command("node") def simple_show( arg: ShowTarget = typer.Argument( help="Target to display", ), channel: Annotated[ str | None, typer.Option( show_default=False, help="Specify the operation mode", autocompletion=channel_completer, ), ] = None, mode: str = typer.Option( None, help="[remote|local|cache]", autocompletion=mode_completer, ), ): validate_mode(mode) execute_cm_cli(["simple-show", arg.value], channel=channel, mode=mode) # install, reinstall, uninstall @app.command(help="Install custom nodes") @tracking.track_command("node") def install( nodes: list[str] = typer.Argument(..., help="List of custom nodes to install", autocompletion=node_completer), channel: Annotated[ str | None, typer.Option( show_default=False, help="Specify the operation mode", autocompletion=channel_completer, ), ] = None, fast_deps: Annotated[ bool, typer.Option( "--fast-deps", show_default=False, help="Use new fast dependency installer", ), ] = False, no_deps: Annotated[ bool, typer.Option( "--no-deps", show_default=False, help="Skip dependency installation", ), ] = False, uv_compile: Annotated[ bool | None, typer.Option( "--uv-compile/--no-uv-compile", show_default=False, help="After installing, batch-resolve all dependencies via uv pip compile (requires ComfyUI-Manager v4.1+)", ), ] = None, exit_on_fail: Annotated[ bool, typer.Option( "--exit-on-fail", help="Exit on failure", ), ] = False, mode: str = typer.Option( None, help="[remote|local|cache]", autocompletion=mode_completer, ), ): if "all" in nodes: typer.echo("`install all` is not allowed", err=True) raise typer.Exit(code=1) exclusive_flags = [ name for name, val in [("--fast-deps", fast_deps), ("--no-deps", no_deps), ("--uv-compile", uv_compile)] if val ] if len(exclusive_flags) > 1: typer.echo(f"Cannot use {' and '.join(exclusive_flags)} together", err=True) raise typer.Exit(code=1) effective_uv_compile = _resolve_uv_compile(uv_compile, fast_deps=fast_deps, no_deps=no_deps) validate_mode(mode) if exit_on_fail: cmd = ["install", "--exit-on-fail"] + nodes else: cmd = ["install"] + nodes try: execute_cm_cli( cmd, channel=channel, fast_deps=fast_deps, no_deps=no_deps, uv_compile=effective_uv_compile, mode=mode, raise_on_error=exit_on_fail, ) except subprocess.CalledProcessError as e: if exit_on_fail: raise typer.Exit(code=e.returncode) @app.command(help="Reinstall custom nodes") @tracking.track_command("node") def reinstall( nodes: list[str] = typer.Argument(..., help="List of custom nodes to reinstall", autocompletion=node_completer), channel: Annotated[ str | None, typer.Option( show_default=False, help="Specify the operation mode", autocompletion=channel_completer, ), ] = None, fast_deps: Annotated[ bool, typer.Option( "--fast-deps", show_default=False, help="Use new fast dependency installer", ), ] = False, uv_compile: Annotated[ bool | None, typer.Option( "--uv-compile/--no-uv-compile", show_default=False, help="After reinstalling, batch-resolve all dependencies via uv pip compile (requires ComfyUI-Manager v4.1+)", ), ] = None, mode: str = typer.Option( None, help="[remote|local|cache]", autocompletion=mode_completer, ), ): if "all" in nodes: typer.echo("`reinstall all` is not allowed", err=True) raise typer.Exit(code=1) exclusive_flags = [name for name, val in [("--fast-deps", fast_deps), ("--uv-compile", uv_compile)] if val] if len(exclusive_flags) > 1: typer.echo(f"Cannot use {' and '.join(exclusive_flags)} together", err=True) raise typer.Exit(code=1) effective_uv_compile = _resolve_uv_compile(uv_compile, fast_deps=fast_deps) validate_mode(mode) execute_cm_cli( ["reinstall"] + nodes, channel=channel, fast_deps=fast_deps, uv_compile=effective_uv_compile, mode=mode ) @app.command( "uv-sync", help="Batch-resolve and install all custom node dependencies via uv (requires ComfyUI-Manager v4.1+)", ) @tracking.track_command("node") def uv_sync(): execute_cm_cli(["uv-sync"]) @app.command(help="Uninstall custom nodes") @tracking.track_command("node") def uninstall( nodes: list[str] = typer.Argument(..., help="List of custom nodes to uninstall", autocompletion=node_completer), channel: Annotated[ str | None, typer.Option( show_default=False, help="Specify the operation mode", autocompletion=channel_completer, ), ] = None, mode: str = typer.Option( None, help="[remote|local|cache]", autocompletion=mode_completer, ), ): if "all" in nodes: typer.echo("`uninstall all` is not allowed", err=True) raise typer.Exit(code=1) validate_mode(mode) execute_cm_cli(["uninstall"] + nodes, channel=channel, mode=mode) def update_node_id_cache(): config_manager = ConfigManager() workspace_path = workspace_manager.workspace_path if not find_cm_cli(): raise FileNotFoundError("cm-cli not found") tmp_path = os.path.join(config_manager.get_config_path(), "tmp") if not os.path.exists(tmp_path): os.makedirs(tmp_path) cache_path = os.path.join(tmp_path, "node-cache.list") python = resolve_workspace_python(workspace_path) cmd = [python, "-m", "cm_cli", "export-custom-node-ids", cache_path] new_env = os.environ.copy() new_env["COMFYUI_PATH"] = workspace_path subprocess.run(cmd, env=new_env, check=True) # `update, disable, enable, fix` allows `all` param @app.command(help="Update custom nodes or ComfyUI") @tracking.track_command("node") def update( nodes: list[str] = typer.Argument( ..., help="[all|List of custom nodes to update]", autocompletion=node_or_all_completer, ), channel: Annotated[ str | None, typer.Option( show_default=False, help="Specify the operation mode", autocompletion=channel_completer, ), ] = None, uv_compile: Annotated[ bool | None, typer.Option( "--uv-compile/--no-uv-compile", show_default=False, help="After updating, batch-resolve all dependencies via uv pip compile (requires ComfyUI-Manager v4.1+)", ), ] = None, mode: str = typer.Option( None, help="[remote|local|cache]", autocompletion=mode_completer, ), ): validate_mode(mode) execute_cm_cli(["update"] + nodes, channel=channel, uv_compile=_resolve_uv_compile(uv_compile), mode=mode) update_node_id_cache() @app.command(help="Disable custom nodes") @tracking.track_command("node") def disable( nodes: list[str] = typer.Argument( ..., help="[all|List of custom nodes to disable]", autocompletion=node_or_all_completer, ), channel: Annotated[ str | None, typer.Option( show_default=False, help="Specify the operation mode", autocompletion=channel_completer, ), ] = None, mode: str = typer.Option( None, help="[remote|local|cache]", autocompletion=mode_completer, ), ): validate_mode(mode) execute_cm_cli(["disable"] + nodes, channel=channel, mode=mode) @app.command(help="Enable custom nodes") @tracking.track_command("node") def enable( nodes: list[str] = typer.Argument( ..., help="[all|List of custom nodes to enable]", autocompletion=node_or_all_completer, ), channel: Annotated[ str | None, typer.Option( show_default=False, help="Specify the operation mode", autocompletion=channel_completer, ), ] = None, mode: str = typer.Option( None, help="[remote|local|cache]", autocompletion=mode_completer, ), ): validate_mode(mode) execute_cm_cli(["enable"] + nodes, channel=channel, mode=mode) @app.command(help="Fix dependencies of custom nodes") @tracking.track_command("node") def fix( nodes: list[str] = typer.Argument( ..., help="[all|List of custom nodes to fix]", autocompletion=node_or_all_completer, ), channel: Annotated[ str | None, typer.Option( show_default=False, help="Specify the operation mode", autocompletion=channel_completer, ), ] = None, uv_compile: Annotated[ bool | None, typer.Option( "--uv-compile/--no-uv-compile", show_default=False, help="After fixing, batch-resolve all dependencies via uv pip compile (requires ComfyUI-Manager v4.1+)", ), ] = None, mode: str = typer.Option( None, help="[remote|local|cache]", autocompletion=mode_completer, ), ): validate_mode(mode) execute_cm_cli(["fix"] + nodes, channel=channel, uv_compile=_resolve_uv_compile(uv_compile), mode=mode) @app.command( "install-deps", help="Install dependencies from dependencies file(.json) or workflow(.png/.json)", ) @tracking.track_command("node") def install_deps( deps: Annotated[ str | None, typer.Option(show_default=False, help="Dependency spec file (.json)"), ] = None, workflow: Annotated[ str | None, typer.Option(show_default=False, help="Workflow file (.json/.png)"), ] = None, channel: Annotated[ str | None, typer.Option( show_default=False, help="Specify the operation mode", autocompletion=channel_completer, ), ] = None, uv_compile: Annotated[ bool | None, typer.Option( "--uv-compile/--no-uv-compile", show_default=False, help="After installing, batch-resolve all dependencies via uv pip compile (requires ComfyUI-Manager v4.1+)", ), ] = None, mode: str = typer.Option( None, help="[remote|local|cache]", autocompletion=mode_completer, ), ): validate_mode(mode) if deps is None and workflow is None: print("[bold red]One of --deps or --workflow must be provided as an argument.[/bold red]\n") effective_uv_compile = _resolve_uv_compile(uv_compile) tmp_path = None if workflow is not None: workflow = os.path.abspath(os.path.expanduser(workflow)) tmp_path = os.path.join(workspace_manager.config_manager.get_config_path(), "tmp") if not os.path.exists(tmp_path): os.makedirs(tmp_path) tmp_path = os.path.join(tmp_path, str(uuid.uuid4())) + ".json" execute_cm_cli( ["deps-in-workflow", "--workflow", workflow, "--output", tmp_path], channel, mode=mode, ) deps_file = tmp_path else: deps_file = os.path.abspath(os.path.expanduser(deps)) execute_cm_cli(["install-deps", deps_file], channel=channel, uv_compile=effective_uv_compile, mode=mode) if tmp_path is not None and os.path.exists(tmp_path): os.remove(tmp_path) @app.command("deps-in-workflow", help="Generate dependencies file from workflow (.json/.png)") @tracking.track_command("node") def deps_in_workflow( workflow: Annotated[str, typer.Option(show_default=False, help="Workflow file (.json/.png)")], output: Annotated[str, typer.Option(show_default=False, help="Output file (.json)")], channel: Annotated[ str | None, typer.Option( show_default=False, help="Specify the operation mode", autocompletion=channel_completer, ), ] = None, mode: str = typer.Option( None, help="[remote|local|cache]", autocompletion=mode_completer, ), ): validate_mode(mode) workflow = os.path.abspath(os.path.expanduser(workflow)) output = os.path.abspath(os.path.expanduser(output)) execute_cm_cli( ["deps-in-workflow", "--workflow", workflow, "--output", output], channel, mode=mode, ) def validate_node_for_publishing(): """ Validates node configuration and runs security checks. Returns the validated config if successful, raises typer.Exit if validation fails. """ # Perform some validation logic here typer.echo("Validating node configuration...") config = extract_node_configuration() if config is None: raise typer.Exit(code=1) if not config.project.version: # Escape `[` chars so rich doesn't parse `[tool.comfy.version]` and # `["version"]` as markup tags; `]` doesn't need escaping. print( "[red]Error: project version is empty. Set `project.version` in pyproject.toml, " r'or configure `\[tool.comfy.version].path` if using `dynamic = \["version"]`.[/red]' ) raise typer.Exit(code=1) # Run security checks first typer.echo("Running security checks...") try: # Run ruff check with security rules and --exit-zero to only warn cmd = [sys.executable, "-m", "ruff", "check", ".", "-q", "--select", "S102,S307,E702", "--exit-zero"] result = subprocess.run(cmd, capture_output=True, text=True) if result.stdout: print("[yellow]Security warnings found:[/yellow]") print(result.stdout) print( "[bold yellow]We will soon disable exec and eval, and multiple statements in a single line, so this will be an error soon.[/bold yellow]" ) else: print("[green]✓ All validation checks passed successfully[/green]") except FileNotFoundError: print("[red]Ruff is not installed. Please install it with 'pip install ruff'[/red]") raise typer.Exit(code=1) except Exception as e: print(f"[red]Error running security check: {e}[/red]") raise typer.Exit(code=1) return config @app.command("validate", help="Run validation checks for publishing") @tracking.track_command("publish") def validate(): """ Run validation checks that would be performed during publishing. """ validate_node_for_publishing() # print("[green]✓ All validation checks passed successfully[/green]") @app.command("publish", help="Publish node to registry") @tracking.track_command("publish") def publish( token: str | None = typer.Option(None, "--token", help="Personal Access Token for publishing", hide_input=True), ): """ Publish a node with optional validation. """ config = validate_node_for_publishing() # Prompt for API Key if not token: token = typer.prompt( "Please enter your API Key (can be created on https://registry.comfy.org)", hide_input=True, ) # Call API to fetch node version with the token in the body typer.echo("Publishing node version...") try: response = registry_api.publish_node_version(config, token) # Zip up all files in the current directory, respecting .gitignore files. signed_url = response.signedUrl zip_filename = NODE_ZIP_FILENAME typer.echo("Creating zip file...") includes = config.tool_comfy.includes if config and config.tool_comfy else [] if includes: typer.echo(f"Including additional directories: {', '.join(includes)}") zip_files(zip_filename, includes=includes) # Upload the zip file to the signed URL typer.echo("Uploading zip file...") upload_file_to_signed_url(signed_url, zip_filename) except Exception as e: ui.display_error_message({str(e)}) raise typer.Exit(code=1) @app.command("init", help="Init scaffolding for custom node") @tracking.track_command("node") def scaffold(): if os.path.exists("pyproject.toml"): typer.echo("Warning: 'pyproject.toml' already exists. Will not overwrite.") raise typer.Exit(code=1) typer.echo("Initializing metadata...") initialize_project_config() typer.echo("pyproject.toml created successfully. Defaults were filled in. Please check before publishing.") @app.command("registry-list", help="List all nodes in the registry", hidden=True) @tracking.track_command("node") def display_all_nodes(): """ Display all nodes in the registry. """ nodes = None try: nodes = registry_api.list_all_nodes() except Exception as e: logging.error(f"Failed to fetch nodes from the registry: {str(e)}") ui.display_error_message("Failed to fetch nodes from the registry.") # Map Node data class instances to tuples for display node_data = [ ( node.id, node.name, node.description, node.author or "N/A", node.license or "N/A", ", ".join(node.tags), node.latest_version.version if node.latest_version else "N/A", ) for node in nodes ] ui.display_table( node_data, [ "ID", "Name", "Description", "Author", "License", "Tags", "Latest Version", ], title="List of All Nodes", ) @app.command( "registry-install", help="Install a node from the registry", hidden=True, ) @tracking.track_command("node") def registry_install( node_id: str, version: str | None = None, force_download: Annotated[ bool, typer.Option( "--force-download", help="Force download the node even if it is already installed", ), ] = False, ): """ Install a node from the registry. Args: node_id: The ID of the node to install. version: The version of the node to install. If not provided, the latest version will be installed. """ # If the node ID is not provided, prompt the user to enter it if not node_id: node_id = typer.prompt("Enter the ID of the node you want to install") node_version = None try: # Call the API to install the node node_version = registry_api.install_node(node_id, version) if not node_version.download_url: logging.error("Download URL not provided from the registry.") ui.display_error_message(f"Failed to download the custom node {node_id}.") return except Exception as e: logging.error(f"Encountered an error while installing the node. error: {str(e)}") ui.display_error_message(f"Failed to download the custom node {node_id}.") return # Download the node archive custom_nodes_path = pathlib.Path(workspace_manager.workspace_path) / "custom_nodes" node_specific_path = custom_nodes_path / node_id # Subdirectory for the node if node_specific_path.exists(): print( f"[bold red] The node {node_id} already exists in the workspace. This might delete any model files in the node.[/bold red]" ) confirm = ui.prompt_confirm_action( "Do you want to overwrite it?", force_download, ) if not confirm: return node_specific_path.mkdir(parents=True, exist_ok=True) # Create the directory if it doesn't exist local_filename = node_specific_path / f"{node_id}-{node_version.version}.zip" logging.debug(f"Start downloading the node {node_id} version {node_version.version} to {local_filename}") try: download_file(node_version.download_url, local_filename) except DownloadException as e: logging.error(f"Failed to download node {node_id} version {node_version.version}: {e}") ui.display_error_message(f"Failed to download the custom node {node_id}: {e}") raise typer.Exit(code=1) from None # Extract the downloaded archive to the custom_node directory on the workspace. logging.debug(f"Start extracting the node {node_id} version {node_version.version} to {custom_nodes_path}") extract_package_as_zip(local_filename, node_specific_path) # TODO: temoporary solution to run requirement.txt and install script execute_install_script(node_specific_path) # Delete the downloaded archive logging.debug(f"Deleting the downloaded archive {local_filename}") os.remove(local_filename) logging.info(f"Node {node_id} version {node_version.version} has been successfully installed.") @app.command( "pack", help="Pack the current node into a zip file using git-tracked files and honoring .comfyignore patterns.", ) @tracking.track_command("pack") def pack(): typer.echo("Validating node configuration...") config = extract_node_configuration() if not config: raise typer.Exit(code=1) zip_filename = NODE_ZIP_FILENAME includes = config.tool_comfy.includes if config and config.tool_comfy else [] if includes: typer.echo(f"Including additional directories: {', '.join(includes)}") zip_files(zip_filename, includes=includes) typer.echo(f"Created zip file: {NODE_ZIP_FILENAME}") logging.info("Node has been packed successfully.") @app.command("scaffold", help="Create a new ComfyUI custom node project using cookiecutter") @tracking.track_command("node") def scaffold_cookiecutter(): """Create a new ComfyUI custom node project using cookiecutter.""" import cookiecutter.main try: cookiecutter.main.cookiecutter( "gh:comfy-org/cookiecutter-comfy-extension", overwrite_if_exists=True, ) console.print("[bold green]✓ Custom node project created successfully![/bold green]") except Exception as e: console.print(f"[bold red]Error creating project: {str(e)}[/bold red]") raise typer.Exit(code=1) ================================================ FILE: comfy_cli/command/generate/__init__.py ================================================ from comfy_cli.command.generate.app import register_with __all__ = ["register_with"] ================================================ FILE: comfy_cli/command/generate/adapters.py ================================================ """Per-endpoint adapters for partners whose request/response shapes don't fit the generic schema-driven flag→JSON mold. Two endpoints today: - **Gemini Flash Image (nano-banana)** — Vertex AI's ``contents``/``parts`` body, inline base64 image input, and inline base64 image output. The model variant lives in the URL path, not the body. - **Seedance** (ByteDance) — assembles a ``content`` array of typed parts (``text`` + optional ``image_url``) and inlines its own knobs (resolution, duration, …) into the prompt string. An adapter contributes three optional pieces: - ``flags`` — replaces the schema-derived flag list for the model - ``build_body`` — produces the JSON body from parsed flag values - ``decode_sync`` — handles a sync response that ships inline blobs (Gemini) - ``path_param`` — name of a flag whose value gets substituted into the URL path's ``{placeholder}`` (e.g. ``model`` for Gemini's templated path) """ from __future__ import annotations import base64 import mimetypes from collections.abc import Callable from dataclasses import dataclass from pathlib import Path from typing import Any import httpx from comfy_cli.command.generate.client import ApiError from comfy_cli.command.generate.schema import FlagDef @dataclass(frozen=True) class Adapter: flags: list[FlagDef] build_body: Callable[[dict, str], dict] decode_sync: Callable[[dict, str, str], list[Path]] | None = None path_param: str | None = None # ── Gemini / nano-banana ────────────────────────────────────────────────── GEMINI_IMAGE_MODELS = ( "gemini-2.5-flash-image", "gemini-2.5-flash-image-preview", "gemini-3-pro-image-preview", ) def _inline_image(value: str) -> tuple[str, str]: """Return ``(mime_type, base64_str)`` for a local path, http(s) URL, or ``data:`` URI. Gemini accepts inline-only — there's no signed-URL path here, so we pull bytes locally rather than going through ``upload.py``.""" if value.startswith("data:"): head, _, b64 = value.partition(",") mime = head.split(";", 1)[0].removeprefix("data:") or "image/png" return mime, b64 if value.startswith(("http://", "https://")): with httpx.Client(timeout=60.0, follow_redirects=True) as c: r = c.get(value) r.raise_for_status() mime = (r.headers.get("content-type") or "image/png").split(";", 1)[0].strip() return mime, base64.b64encode(r.content).decode("ascii") path = Path(value).expanduser() if not path.is_file(): raise ApiError(0, "", f"Image not found: {path}") mime, _ = mimetypes.guess_type(path.name) return mime or "image/png", base64.b64encode(path.read_bytes()).decode("ascii") def _gemini_build_body(values: dict, api_key: str) -> dict[str, Any]: parts: list[dict[str, Any]] = [{"text": str(values["prompt"])}] images = values.get("image") or [] if isinstance(images, str): images = [images] for img in images: mime, b64 = _inline_image(str(img)) parts.append({"inlineData": {"mimeType": mime, "data": b64}}) return { "contents": [{"role": "user", "parts": parts}], "generationConfig": {"responseModalities": ["IMAGE"]}, } def _gemini_decode_sync(body: dict, download: str, request_id: str) -> list[Path]: """Walk candidates[*].content.parts[*].inlineData; save each blob.""" from comfy_cli.command.generate import output blobs: list[tuple[str, bytes]] = [] for cand in body.get("candidates") or []: content = cand.get("content") or {} for part in content.get("parts") or []: inline = part.get("inlineData") or part.get("inline_data") if not inline: continue data_b64 = inline.get("data") or "" mime = inline.get("mimeType") or inline.get("mime_type") or "image/png" try: raw = base64.b64decode(data_b64, validate=False) except (ValueError, TypeError): continue blobs.append((mime, raw)) if not blobs: return [] return output.save_inline_blobs(blobs, download, request_id) _gemini_adapter = Adapter( flags=[ FlagDef( name="prompt", kind="string", required=True, description="Text instruction. For edits, describe the change.", ), FlagDef( name="image", kind="array", item_kind="string", required=False, description="Optional reference image(s): local path, http(s) URL, or data URI.", ), FlagDef( name="model", kind="enum", required=False, default="gemini-2.5-flash-image", description="Gemini image-model variant.", enum=list(GEMINI_IMAGE_MODELS), ), ], build_body=_gemini_build_body, decode_sync=_gemini_decode_sync, path_param="model", ) # ── Seedance ────────────────────────────────────────────────────────────── SEEDANCE_MODELS = ( "seedance-1-0-pro-250528", "seedance-1-0-pro-fast-251015", "seedance-1-5-pro-251215", "seedance-1-0-lite-t2v-250428", "seedance-1-0-lite-i2v-250428", ) _SEEDANCE_INLINE_KEYS = ("resolution", "ratio", "duration", "fps", "seed", "camerafixed", "watermark") def _seedance_text(values: dict) -> str: """Compose the ``text`` field, appending Seedance's inline ``--rs/--rt/…`` style overrides for any flags the user set.""" prompt = str(values["prompt"]) extras: list[str] = [] for key in _SEEDANCE_INLINE_KEYS: v = values.get(key) if v is None or v == "": continue if isinstance(v, bool): v = "true" if v else "false" extras.append(f"--{key} {v}") return f"{prompt} {' '.join(extras)}".strip() def _seedance_image_url(value: str, api_key: str) -> str: """Local paths get uploaded; data: and http(s) pass through verbatim.""" if value.startswith(("http://", "https://", "data:")): return value from comfy_cli.command.generate import upload return upload.upload_path(Path(value).expanduser(), api_key).url def _seedance_build_body(values: dict, api_key: str) -> dict[str, Any]: content: list[dict[str, Any]] = [{"type": "text", "text": _seedance_text(values)}] image = values.get("image") if image: content.append({"type": "image_url", "image_url": {"url": _seedance_image_url(str(image), api_key)}}) body: dict[str, Any] = { "model": values.get("model") or SEEDANCE_MODELS[0], "content": content, } if "generate_audio" in values: body["generate_audio"] = bool(values["generate_audio"]) if "return_last_frame" in values: body["return_last_frame"] = bool(values["return_last_frame"]) return body _seedance_adapter = Adapter( flags=[ FlagDef(name="prompt", kind="string", required=True, description="Text prompt for the video."), FlagDef( name="image", kind="string", required=False, description="Optional first-frame image (URL, local path, or data URI). " "Local paths are auto-uploaded via /customers/storage.", ), FlagDef( name="model", kind="enum", required=False, default="seedance-1-0-pro-250528", description="Seedance model variant.", enum=list(SEEDANCE_MODELS), ), FlagDef(name="resolution", kind="enum", required=False, enum=["480p", "720p", "1080p"]), FlagDef( name="ratio", kind="enum", required=False, enum=["21:9", "16:9", "4:3", "1:1", "3:4", "9:16", "9:21", "adaptive"], ), FlagDef(name="duration", kind="integer", required=False, description="Length in seconds (3–12)."), FlagDef(name="fps", kind="integer", required=False, description="Frames per second (default 24)."), FlagDef(name="seed", kind="integer", required=False, description="RNG seed (-1 to 2^32-1)."), FlagDef(name="camerafixed", kind="boolean", required=False, description="Lock camera position."), FlagDef(name="watermark", kind="boolean", required=False, description="Include a watermark."), FlagDef( name="generate_audio", kind="boolean", required=False, description="Synthesize matching audio (Seedance 1.5 pro only).", ), FlagDef( name="return_last_frame", kind="boolean", required=False, description="Return the last-frame image alongside the video.", ), ], build_body=_seedance_build_body, decode_sync=None, path_param=None, ) _ADAPTERS: dict[str, Adapter] = { "vertexai/gemini/{model}": _gemini_adapter, "byteplus/api/v3/contents/generations/tasks": _seedance_adapter, } def get(endpoint_id: str) -> Adapter | None: return _ADAPTERS.get(endpoint_id) def resolve_path(template: str, values: dict, adapter: Adapter) -> str: """Substitute ``adapter.path_param`` into the URL template, falling back to the flag's ``default`` when the user didn't pass it.""" if not adapter.path_param: return template val = values.get(adapter.path_param) if not val: for f in adapter.flags: if f.name == adapter.path_param: val = f.default break if not val: raise ApiError(0, "", f"Missing --{adapter.path_param}: required to fill in the URL path.") return template.replace("{" + adapter.path_param + "}", str(val)) ================================================ FILE: comfy_cli/command/generate/app.py ================================================ """``comfy generate`` — call ComfyUI partner nodes from the CLI. UX shape, modeled on fal-ai's genmedia but creative-user-first: comfy generate [-- value]... [--download P] [--async] comfy generate list [--partner P] [--style S] comfy generate schema comfy generate refresh comfy generate resume [--download P] The first positional is either a reserved action (``list``/``schema``/ ``refresh``/``resume``) or a model alias (``flux-pro``, ``ideogram-edit``, …). Anything not in the reserved set falls through to the generate path. """ from __future__ import annotations import uuid from pathlib import Path from typing import Annotated import httpx import typer from rich import print as rprint from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn from comfy_cli import tracking, ui from comfy_cli.command.generate import adapters, client, output, poll, schema, spec, upload _HELP = "Generate images via ComfyUI partner nodes (Flux, Ideogram, DALL·E, Recraft, Stability, …)." _CONTEXT_SETTINGS = { "allow_extra_args": True, "ignore_unknown_options": True, "help_option_names": [], } def register_with(parent: typer.Typer) -> None: """Wire the ``generate`` command into a Typer app. We register directly (rather than as a sub-app via ``add_typer``) so the first positional after ``generate`` can be a model alias — Click groups would treat that as a subcommand name and error.""" @parent.command(name="generate", help=_HELP, context_settings=_CONTEXT_SETTINGS) @tracking.track_command() def _generate_entry( ctx: typer.Context, target: Annotated[ str | None, typer.Argument( help="A model alias (e.g. flux-pro, ideogram-edit, dalle) " "or one of: list, schema, refresh, upload, resume.", ), ] = None, ) -> None: if target is None or target in {"-h", "--help"}: _print_top_help() raise typer.Exit(code=0) if target == "list": return _list_models(list(ctx.args)) if target == "schema": return _schema(list(ctx.args)) if target == "refresh": return _refresh() if target == "upload": return _upload(list(ctx.args)) if target == "resume": return _resume(list(ctx.args)) _generate(target, list(ctx.args)) def _separate_meta_flags(extra_args: list[str]) -> tuple[list[str], dict[str, str | bool]]: """Pull run-level flags out of the user's argv tail.""" meta_names = {"download", "async", "json", "timeout", "api-key"} meta: dict[str, str | bool] = {} remaining: list[str] = [] i = 0 while i < len(extra_args): tok = extra_args[i] if tok.startswith("--"): body = tok[2:] raw: str | None = None if "=" in body: body, raw = body.split("=", 1) if body in meta_names: if body in {"async", "json"}: meta[body] = True if raw is None else raw.lower() not in {"false", "0", "no"} i += 1 continue if raw is None: if i + 1 >= len(extra_args): raise schema.SchemaError(f"--{body}: missing value") raw = extra_args[i + 1] i += 2 else: i += 1 meta[body] = raw continue remaining.append(tok) i += 1 return remaining, meta def _show_schema_help(endpoint: spec.Endpoint) -> None: """Print the schema-driven help block for a model.""" flags = schema.flags_for(endpoint) alias = spec.preferred_alias(endpoint.id) name = alias or endpoint.id if alias: rprint(f"[bold]Model:[/bold] {alias} [dim]({endpoint.id})[/dim]") else: rprint(f"[bold]Model:[/bold] {endpoint.id}") body = schema.help_text(endpoint, flags) rprint(body) rprint("") rprint("[dim]Example:[/dim]") rprint(f" {schema.example_invocation(endpoint, flags, display_name=name)}") def _spinner() -> Progress: return Progress( SpinnerColumn(), TextColumn("[bold blue]{task.description}"), TimeElapsedColumn(), transient=True, ) def _emit_result(result: poll.PollResult, *, request_id: str, download: str | None, as_json: bool) -> None: if as_json: output.print_json(result.raw) return if result.status != "succeeded": rprint(f"[bold red]Job {result.status}: {result.error or 'unknown error'}[/bold red]") output.print_json(result.raw) raise typer.Exit(code=1) if download and result.image_urls: saved = output.save_urls(result.image_urls, download, request_id) output.print_urls(result.image_urls, request_id=request_id) output.print_saved(saved) else: output.print_urls(result.image_urls, request_id=request_id) if download and not result.image_urls: rprint("[yellow]--download requested but no image URLs found in response.[/yellow]") def _generate(model: str, extra_args: list[str]) -> None: try: ep = spec.get_endpoint(model) except spec.SpecError as e: rprint(f"[bold red]{e}[/bold red]") raise typer.Exit(code=1) if any(a in {"--help", "-h"} for a in extra_args): _show_schema_help(ep) raise typer.Exit(code=0) try: remaining, meta = _separate_meta_flags(extra_args) except schema.SchemaError as e: rprint(f"[bold red]{e}[/bold red]") raise typer.Exit(code=1) flags = schema.flags_for(ep) try: values = schema.parse_args(flags, remaining) except schema.SchemaError as e: rprint(f"[bold red]{e}[/bold red]") name = spec.preferred_alias(ep.id) or ep.id rprint(f"[dim]Run `comfy generate schema {name}` for the full parameter list.[/dim]") raise typer.Exit(code=1) try: api_key = client.resolve_api_key(meta.get("api-key") if isinstance(meta.get("api-key"), str) else None) except client.ApiError as e: rprint(f"[bold red]{e}[/bold red]") raise typer.Exit(code=1) timeout_raw = meta.get("timeout", "300") try: timeout = float(timeout_raw) if isinstance(timeout_raw, str) else 300.0 except ValueError: rprint(f"[bold red]--timeout: expected number, got {timeout_raw!r}[/bold red]") raise typer.Exit(code=1) do_async = bool(meta.get("async", False)) download = meta.get("download") if isinstance(meta.get("download"), str) else None as_json = bool(meta.get("json", False)) try: _apply_upload_transforms(values, flags, ep, api_key) except (client.ApiError, httpx.HTTPError) as e: rprint(f"[bold red]Upload failed: {e}[/bold red]") raise typer.Exit(code=1) request_id = str(uuid.uuid4())[:8] try: resp = client.send_request(ep, values, flags, api_key, timeout=timeout) except httpx.HTTPError as e: rprint(f"[bold red]Network error contacting {spec.base_url()}: {e}[/bold red]") raise typer.Exit(code=1) from e try: client.raise_for_status(resp) except client.ApiError as e: rprint(f"[bold red]API error {e.status}[/bold red]\n{e.body}") raise typer.Exit(code=1) from e if resp.headers.get("content-type", "").startswith("image/"): if download: saved = output.save_binary_response(resp, download, request_id) output.print_saved([saved]) else: rprint("[yellow]Binary image response; nothing saved. Pass --download to write it to disk.[/yellow]") return try: body = resp.json() except ValueError: rprint("[bold red]Unexpected non-JSON response.[/bold red]") rprint(resp.text[:500]) raise typer.Exit(code=1) if ep.polling: job_id = poll.extract_job_id(ep.polling, body) or request_id name = spec.preferred_alias(ep.id) or ep.id if do_async: if as_json: output.print_json(body) else: rprint(f"[bold green]Submitted:[/bold green] {name}") rprint(f" job id: {job_id}") rprint(f" resume: comfy generate resume {name} {job_id}") return poller = poll.get_poller(ep.polling) with _spinner() as prog: task = prog.add_task(f"Generating with {name} (job {job_id})", total=None) def _on_progress(p: float) -> None: prog.update(task, description=f"Generating ({p * 100:.0f}%)") result = poller( body, api_key=api_key, timeout=timeout, on_progress=_on_progress, create_path=ep.path, ) _emit_result(result, request_id=job_id, download=download, as_json=as_json) return adapter = adapters.get(ep.id) if adapter is not None and adapter.decode_sync is not None: body = resp.json() if as_json: output.print_json(body) return if not download: rprint("[yellow]Image data returned inline. Pass --download to save.[/yellow]") return saved = adapter.decode_sync(body, download, request_id) if saved: output.print_saved(saved) else: rprint("[yellow]No image data found in response.[/yellow]") output.print_json(body) return result = poll.sync_result_from_response(resp) _emit_result(result, request_id=request_id, download=download, as_json=as_json) def _arg_value(args: list[str], *names: str) -> str | None: for i, tok in enumerate(args): for n in names: if tok == n and i + 1 < len(args): return args[i + 1] if tok.startswith(n + "="): return tok.split("=", 1)[1] return None def _list_models(extra_args: list[str]) -> None: """`comfy generate list` — show available models with their short aliases.""" partner = _arg_value(extra_args, "--partner", "-p") category = _arg_value(extra_args, "--category", "--style", "-c") query = _arg_value(extra_args, "--query", "-q") eps = spec.list_endpoints(partner=partner, category=category, query=query) if not eps: rprint("[yellow]No models match those filters.[/yellow]") raise typer.Exit(code=0) rows = [ ( spec.preferred_alias(e.id) or e.id, e.partner, e.category, "async" if e.polling else "sync", (e.summary[:60] + "…") if len(e.summary) > 61 else e.summary, ) for e in eps ] ui.display_table(rows, ["Model", "Partner", "Style", "Mode", "Summary"], title="Comfy Generate — Models") rprint("\n[dim]Run `comfy generate schema ` to see parameters for a model.[/dim]") def _schema(extra_args: list[str]) -> None: """`comfy generate schema ` — show params for a model (fal-style).""" if not extra_args or extra_args[0].startswith("-"): rprint("[bold red]Usage: comfy generate schema [/bold red]") raise typer.Exit(code=1) try: ep = spec.get_endpoint(extra_args[0]) except spec.SpecError as e: rprint(f"[bold red]{e}[/bold red]") raise typer.Exit(code=1) _show_schema_help(ep) def _refresh() -> None: url = spec.base_url() + "/openapi.yml" try: with httpx.Client(timeout=30.0, follow_redirects=True) as cli: r = cli.get(url, headers={"Comfy-Env": "comfy-cli", "User-Agent": "comfy-cli/api"}) r.raise_for_status() except httpx.HTTPError as e: rprint(f"[bold red]Failed to fetch {url}: {e}[/bold red]") raise typer.Exit(code=1) path = spec.write_cache(r.text) rprint(f"[bold green]Refreshed model catalog at {path}[/bold green]") def _upload(extra_args: list[str]) -> None: """`comfy generate upload [--json] [--api-key K]`.""" try: remaining, meta = _separate_meta_flags(extra_args) except schema.SchemaError as e: rprint(f"[bold red]{e}[/bold red]") raise typer.Exit(code=1) # `remaining` already excludes recognized --meta flags AND their values, so # `comfy generate upload --api-key KEY ./img.png` correctly resolves to "./img.png". if not remaining: rprint("[bold red]Usage: comfy generate upload [--json][/bold red]") raise typer.Exit(code=1) target = remaining[0] try: api_key = client.resolve_api_key(meta.get("api-key") if isinstance(meta.get("api-key"), str) else None) except client.ApiError as e: rprint(f"[bold red]{e}[/bold red]") raise typer.Exit(code=1) as_json = bool(meta.get("json", False)) try: result = upload.upload_target(target, api_key) except (client.ApiError, httpx.HTTPError) as e: rprint(f"[bold red]Upload failed: {e}[/bold red]") raise typer.Exit(code=1) if as_json: output.print_json( { "url": result.url, "expires_at": result.expires_at, "existing_file": result.existing_file, "hint": "Pass this URL as the model's image/input_image field.", } ) return rprint(f"[bold green]Uploaded:[/bold green] {result.url}") if result.expires_at: rprint(f" expires: {result.expires_at}") if result.existing_file: rprint(" [dim](server already had a hash-match; no bytes transferred)[/dim]") def _apply_upload_transforms(values: dict, flags: list[schema.FlagDef], endpoint: spec.Endpoint, api_key: str) -> None: """When the user supplies a local file path for a field that expects a base64 blob or a URL, transform it transparently. This only applies to JSON endpoints — multipart endpoints already stream file paths natively via httpx and don't need pre-uploading. Endpoints with a custom adapter handle their own asset shaping inside ``build_body``. """ if adapters.get(endpoint.id) is not None: return if endpoint.request_content_type != "application/json": return flag_by_name = {f.name: f for f in flags} for name, value in list(values.items()): flag = flag_by_name.get(name) if flag is None or flag.upload_mode is None or not isinstance(value, str): continue if value.startswith(("http://", "https://", "data:")): continue path = Path(value).expanduser() if not path.is_file(): continue if flag.upload_mode == "base64": import base64 as _base64 try: data = path.read_bytes() except OSError as e: raise client.ApiError(0, "", f"Unable to read file for --{name}: {path} ({e})") from e values[name] = _base64.b64encode(data).decode("ascii") rprint(f"[dim]base64-encoded {path.name} for --{name}[/dim]") elif flag.upload_mode == "url": rprint(f"[dim]uploading {path.name} for --{name}…[/dim]") result = upload.upload_path(path, api_key) values[name] = result.url def _resume(extra_args: list[str]) -> None: if len(extra_args) < 2: rprint("[bold red]Usage: comfy generate resume [--download PATH] [--json][/bold red]") raise typer.Exit(code=1) model, job_id = extra_args[0], extra_args[1] tail = extra_args[2:] try: ep = spec.get_endpoint(model) except spec.SpecError as e: rprint(f"[bold red]{e}[/bold red]") raise typer.Exit(code=1) if not ep.polling: rprint(f"[bold red]{model} is a sync model; nothing to resume.[/bold red]") raise typer.Exit(code=1) try: _, meta = _separate_meta_flags(tail) except schema.SchemaError as e: rprint(f"[bold red]{e}[/bold red]") raise typer.Exit(code=1) try: api_key = client.resolve_api_key(meta.get("api-key") if isinstance(meta.get("api-key"), str) else None) except client.ApiError as e: rprint(f"[bold red]{e}[/bold red]") raise typer.Exit(code=1) timeout = float(meta.get("timeout") or 300.0) if isinstance(meta.get("timeout"), str) else 300.0 download = meta.get("download") if isinstance(meta.get("download"), str) else None as_json = bool(meta.get("json", False)) try: initial = poll.build_synthetic_initial(ep.polling, job_id, base_url=spec.base_url()) except client.ApiError as e: rprint(f"[bold red]{e}[/bold red]") raise typer.Exit(code=1) poller = poll.get_poller(ep.polling) with _spinner() as prog: task = prog.add_task(f"Resuming job {job_id}", total=None) def _on_progress(p: float) -> None: prog.update(task, description=f"Job {job_id} ({p * 100:.0f}%)") result = poller( initial, api_key=api_key, timeout=timeout, on_progress=_on_progress, create_path=ep.path, ) _emit_result(result, request_id=job_id, download=download, as_json=as_json) def _print_top_help() -> None: """Custom help that emphasizes the model-first UX over Typer's auto-help.""" rprint("[bold]comfy generate[/bold] — call ComfyUI partner nodes") rprint("") rprint("[bold]Usage:[/bold]") rprint(" comfy generate [-- value]... [--download PATH] [--async] [--api-key KEY]") rprint("") rprint("[bold]Examples:[/bold]") rprint(' comfy generate flux-pro --prompt "a cat on the moon" --width 1024 --height 1024 --download cat.png') rprint( ' comfy generate ideogram-edit --image cat.png --mask m.png --prompt "add sunglasses" --rendering_speed TURBO' ) rprint(' comfy generate dalle --prompt "a watercolor whale" --download whale.png') rprint("") rprint("[bold]Actions:[/bold]") rprint(" comfy generate list Browse available models") rprint(" comfy generate schema Show parameters for a model") rprint(" comfy generate refresh Refresh the model catalog") rprint(" comfy generate upload Host a local file or remote URL and print its signed URL") rprint(" comfy generate resume Resume an async job") rprint("") rprint("[dim]Auth: set COMFY_API_KEY or pass --api-key. Get one at https://platform.comfy.org.[/dim]") ================================================ FILE: comfy_cli/command/generate/client.py ================================================ """HTTP client for the Comfy cloud API. A thin wrapper around httpx that: - attaches ``Authorization: Bearer $COMFY_API_KEY`` to every request, - targets ``$COMFY_API_BASE_URL`` (defaulting to ``https://api.comfy.org``), - splits a request payload into JSON or multipart based on the endpoint's declared content-type, streaming any ``format: binary`` fields as files. """ from __future__ import annotations import os from pathlib import Path from typing import Any import httpx from comfy_cli.command.generate import spec from comfy_cli.command.generate.schema import FlagDef class ApiError(RuntimeError): def __init__(self, status: int, body: str, message: str | None = None) -> None: super().__init__(message or f"HTTP {status}: {body}") self.status = status self.body = body def resolve_api_key(explicit: str | None = None) -> str: """Order: explicit flag → COMFY_API_KEY env var. Raise if neither set.""" key = explicit.strip() if isinstance(explicit, str) and explicit.strip() else os.environ.get("COMFY_API_KEY", "") key = key.strip() if not key: raise ApiError( 401, "", "No API key. Pass --api-key or set COMFY_API_KEY in your environment. " "Generate one at https://platform.comfy.org/api-keys.", ) return key def _split_payload( values: dict[str, Any], flags: list[FlagDef], content_type: str ) -> tuple[dict[str, Any] | None, list[tuple[str, Any]] | None, dict[str, Any] | None]: """Return (json_body, multipart_files, multipart_data). For JSON endpoints: json_body is the dict, others are None. For multipart: files is a list of (field_name, (filename, fileobj, mime)) tuples and data is the non-file form fields (stringified or JSON-encoded as needed). """ flag_by_name = {f.name: f for f in flags} if content_type != "multipart/form-data": return values, None, None files: list[tuple[str, Any]] = [] data: dict[str, Any] = {} for name, value in values.items(): flag = flag_by_name.get(name) if flag and flag.kind == "binary": path = Path(value) if not isinstance(value, Path) else value if not path.is_file(): raise ApiError(0, "", f"--{name}: file not found: {path}") files.append((name, (path.name, path.open("rb"), "application/octet-stream"))) elif flag and flag.kind == "array" and flag.item_kind == "binary": for p in value: p = Path(p) if not isinstance(p, Path) else p if not p.is_file(): raise ApiError(0, "", f"--{name}: file not found: {p}") files.append((name, (p.name, p.open("rb"), "application/octet-stream"))) elif flag and flag.kind in ("object", "array"): # Multipart form fields are scalar — JSON-encode complex values. import json as _json data[name] = _json.dumps(value) elif flag and flag.kind == "boolean": data[name] = "true" if value else "false" else: data[name] = str(value) return None, files, data def _auth_headers(api_key: str, extra: dict[str, str] | None = None) -> dict[str, str]: # The server accepts two key types on different headers: # - "comfyui-..." API keys → X-API-Key (validated by sha256 lookup) # - Firebase ID tokens → Authorization: Bearer (validated as a JWT) # See comfy-api server/middleware/authentication/comfy_firebase_auth.go. headers = {"User-Agent": "comfy-cli/api", "Comfy-Env": "comfy-cli"} if api_key.startswith("comfyui-"): headers["X-API-Key"] = api_key else: headers["Authorization"] = f"Bearer {api_key}" if extra: headers.update(extra) return headers def send_request( endpoint: spec.Endpoint, values: dict[str, Any], flags: list[FlagDef], api_key: str, timeout: float = 120.0, ) -> httpx.Response: """Send the initial request for `endpoint` with the given typed values.""" from comfy_cli.command.generate import adapters as _adapters adapter = _adapters.get(endpoint.id) url_path = _adapters.resolve_path(endpoint.path, values, adapter) if adapter else endpoint.path url = spec.base_url() + url_path if adapter is not None: json_body, files, data = adapter.build_body(values, api_key), None, None else: json_body, files, data = _split_payload(values, flags, endpoint.request_content_type) headers = _auth_headers(api_key) try: if endpoint.method.lower() == "get": return httpx.get(url, params=values, headers=headers, timeout=timeout) if endpoint.request_content_type == "application/json": return httpx.post(url, json=json_body, headers=headers, timeout=timeout) return httpx.post(url, files=files, data=data, headers=headers, timeout=timeout) finally: # Ensure file handles from multipart are closed even on httpx errors. if files: for _name, payload in files: fileobj = payload[1] try: fileobj.close() except Exception: # noqa: BLE001 pass def get(url: str, api_key: str, timeout: float = 60.0) -> httpx.Response: """GET helper for polling sibling endpoints and downloading result URLs.""" if url.startswith("/"): url = spec.base_url() + url return httpx.get(url, headers=_auth_headers(api_key), timeout=timeout) def download_bytes(url: str, timeout: float = 120.0) -> bytes: """Fetch result media. These URLs are usually pre-signed and not Comfy-hosted, so we don't send the Comfy bearer token.""" with httpx.Client(timeout=timeout, follow_redirects=True) as client: r = client.get(url) r.raise_for_status() return r.content def raise_for_status(resp: httpx.Response) -> None: if resp.status_code < 400: return try: body = resp.json() import json as _json body_str = _json.dumps(body, indent=2) except Exception: # noqa: BLE001 body_str = resp.text raise ApiError(resp.status_code, body_str) ================================================ FILE: comfy_cli/command/generate/output.py ================================================ """Output handling: --download templating, URL printing, binary response writes. Templating tokens: ``{request_id}``, ``{index}``, ``{ext}``. A trailing ``/`` on the template means "use a default filename in this directory." """ from __future__ import annotations import json import mimetypes from pathlib import Path import httpx from rich import print as rprint from comfy_cli.command.generate import client _EXT_FROM_MIME = { "image/png": "png", "image/jpeg": "jpg", "image/jpg": "jpg", "image/webp": "webp", "image/gif": "gif", "image/svg+xml": "svg", } def _ext_from_url(url: str) -> str: suffix = Path(url.split("?", 1)[0]).suffix.lstrip(".").lower() return suffix or "png" def _ext_from_response(resp: httpx.Response) -> str: ct = resp.headers.get("content-type", "").split(";", 1)[0].strip().lower() if ct in _EXT_FROM_MIME: return _EXT_FROM_MIME[ct] guess = mimetypes.guess_extension(ct) or "" return guess.lstrip(".") or "bin" def _resolve_template(template: str, request_id: str, index: int, ext: str) -> Path: if template.endswith(("/", "\\")) or Path(template).is_dir(): # Directory shorthand. path = Path(template) / f"{request_id}_{index}.{ext}" else: path = Path(template.format(request_id=request_id, index=index, ext=ext)) return path.expanduser() def save_urls(urls: list[str], template: str, request_id: str) -> list[Path]: """Download each URL and save under the resolved template path. Returns saved paths. Multi-URL responses (a video + thumbnail from Luma, for example) need a per-output filename. If the template has no ``{index}`` placeholder and isn't a directory shorthand, we auto-insert ``_`` before the suffix and switch the extension to whatever the URL actually points at — so a user who typed ``--download out.mp4`` doesn't silently get a thumbnail JPEG written into ``out.mp4`` because the model returned two URLs.""" saved: list[Path] = [] auto_index = len(urls) > 1 and "{index}" not in template and not template.endswith(("/", "\\")) for i, url in enumerate(urls): ext = _ext_from_url(url) dest = _resolve_template(template, request_id, i, ext) if auto_index: dest = dest.with_name(f"{dest.stem}_{i}.{ext}") dest.parent.mkdir(parents=True, exist_ok=True) data = client.download_bytes(url) dest.write_bytes(data) saved.append(dest) return saved def save_inline_blobs(blobs: list[tuple[str, bytes]], template: str, request_id: str) -> list[Path]: """Save ``(mime, bytes)`` pairs returned inline (e.g. Gemini's ``inlineData``) under the resolved template path. Same auto-indexing rule as ``save_urls``: if the template has no ``{index}`` placeholder and isn't a directory shorthand, multi-blob responses get ``_`` inserted before the suffix so the first blob doesn't get silently overwritten.""" saved: list[Path] = [] auto_index = len(blobs) > 1 and "{index}" not in template and not template.endswith(("/", "\\")) for i, (mime, data) in enumerate(blobs): ext = _EXT_FROM_MIME.get(mime) or (mimetypes.guess_extension(mime) or ".png").lstrip(".") or "png" dest = _resolve_template(template, request_id, i, ext) if auto_index: dest = dest.with_name(f"{dest.stem}_{i}.{ext}") dest.parent.mkdir(parents=True, exist_ok=True) dest.write_bytes(data) saved.append(dest) return saved def save_binary_response(resp: httpx.Response, template: str, request_id: str) -> Path: """Save a single binary response body (e.g. Stability returns image/* bytes).""" ext = _ext_from_response(resp) dest = _resolve_template(template, request_id, 0, ext) dest.parent.mkdir(parents=True, exist_ok=True) dest.write_bytes(resp.content) return dest def print_urls(urls: list[str], request_id: str | None = None) -> None: if not urls: rprint("[yellow]No image URLs found in response. Pass --json to inspect.[/yellow]") return if request_id: rprint(f"[bold green]Request:[/bold green] {request_id}") rprint("[bold green]Outputs:[/bold green]") for url in urls: rprint(f" {url}") def print_json(body: dict | list | str) -> None: if isinstance(body, str): print(body) return print(json.dumps(body, indent=2, default=str)) def print_saved(paths: list[Path]) -> None: if not paths: return rprint("[bold green]Saved:[/bold green]") for p in paths: rprint(f" {p}") ================================================ FILE: comfy_cli/command/generate/poll.py ================================================ """Async-job polling for partner endpoints. There are two flavors: 1. **BFL** — the server returns ``{id, polling_url}`` on submit and we just GET that URL until the ``status`` field is terminal. 2. **Everything else** — a small ``PollSpec`` per partner describes where the job id lives in the create response, how to construct the poll URL (some partners use a sibling endpoint relative to the create path; others have a dedicated ``/tasks/{id}`` endpoint), and which status values mean "succeeded" / "failed". The generic poller walks dot-paths into the JSON to extract the id/status without having to write a new adapter for each partner. """ from __future__ import annotations import time from collections.abc import Callable from dataclasses import dataclass, field from typing import Any import httpx from comfy_cli.command.generate import client @dataclass class PollResult: """Normalized terminal state of an async job.""" status: str # "succeeded" | "failed" | "cancelled" raw: dict[str, Any] # last response body — full upstream payload image_urls: list[str] # any image/video result URLs we could pluck out error: str | None = None # Recognized result extensions when sniffing URLs out of a poll body. _MEDIA_EXTS = ( ".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", ".mp4", ".mov", ".webm", ".m4v", ".gltf", ".glb", ".obj", ".fbx", ".wav", ".mp3", ".m4a", ".flac", ) def _now() -> float: return time.monotonic() def _extract_urls(node: Any) -> list[str]: """Walk a JSON tree, collecting strings that look like media URLs.""" found: list[str] = [] def visit(n: Any) -> None: if isinstance(n, str): low = n.lower() if n.startswith(("http://", "https://")) and ( low.split("?", 1)[0].endswith(_MEDIA_EXTS) or "image" in low or "video" in low ): found.append(n) return if isinstance(n, dict): for v in n.values(): visit(v) elif isinstance(n, list): for v in n: visit(v) visit(node) seen: set[str] = set() return [u for u in found if not (u in seen or seen.add(u))] def _dotget(body: Any, path: str) -> Any: """Look up a dotted path inside a JSON body. Returns None if any segment misses.""" cur: Any = body for part in path.split("."): if isinstance(cur, dict): cur = cur.get(part) else: return None return cur def _first(body: Any, paths: tuple[str, ...]) -> Any: for p in paths: v = _dotget(body, p) if v not in (None, "", []): return v return None def _sleep(seconds: float) -> None: time.sleep(seconds) def poll_bfl( initial: dict[str, Any], api_key: str, *, interval: float = 2.0, timeout: float = 300.0, on_progress: Callable[[float], None] | None = None, create_path: str | None = None, # ignored, kept for uniform signature ) -> PollResult: """BFL polls a server-issued ``polling_url`` until ``status`` flips to Ready.""" url = initial.get("polling_url") if not url: raise client.ApiError(0, "", "BFL response missing polling_url") deadline = _now() + timeout last_body: dict[str, Any] = {} while _now() < deadline: resp = client.get(url, api_key=api_key) if resp.status_code >= 400: client.raise_for_status(resp) last_body = resp.json() status = str(last_body.get("status", "")).strip() if on_progress is not None: progress = last_body.get("progress") if isinstance(progress, int | float): on_progress(float(progress)) if status == "Ready": urls = _extract_urls(last_body.get("result")) return PollResult(status="succeeded", raw=last_body, image_urls=urls) if status in {"Error", "Task not found", "Content Moderated", "Request Moderated"}: return PollResult(status="failed", raw=last_body, image_urls=[], error=status) _sleep(interval) return PollResult(status="failed", raw=last_body, image_urls=[], error=f"timed out after {timeout:.0f}s") @dataclass(frozen=True) class PollSpec: """Per-partner polling configuration. ``poll_url`` is a template supporting ``{id}`` and ``{create_path}``; ``post_success_url`` (optional) is a second-stage fetcher invoked once the job reaches a success state — for partners like MiniMax where the terminal poll response gives you a file id you still need to redeem for a URL.""" name: str id_paths: tuple[str, ...] poll_url: str status_paths: tuple[str, ...] success_values: tuple[str, ...] failure_values: tuple[str, ...] = () progress_path: str | None = None post_success_url: str | None = None post_success_id_paths: tuple[str, ...] = field(default_factory=tuple) _POLL_SPECS: dict[str, PollSpec] = { "kling": PollSpec( name="kling", id_paths=("data.task_id",), poll_url="{create_path}/{id}", status_paths=("data.task_status",), success_values=("succeed",), failure_values=("failed",), ), "luma": PollSpec( name="luma", id_paths=("id",), poll_url="/proxy/luma/generations/{id}", status_paths=("state",), success_values=("completed",), failure_values=("failed",), ), "minimax": PollSpec( name="minimax", id_paths=("task_id",), poll_url="/proxy/minimax/query/video_generation?task_id={id}", status_paths=("status",), success_values=("Success",), failure_values=("Fail",), post_success_url="/proxy/minimax/files/retrieve?file_id={id}", post_success_id_paths=("file_id",), ), "runway": PollSpec( name="runway", id_paths=("id",), poll_url="/proxy/runway/tasks/{id}", status_paths=("status",), success_values=("SUCCEEDED",), failure_values=("FAILED", "CANCELLED", "THROTTLED"), progress_path="progress", ), "moonvalley": PollSpec( name="moonvalley", id_paths=("id",), poll_url="/proxy/moonvalley/prompts/{id}", status_paths=("status",), success_values=("completed",), failure_values=("failed", "error"), ), "pika": PollSpec( name="pika", id_paths=("video_id", "id"), poll_url="/proxy/pika/videos/{id}", status_paths=("status",), success_values=("finished",), # Pika's enum has no explicit failure state; treat sustained queued/started # as in-progress and rely on `timeout` to surface stalls. failure_values=(), progress_path="progress", ), "vidu": PollSpec( name="vidu", id_paths=("task_id",), poll_url="/proxy/vidu/tasks/{id}/creations", status_paths=("state",), success_values=("success",), failure_values=("failed",), ), "xai_video": PollSpec( name="xai_video", id_paths=("request_id",), poll_url="/proxy/xai/v1/videos/{id}", status_paths=("status",), success_values=("done",), failure_values=(), ), "seedance": PollSpec( name="seedance", id_paths=("id",), poll_url="/proxy/byteplus/api/v3/contents/generations/tasks/{id}", status_paths=("status",), success_values=("succeeded",), failure_values=("failed", "cancelled"), ), } def _build_poll_url(spec: PollSpec, job_id: str, create_path: str | None) -> str: url = spec.poll_url.replace("{id}", str(job_id)) if "{create_path}" in url: if not create_path: raise client.ApiError(0, "", f"{spec.name} poller needs the create path") url = url.replace("{create_path}", create_path) return url def poll_generic( initial: dict[str, Any], api_key: str, *, spec: PollSpec, create_path: str | None = None, interval: float = 2.0, timeout: float = 300.0, on_progress: Callable[[float], None] | None = None, ) -> PollResult: """Drive a partner's poll endpoint by reading dot-paths out of the JSON. ``initial`` is the create-response body; we pull a job id out of it, build the poll URL from ``spec.poll_url``, and GET it until the status field hits a terminal value. Handles MiniMax-style two-stage flows via ``spec.post_success_url`` (a follow-up GET keyed off something the terminal poll body contains, e.g. ``file_id``).""" job_id = _first(initial, spec.id_paths) if job_id is None: raise client.ApiError(0, "", f"{spec.name} response missing id (looked for {spec.id_paths})") url = _build_poll_url(spec, str(job_id), create_path) deadline = _now() + timeout last_body: dict[str, Any] = {} while _now() < deadline: resp = client.get(url, api_key=api_key) if resp.status_code >= 400: client.raise_for_status(resp) last_body = resp.json() if on_progress is not None and spec.progress_path: p = _dotget(last_body, spec.progress_path) if isinstance(p, int | float): # Some partners report 0–100, others 0–1; normalize. on_progress(float(p) / 100.0 if p > 1 else float(p)) status = _first(last_body, spec.status_paths) status_str = str(status) if status is not None else "" if status_str in spec.success_values: merged = dict(last_body) if spec.post_success_url: redeem_id = _first(last_body, spec.post_success_id_paths) if redeem_id is not None: redeem_url = spec.post_success_url.replace("{id}", str(redeem_id)) r2 = client.get(redeem_url, api_key=api_key) if r2.status_code < 400: try: merged["_redeemed"] = r2.json() except ValueError: pass urls = _extract_urls(merged) return PollResult(status="succeeded", raw=merged, image_urls=urls) if status_str in spec.failure_values: return PollResult(status="failed", raw=last_body, image_urls=[], error=status_str) _sleep(interval) return PollResult(status="failed", raw=last_body, image_urls=[], error=f"timed out after {timeout:.0f}s") def extract_job_id(name: str, body: dict[str, Any]) -> str | None: """Pull the partner's job id out of a create-response body for display.""" if name == "bfl": return body.get("id") or None spec = _POLL_SPECS.get(name) if spec is None: return None v = _first(body, spec.id_paths) return str(v) if v is not None else None def build_synthetic_initial(name: str, job_id: str, base_url: str | None = None) -> dict[str, Any]: """Recreate a minimal create-response so ``poll_generic`` can find the id. Used by ``comfy generate resume`` — the user supplies just a partner key and a job id, and we reverse-engineer the shape the poller expects.""" if name == "bfl": if not base_url: raise client.ApiError(0, "", "BFL resume needs a base URL to build the polling_url") return {"polling_url": f"{base_url}/proxy/bfl/get_result?id={job_id}"} spec = _POLL_SPECS.get(name) if not spec: raise client.ApiError(0, "", f"No polling adapter for partner {name!r}") primary = spec.id_paths[0] body: dict[str, Any] = {} cur = body parts = primary.split(".") for p in parts[:-1]: cur[p] = {} cur = cur[p] cur[parts[-1]] = job_id return body def get_poller(name: str) -> Callable[..., PollResult]: """Return the poller callable for a partner name. All pollers accept the same kwargs (``api_key``, ``timeout``, ``on_progress``, ``create_path``) so callers don't need to special-case which one they got.""" if name == "bfl": return poll_bfl if name in _POLL_SPECS: spec = _POLL_SPECS[name] def runner( initial: dict[str, Any], api_key: str, *, create_path: str | None = None, interval: float = 2.0, timeout: float = 300.0, on_progress: Callable[[float], None] | None = None, ) -> PollResult: return poll_generic( initial, api_key, spec=spec, create_path=create_path, interval=interval, timeout=timeout, on_progress=on_progress, ) return runner raise client.ApiError(0, "", f"No polling adapter for partner {name!r}") def sync_result_from_response(resp: httpx.Response) -> PollResult: """Wrap a sync response in a PollResult so the run path is uniform.""" ctype = resp.headers.get("content-type", "") if ctype.startswith(("image/", "video/", "audio/")): return PollResult(status="succeeded", raw={"_binary": True}, image_urls=[]) try: body = resp.json() except ValueError: return PollResult(status="succeeded", raw={"_text": resp.text}, image_urls=[]) return PollResult(status="succeeded", raw=body, image_urls=_extract_urls(body)) ================================================ FILE: comfy_cli/command/generate/schema.py ================================================ """Convert an openapi requestBody schema into CLI flag definitions, and parse user-supplied argv against those flags. This is the equivalent of fal-ai's `genmedia run --param value` UX: the schema for each endpoint drives which flags are valid, their types, and their help text. We accept inline JSON for object/array params and treat fields with ``format: binary`` as file-path inputs that get streamed via multipart. """ from __future__ import annotations import json import shlex from dataclasses import dataclass, field from pathlib import Path from typing import Any from comfy_cli.command.generate.spec import Endpoint @dataclass class FlagDef: name: str # openapi property name; CLI flag = "--" + name kind: str # "string" | "integer" | "number" | "boolean" | "enum" | "object" | "array" | "binary" required: bool description: str = "" default: Any = None enum: list[str] = field(default_factory=list) item_kind: str | None = None # for arrays: kind of items ("binary", "string", ...) upload_mode: str | None = None # "base64" | "url" | None — auto-upload behavior for local paths class SchemaError(ValueError): pass def _classify(prop: dict[str, Any]) -> tuple[str, str | None]: """Return (kind, item_kind). item_kind only set when kind == 'array'.""" if "enum" in prop and prop.get("type", "string") == "string": return "enum", None t = prop.get("type") if t == "string" and prop.get("format") == "binary": return "binary", None if t == "string": return "string", None if t == "integer": return "integer", None if t == "number": return "number", None if t == "boolean": return "boolean", None if t == "array": items = prop.get("items") or {} if items.get("format") == "binary": return "array", "binary" return "array", items.get("type", "string") if t == "object" or "oneOf" in prop or "anyOf" in prop or "allOf" in prop: return "object", None # Fallback — treat as string. return "string", None _URL_FIELD_NAMES = frozenset({"input_image", "image_url", "image_uri", "init_image", "reference_image", "mask_url"}) def _detect_upload_mode(name: str, prop: dict[str, Any]) -> str | None: """Infer whether this string param expects a base64 blob, a URL, or neither. JSON-only endpoints sometimes ship a local file via base64 (BFL Kontext, Flux Fill/Canny/Depth/Expand all say "Base64 encoded image" in their descriptions) and sometimes via a signed URL. We sniff three signals in priority order: openapi ``format``, description keywords, then a small allow-list of common field names.""" if prop.get("type") != "string": return None desc = (prop.get("description") or "").lower() fmt = (prop.get("format") or "").lower() if "base64" in desc: return "base64" if fmt in {"uri", "url"}: return "url" if "url" in desc or "uri" in desc: return "url" if name.lower() in _URL_FIELD_NAMES: return "url" return None def flags_for(endpoint: Endpoint) -> list[FlagDef]: # Lazy import: adapters depends on FlagDef, so the top-level import would cycle. from comfy_cli.command.generate import adapters as _adapters adapter = _adapters.get(endpoint.id) if adapter is not None: return list(adapter.flags) schema = endpoint.request_schema or {} props = schema.get("properties") or {} required = set(schema.get("required") or []) out: list[FlagDef] = [] for name, prop in props.items(): if not isinstance(prop, dict): continue kind, item_kind = _classify(prop) upload_mode = _detect_upload_mode(name, prop) if kind == "string" else None out.append( FlagDef( name=name, kind=kind, required=name in required, description=str(prop.get("description") or "").strip(), default=prop.get("default"), enum=list(prop.get("enum") or []), item_kind=item_kind, upload_mode=upload_mode, ) ) return out def _coerce(flag: FlagDef, raw: str) -> Any: """Convert a string value from argv into its typed form, raising SchemaError with a clear message on failure.""" if flag.kind == "string": return raw if flag.kind == "enum": if flag.enum and raw not in flag.enum: raise SchemaError(f"--{flag.name}: {raw!r} is not one of {flag.enum}") return raw if flag.kind == "integer": try: return int(raw) except ValueError as e: raise SchemaError(f"--{flag.name}: expected integer, got {raw!r}") from e if flag.kind == "number": try: return float(raw) except ValueError as e: raise SchemaError(f"--{flag.name}: expected number, got {raw!r}") from e if flag.kind == "boolean": v = raw.lower() if v in {"true", "1", "yes", "on"}: return True if v in {"false", "0", "no", "off"}: return False raise SchemaError(f"--{flag.name}: expected boolean (true/false), got {raw!r}") if flag.kind in ("object", "array"): # For arrays of binary, the raw value is a comma-separated list of paths # or a JSON array of paths. if flag.kind == "array" and flag.item_kind == "binary": try: parsed = json.loads(raw) if raw.startswith("[") else [p.strip() for p in raw.split(",")] except json.JSONDecodeError as e: raise SchemaError(f"--{flag.name}: invalid file list: {e}") from e return [Path(p).expanduser() for p in parsed] try: return json.loads(raw) except json.JSONDecodeError as e: raise SchemaError( f"--{flag.name}: expected JSON {flag.kind}, got {raw!r}. " 'Wrap the value in quotes, e.g. --{n} \'{{"k":"v"}}\''.format(n=flag.name) ) from e if flag.kind == "binary": return Path(raw).expanduser() raise SchemaError(f"--{flag.name}: unknown kind {flag.kind!r}") # unreachable def parse_args(flags: list[FlagDef], argv: list[str]) -> dict[str, Any]: """Parse ``argv`` against the given flag list. Returns {name: typed_value}. Recognized forms: --name value --name=value --name (only for boolean flags; means True) --no-name (only for boolean flags; means False) """ by_name = {f.name: f for f in flags} # Also accept dash-separated aliases so `--no-X` matching can't collide with # underscored names. by_dashed = {f.name.replace("_", "-"): f for f in flags} values: dict[str, Any] = {} i = 0 while i < len(argv): token = argv[i] if not token.startswith("--"): raise SchemaError(f"Unexpected positional argument: {token!r}") body = token[2:] raw: str | None = None if "=" in body: body, raw = body.split("=", 1) flag = by_name.get(body) or by_dashed.get(body) if flag is None and body.startswith("no-"): candidate = body[3:] f = by_name.get(candidate) or by_dashed.get(candidate) if f and f.kind == "boolean": values[f.name] = False i += 1 continue if flag is None: raise SchemaError(f"Unknown flag: {token!r}. Run `comfy generate schema ` to list params.") if flag.kind == "boolean" and raw is None: # Look ahead — only consume next token if it parses as boolean. if i + 1 < len(argv) and argv[i + 1].lower() in {"true", "false", "1", "0", "yes", "no", "on", "off"}: raw = argv[i + 1] i += 1 else: values[flag.name] = True i += 1 continue if raw is None: if i + 1 >= len(argv): raise SchemaError(f"--{flag.name}: missing value") raw = argv[i + 1] i += 2 else: i += 1 values[flag.name] = _coerce(flag, raw) missing = [f.name for f in flags if f.required and f.name not in values] if missing: joined = ", ".join(f"--{m}" for m in missing) raise SchemaError(f"Missing required argument(s): {joined}") return values def help_text(endpoint: Endpoint, flags: list[FlagDef]) -> str: """Produce a human-readable help block describing a model and its flags. The caller is expected to already print a one-line ``Model:`` header, so we skip restating the id here.""" lines: list[str | None] = [ f" {endpoint.summary}" if endpoint.summary else None, f" partner: {endpoint.partner} style: {endpoint.category} " f"content-type: {endpoint.request_content_type} " f"mode: {'async (' + endpoint.polling + ')' if endpoint.polling else 'sync'}", "", "Parameters (use as `--name value`):", ] if not flags: lines.append(" (no parameters)") for f in flags: marker = " *" if f.required else " " type_str = f.kind if f.kind == "enum": type_str = "enum=" + "|".join(f.enum) if f.kind == "array" and f.item_kind: type_str = f"array<{f.item_kind}>" head = f"{marker} --{f.name} <{type_str}>" lines.append(head) if f.description: lines.append(f" {f.description}") if f.default is not None: lines.append(f" default: {f.default!r}") lines.append("") lines.append("Common options:") lines.append(" --download Save outputs locally. Supports {request_id}, {index}, {ext}.") lines.append(" --async Submit and return job id without waiting.") lines.append(" --json Emit raw JSON response instead of pretty output.") lines.append(" --timeout Override sync-poll timeout (default 300).") lines.append(" --api-key Override COMFY_API_KEY env var.") return "\n".join(line for line in lines if line is not None) def example_invocation(endpoint: Endpoint, flags: list[FlagDef], display_name: str | None = None) -> str: """A copy-paste invocation snippet showing required args.""" parts = ["comfy generate", display_name or endpoint.id] for f in flags: if not f.required: continue if f.kind == "binary": parts.extend([f"--{f.name}", "./input.png"]) elif f.kind == "enum": parts.extend([f"--{f.name}", f.enum[0] if f.enum else "VALUE"]) elif f.kind == "string": parts.extend([f"--{f.name}", shlex.quote("...")]) elif f.kind in ("object", "array"): parts.extend([f"--{f.name}", shlex.quote("{}")]) else: parts.extend([f"--{f.name}", "0"]) return " ".join(parts) ================================================ FILE: comfy_cli/command/generate/spec/openapi.yml ================================================ openapi: "3.0.2" info: title: Comfy API version: "1.0" servers: - url: https://api.comfy.org paths: /users: get: summary: Get information about the calling user. operationId: getUser tags: - Registry security: - BearerAuth: [] responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/User" "404": description: Not Found "401": description: Unauthorized /customers: post: summary: Create a new customer description: Creates a new customer using the provided token. No request body is needed as user information is extracted from the token. operationId: createCustomer x-excluded: true tags: - API Nodes security: - BearerAuth: [] responses: "200": description: Customer already exists content: application/json: schema: $ref: "#/components/schemas/Customer" "201": description: Customer created successfully content: application/json: schema: $ref: "#/components/schemas/Customer" "400": description: Invalid request content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" get: summary: Search for customers description: Search for customers by email, name, Stripe ID, or Metronome ID. operationId: searchCustomers x-excluded: true tags: - API Nodes - Admin security: - BearerAuth: [] parameters: - in: query name: email schema: type: string description: Email address to search for - in: query name: name schema: type: string description: Customer name to search for - in: query name: stripe_id schema: type: string description: Stripe customer ID to search for - in: query name: metronome_id schema: type: string description: Metronome customer ID to search for\ - in: query name: page schema: type: integer default: 1 description: Page number to retrieve - in: query name: limit schema: type: integer default: 10 description: Number of customers to return per page responses: "200": description: Customers matching the search criteria content: application/json: schema: type: object properties: page: type: integer description: Current page number limit: type: integer description: Number of customers per page totalPages: type: integer description: Total number of pages available customers: type: array items: $ref: "#/components/schemas/Customer" total: type: integer description: Total number of matching customers "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "403": description: Forbidden - insufficient permissions content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /customers/me: get: summary: Get authenticated customer details description: Returns details about the currently authenticated customer based on their JWT token. operationId: getAuthenticatedCustomer x-excluded: true tags: - API Nodes security: - BearerAuth: [] responses: "200": description: Customer details retrieved successfully content: application/json: schema: $ref: "#/components/schemas/Customer" "401": description: Unauthorized or invalid token "404": description: Customer not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /customers/{customer_id}: get: summary: Get a customer by ID description: Returns details about a customer by their ID. operationId: getCustomerById x-excluded: true tags: - API Nodes - Admin security: - BearerAuth: [] parameters: - in: path name: customer_id required: true schema: type: string responses: "200": description: Customer details retrieved successfully content: application/json: schema: type: object properties: customer: $ref: "#/components/schemas/CustomerAdmin" "401": description: Unauthorized or invalid token "404": description: Customer not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /customers/api-keys: get: summary: List all API keys for a customer operationId: listCustomerAPIKeys x-excluded: true responses: "200": description: List of API keys content: application/json: schema: type: object properties: api_keys: type: array items: $ref: "#/components/schemas/APIKey" "401": description: Unauthorized "404": description: Customer not found "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" post: summary: Create a new API key for a customer operationId: createCustomerAPIKey x-excluded: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateAPIKeyRequest" responses: "201": description: API key created content: application/json: schema: type: object properties: api_key: $ref: "#/components/schemas/APIKeyWithPlaintext" "400": description: Bad request content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "404": description: Customer or API key not found "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /customers/api-keys/{api_key_id}: delete: summary: Delete an API key for a customer operationId: deleteCustomerAPIKey x-excluded: true parameters: - in: path name: api_key_id required: true schema: type: string responses: "204": description: API key deleted "401": description: Unauthorized "404": description: Customer or API key not found "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /customers/credit: post: summary: Initiates a Credit Purchase. operationId: InitiateCreditPurchase x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: type: object properties: amount_micros: type: integer format: int64 description: the amount of the checkout transaction in micro value currency: type: string description: the currency used in the checkout transaction required: - amount_micros - currency responses: "201": description: Customer Checkout created successfully content: application/json: schema: type: object properties: checkout_url: type: string description: the url to redirect the customer "400": description: Bad request, invalid token or user already exists content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized or invalid token "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /customers/billing: post: summary: Access customer billing portal description: Creates a session for the customer to access their billing portal where they can manage subscriptions, payment methods, and view invoices. operationId: AccessBillingPortal x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: false content: application/json: schema: type: object properties: return_url: type: string description: Optional URL to redirect the customer after they're done with the billing portal target_tier: type: string enum: [standard, creator, pro, standard-yearly, creator-yearly, pro-yearly] description: Optional target subscription tier. When provided, creates a deep link directly to the subscription update confirmation screen with this tier pre-selected. responses: "200": description: Billing portal session created successfully content: application/json: schema: type: object properties: billing_portal_url: type: string description: The URL to redirect the customer to the billing portal "400": description: Bad request, invalid input content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized or invalid token "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /customers/cloud-subscription-checkout: post: summary: Create cloud subscription checkout session description: Creates a cloud subscription checkout session for $20/month with automatic billing operationId: createCloudSubscriptionCheckout x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: false content: application/json: schema: type: object properties: ga_client_id: type: string description: Google Analytics client ID from _ga cookie ga_session_id: type: string description: Google Analytics session ID ga_session_number: type: string description: Google Analytics session number gclid: type: string description: Google Ads click ID gbraid: type: string description: Google Ads iOS attribution parameter wbraid: type: string description: Google Ads web-to-app attribution parameter utm_source: type: string description: UTM source parameter utm_medium: type: string description: UTM medium parameter utm_campaign: type: string description: UTM campaign parameter utm_term: type: string description: UTM term parameter utm_content: type: string description: UTM content parameter im_ref: type: string description: Impact.com click ID for affiliate conversion tracking responses: "201": description: Subscription checkout session created successfully content: application/json: schema: type: object properties: checkout_url: type: string description: The URL to redirect the customer to complete subscription "400": description: Bad request, invalid input content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized or invalid token "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /customers/cloud-subscription-checkout/{tier}: post: summary: Create cloud subscription checkout session for a specific tier description: Creates a cloud subscription checkout session for a specific subscription tier (standard, creator, or pro) with automatic billing operationId: createCloudSubscriptionCheckoutTier x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] parameters: - in: path name: tier required: true description: The subscription tier (standard, creator, or pro) with optional yearly billing (standard-yearly, creator-yearly, pro-yearly) schema: type: string enum: - standard - creator - pro - standard-yearly - creator-yearly - pro-yearly requestBody: required: false content: application/json: schema: type: object properties: ga_client_id: type: string description: Google Analytics client ID from _ga cookie ga_session_id: type: string description: Google Analytics session ID ga_session_number: type: string description: Google Analytics session number gclid: type: string description: Google Ads click ID gbraid: type: string description: Google Ads iOS attribution parameter wbraid: type: string description: Google Ads web-to-app attribution parameter utm_source: type: string description: UTM source parameter utm_medium: type: string description: UTM medium parameter utm_campaign: type: string description: UTM campaign parameter utm_term: type: string description: UTM term parameter utm_content: type: string description: UTM content parameter im_ref: type: string description: Impact.com click ID for affiliate conversion tracking responses: "201": description: Subscription checkout session created successfully content: application/json: schema: type: object properties: checkout_url: type: string description: The URL to redirect the customer to complete subscription "400": description: Bad request, invalid input or tier content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized or invalid token "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /customers/cloud-subscription-status: get: summary: Check cloud subscription status description: Check if the customer has an active cloud subscription operationId: GetCloudSubscriptionStatus x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] responses: "200": description: Cloud subscription status retrieved successfully content: application/json: schema: type: object properties: is_active: type: boolean description: Whether the customer has an active cloud subscription subscription_id: type: string description: The active subscription ID if one exists nullable: true subscription_tier: allOf: - $ref: "#/components/schemas/SubscriptionTier" nullable: true subscription_duration: allOf: - $ref: "#/components/schemas/SubscriptionDuration" nullable: true has_fund: type: boolean description: Whether the customer has funds/credits available renewal_date: type: string format: date-time description: The next renewal date for the subscription (ISO 8601 format) nullable: true end_date: type: string format: date-time description: The date when the subscription is set to end (ISO 8601 format) nullable: true "401": description: Unauthorized or invalid token "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /admin/verify-api-key: post: summary: Verify a ComfyUI API key and return customer details description: | Validates a ComfyUI API key and returns the associated customer information. This endpoint is used by cloud.comfy.org to authenticate users via API keys instead of Firebase tokens. operationId: VerifyApiKey x-excluded: true tags: - Admin parameters: - in: header name: X-Comfy-Admin-Secret required: true schema: type: string description: Admin API secret used to authorize this request requestBody: required: true content: application/json: schema: type: object properties: api_key: type: string description: The ComfyUI API key to verify (e.g., comfy_xxx...) required: - api_key responses: "200": description: API key is valid content: application/json: schema: type: object properties: valid: type: boolean description: Whether the API key is valid firebase_uid: type: string description: The Firebase UID of the user email: type: string description: The customer's email address name: type: string description: The customer's name is_admin: type: boolean description: Whether the customer is an admin required: - valid - firebase_uid "401": description: Unauthorized or missing admin API secret "403": description: API key auth not allowed for this account (e.g., free tier) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: API key not found or invalid content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /admin/generate-token: post: summary: Generate a short-lived JWT admin token description: | Generates a short-lived JWT admin token for browser-based admin operations. The user must already be authenticated with Firebase and have admin privileges. The generated token expires after 1 hour. operationId: GenerateAdminToken tags: - Admin security: - BearerAuth: [] responses: "200": description: JWT token generated successfully content: application/json: schema: type: object properties: token: type: string description: The JWT admin token expires_at: type: string format: date-time description: When the token expires required: - token - expires_at "401": description: Unauthorized or user is not an admin content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /admin/customers/{customer_id}/cloud-subscription-status: get: summary: Admin check cloud subscription status description: Allows an admin to inspect a specific customer's cloud subscription status. operationId: GetAdminCustomerCloudSubscriptionStatus x-excluded: true tags: - Admin parameters: - in: path name: customer_id required: true schema: type: string description: The ID of the customer whose subscription status to retrieve - in: header name: X-Comfy-Admin-Secret required: true schema: type: string description: Admin API secret used to authorize this request responses: "200": description: Cloud subscription status retrieved successfully content: application/json: schema: type: object properties: is_active: type: boolean description: Whether the customer has an active cloud subscription subscription_id: type: string description: The active subscription ID if one exists nullable: true subscription_tier: allOf: - $ref: "#/components/schemas/SubscriptionTier" nullable: true subscription_duration: allOf: - $ref: "#/components/schemas/SubscriptionDuration" nullable: true has_fund: type: boolean description: Whether the customer has funds/credits available renewal_date: type: string format: date-time description: The next renewal date for the subscription (ISO 8601 format) nullable: true end_date: type: string format: date-time description: The date when the subscription is set to end (ISO 8601 format) nullable: true "401": description: Unauthorized or missing admin API secret "404": description: Customer not found "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /admin/customers/{customer_id}/balance: get: summary: Admin get customer's remaining balance description: Returns the specified customer's current remaining balance in microamount and its currency. operationId: GetAdminCustomerBalance x-excluded: true tags: - Admin parameters: - in: path name: customer_id required: true schema: type: string - in: header name: X-Comfy-Admin-Secret required: true schema: type: string responses: "200": description: Customer balance retrieved successfully content: application/json: schema: type: object properties: amount_micros: type: number format: double prepaid_balance_micros: type: number format: double cloud_credit_balance_micros: type: number format: double pending_charges_micros: type: number format: double effective_balance_micros: type: number format: double currency: type: string required: - amount_micros - currency "401": description: Unauthorized content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Customer not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /admin/customers/{customer_id}/stripe-data: delete: summary: Delete customer Stripe data description: Deletes the Stripe customer data associated with the given customer ID. operationId: DeleteAdminCustomerStripeData x-excluded: true tags: - Admin parameters: - in: path name: customer_id required: true schema: type: string description: The ID of the customer whose Stripe data to delete - in: header name: X-Comfy-Admin-Secret required: true schema: type: string description: Admin API secret used to authorize this request responses: "200": description: Stripe data deleted successfully content: application/json: schema: type: object properties: message: type: string description: Success message "400": description: Bad request - missing required parameter content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized or missing admin API secret "404": description: Customer not found "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /admin/customers/{customer_id}/archive-metronome-data: post: summary: Archive customer Metronome data description: Archives metronome data. See https://docs.metronome.com/api-reference/customers/archive-a-customer operationId: PostAdminArchiveMetronomeData x-excluded: true tags: - Admin parameters: - in: path name: customer_id required: true schema: type: string description: The ID of the customer whose Metronome data to archive - in: header name: X-Comfy-Admin-Secret required: true schema: type: string description: Admin API secret used to authorize this request responses: "200": description: Metronome data archived successfully content: application/json: schema: type: object properties: message: type: string description: Success message "400": description: Bad request - missing required parameter content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized or missing admin API secret "404": description: Customer not found "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /customers/usage: post: summary: Get customer's usage description: Returns the customer's as a dashboard URL. operationId: GetCustomerUsage x-excluded: true tags: - API Nodes requestBody: required: false content: application/json: schema: type: object properties: dashboard_type: type: string description: The type of dashboard to retrieve enum: - invoices - usage - credits - commits_and_credits default: usage color_overrides: type: array description: Optional list of colors to override for branding items: type: object required: - name - value properties: name: type: string description: The color property to override enum: - Gray_dark - Gray_medium - Gray_light - Gray_extralight - White - Primary_medium - Primary_light - UsageLine_0 - UsageLine_1 - UsageLine_2 - UsageLine_3 - UsageLine_4 - UsageLine_5 - UsageLine_6 - UsageLine_7 - UsageLine_8 - UsageLine_9 - Primary_green - Primary_red - Progress_bar - Progress_bar_background value: type: string description: Hex color code (e.g., "#FF5733") pattern: "^#[0-9A-Fa-f]{6}$" responses: "200": description: Successful response content: application/json: schema: type: object properties: url: type: string description: The dashboard URL for the customer's usage "401": description: Unauthorized or invalid token "404": description: Customer not found "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /customers/balance: get: summary: Get customer's remaining balance description: Returns the customer's current remaining balance in microamount and its currency, with separate breakdowns for prepaid commits and cloud credits. operationId: GetCustomerBalance x-excluded: true tags: - API Nodes security: - BearerAuth: [] responses: "200": description: Customer balance retrieved successfully content: application/json: schema: type: object 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. Only included when the show_negative_balances feature flag is enabled. 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. Only included when the show_negative_balances feature flag is enabled. currency: type: string description: The currency code (e.g., "usd") required: - amount_micros - currency "401": description: Unauthorized or invalid token "404": description: Customer not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /customers/{customer_id}/balance: get: summary: Get customer's remaining balance by ID description: Returns the specified customer's current remaining balance in microamount and its currency, with separate breakdowns for prepaid commits and cloud credits. operationId: GetCustomerBalanceById x-excluded: true tags: - API Nodes - Admin security: - BearerAuth: [] parameters: - in: path name: customer_id required: true schema: type: string description: The ID of the customer whose balance to retrieve responses: "200": description: Customer balance retrieved successfully content: application/json: schema: type: object 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. Only included when the show_negative_balances feature flag is enabled. 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. Only included when the show_negative_balances feature flag is enabled. currency: type: string description: The currency code (e.g., "usd") required: - amount_micros - currency "400": description: Bad request content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized or invalid token "404": description: Customer not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /customers/{customer_id}/usage: post: summary: Track usage for a customer (Admin only) description: Manually track usage for a customer in Metronome. This endpoint is for admin use to record usage events. operationId: TrackCustomerUsage x-excluded: true tags: - API Nodes - Admin security: - BearerAuth: [] parameters: - in: path name: customer_id required: true schema: type: string description: The ID of the customer to track usage for - in: header name: X-Comfy-Admin-Secret required: true schema: type: string description: Admin API secret used to authorize this request requestBody: required: true content: application/json: schema: type: object properties: transaction_id: type: string format: uuid description: Unique transaction ID for this usage event timestamp: type: string format: date-time description: Timestamp of the usage event (RFC3339 format) params: type: object additionalProperties: true description: Custom parameters for the usage event required: - transaction_id - params responses: "200": description: Usage tracked successfully content: application/json: schema: type: object properties: message: type: string description: Success message "400": description: Bad request content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized or invalid token "404": description: Customer not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /customers/storage: post: summary: Store a resource for a customer description: Store a resource for a customer. Resource will have a 24 hour expiry. The signed URL will be generated for the specified file path. operationId: createCustomerStorageResource x-excluded: true tags: - API Nodes security: - BearerAuth: [] requestBody: required: true content: application/json: schema: type: object properties: file_name: type: string description: The desired name of the file (e.g., 'profile.jpg') content_type: type: string description: The content type of the file (e.g., 'image/png') file_hash: type: string description: The hash of the file. If provided, an existing file with the same hash may be returned. required: - file_name responses: "200": description: Signed URL generated successfully content: application/json: schema: $ref: "#/components/schemas/CustomerStorageResourceResponse" "400": description: Bad request content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /customers/{customer_id}/events: get: summary: Get events related to customer operationId: GetCustomerEventsById x-excluded: true tags: - API Nodes security: - BearerAuth: [] parameters: - in: path name: customer_id required: true schema: type: string - in: query name: page description: Page number of the nodes list required: false schema: type: integer default: 1 - in: query name: limit description: Number of nodes to return per page required: false schema: type: integer default: 10 - in: query name: filter description: Event type to filter required: false schema: type: string - in: query name: start_date description: Start date for filtering events (RFC3339 format, e.g., 2025-01-01T00:00:00Z) required: false schema: type: string format: date-time - in: query name: end_date description: End date for filtering events (RFC3339 format, e.g., 2025-01-31T23:59:59Z) required: false schema: type: string format: date-time responses: "200": description: A paginated list of nodes content: application/json: schema: type: object properties: total: type: integer description: Total number of events available events: type: array items: $ref: "#/components/schemas/AuditLog" page: type: integer description: Current page number limit: type: integer description: Maximum number of nodes per page totalPages: type: integer description: Total number of pages available "400": description: Invalid input, object invalid "404": description: Not found "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /customers/events: get: summary: Get events related to customer operationId: GetCustomerEvents x-excluded: true tags: - API Nodes security: - BearerAuth: [] parameters: - in: query name: page description: Page number of the nodes list required: false schema: type: integer default: 1 - in: query name: limit description: Number of nodes to return per page required: false schema: type: integer default: 10 - in: query name: filter description: Event type to filter required: false schema: type: string - in: query name: start_date description: Start date for filtering events (RFC3339 format, e.g., 2025-01-01T00:00:00Z) required: false schema: type: string format: date-time - in: query name: end_date description: End date for filtering events (RFC3339 format, e.g., 2025-01-31T23:59:59Z) required: false schema: type: string format: date-time responses: "200": description: A paginated list of nodes content: application/json: schema: type: object properties: total: type: integer description: Total number of events available events: type: array items: $ref: "#/components/schemas/AuditLog" page: type: integer description: Current page number limit: type: integer description: Maximum number of nodes per page totalPages: type: integer description: Total number of pages available "400": description: Invalid input, object invalid "404": description: Not found "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /upload-artifact: post: summary: Receive artifacts (output files) from the ComfyUI GitHub Action description: Receive artifacts (output files) from the ComfyUI GitHub Action x-excluded: true tags: - ComfyUI CI requestBody: required: true content: application/json: schema: type: object properties: repo: type: string description: Repository name job_id: type: string description: Unique identifier for the job run_id: type: string description: Unique identifier for the run os: type: string description: Operating system used in the run cuda_version: type: string description: Cuda version. bucket_name: type: string description: The name of the bucket where the output files are stored output_files_gcs_paths: type: string description: A comma separated string that contains GCS path(s) to output files. eg. gs://bucket-name/output, gs://bucket-name/output2 comfy_logs_gcs_path: type: string description: The path to ComfyUI logs. eg. gs://bucket-name/logs comfy_run_flags: type: string description: The flags used in the comfy run commit_hash: type: string commit_time: type: string description: The time of the commit in the format of "YYYY-MM-DDTHH:MM:SSZ" (2016-10-10T00:00:00Z) commit_message: type: string description: The commit message workflow_name: type: string description: The name of the workflow branch_name: type: string start_time: type: integer format: int64 description: The start time of the job as a Unix timestamp. end_time: type: integer format: int64 description: The end time of the job as a Unix timestamp. avg_vram: type: integer description: The average amount of VRAM used in the run. peak_vram: type: integer description: The peak amount of VRAM used in the run. pr_number: type: string description: The pull request number author: type: string description: The author of the commit job_trigger_user: type: string description: The user who triggered the job python_version: type: string description: The python version used in the run pytorch_version: type: string description: The pytorch version used in the run machine_stats: $ref: "#/components/schemas/MachineStats" status: $ref: "#/components/schemas/WorkflowRunStatus" required: - repo - job_id - run_id - os - commit_hash - commit_time - commit_message - branch_name - workflow_name - start_time - end_time - pr_number - python_version - job_trigger_user - author - status responses: "200": description: Successfully received the artifact details content: application/json: schema: type: object properties: message: type: string "400": description: Invalid request "500": description: Internal server error /gitcommit: get: summary: Retrieve CI data for a given commit description: Returns all runs, jobs, job results, and storage files associated with a given commit. x-excluded: true tags: - ComfyUI CI parameters: - in: query name: commitId required: false schema: type: string description: The ID of the commit to fetch data for. - in: query name: operatingSystem required: false schema: type: string description: The operating system to filter the CI data by. - in: query name: workflowName required: false schema: type: string description: The name of the workflow to filter the CI data by. - in: query name: branch required: false schema: type: string description: The branch of the gitcommit to filter the CI data by. - in: query name: page required: false schema: type: integer default: 1 description: The page number to retrieve. - in: query name: pageSize required: false schema: type: integer default: 10 description: The number of items to include per page. - in: query name: repoName required: false schema: type: string default: comfyanonymous/ComfyUI description: The repo to filter by. responses: "200": description: An object containing runs, jobs, job results, and storage files content: application/json: schema: type: object properties: jobResults: type: array items: $ref: "#/components/schemas/ActionJobResult" totalNumberOfPages: type: integer "404": description: Commit not found "500": description: Internal server error /gitcommitsummary: get: summary: Retrieve a summary of git commits description: Returns a summary of git commits, including status, start time, and end time. x-excluded: true tags: - ComfyUI CI parameters: - in: query name: repoName required: false schema: type: string default: comfyanonymous/ComfyUI description: The repository name to filter the git commits by. - in: query name: branchName required: false schema: type: string description: The branch name to filter the git commits by. - in: query name: page required: false schema: type: integer default: 1 description: The page number to retrieve. - in: query name: pageSize required: false schema: type: integer default: 10 description: The number of items to include per page. responses: "200": description: Successfully retrieved git commit summaries content: application/json: schema: type: object properties: commitSummaries: type: array items: $ref: "#/components/schemas/GitCommitSummary" totalNumberOfPages: type: integer "500": description: Internal server error content: application/json: schema: type: object properties: message: type: string /workflowresult/{workflowResultId}: get: summary: Retrieve a specific commit by ID operationId: getWorkflowResult x-excluded: true tags: - ComfyUI CI parameters: - in: path name: workflowResultId required: true schema: type: string responses: "200": description: Commit details content: application/json: schema: $ref: "#/components/schemas/ActionJobResult" "404": description: Commit not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /branch: get: summary: Retrieve all distinct branches for a given repo description: Returns all branches for a given repo. x-excluded: true tags: - ComfyUI CI parameters: - in: query name: repo_name required: true schema: type: string default: comfyanonymous/ComfyUI description: The repo to filter by. responses: "200": description: An array of branches content: application/json: schema: type: object properties: branches: type: array items: type: string "404": description: Repo not found "500": description: Internal server error /users/publishers/: get: summary: Retrieve all publishers for a given user operationId: listPublishersForUser tags: - Registry responses: "200": description: A list of publishers content: application/json: schema: type: array items: $ref: "#/components/schemas/Publisher" "400": description: Bad request, invalid input data content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /publishers/{publisherId}/permissions: get: summary: Retrieve permissions the user has for a given publisher operationId: getPermissionOnPublisher tags: - Registry parameters: - in: path name: publisherId required: true schema: type: string responses: "200": description: A list of permissions content: application/json: schema: type: object properties: canEdit: type: boolean "400": description: Bad request, invalid input data content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /publishers/validate: get: summary: Validate if a publisher username is available description: Checks if the publisher username is already taken. operationId: validatePublisher tags: - Registry parameters: - in: query name: username schema: type: string description: The publisher username to validate. required: true responses: "200": description: Username validation result content: application/json: schema: type: object properties: isAvailable: type: boolean description: True if the username is available, false otherwise. "400": description: Invalid input, such as missing username in the query. content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /publishers: post: summary: Create a new publisher operationId: createPublisher security: - BearerAuth: [] tags: - Registry requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/Publisher" responses: "201": description: Publisher created successfully content: application/json: schema: $ref: "#/components/schemas/Publisher" "400": description: Bad request, invalid input data content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "403": description: Forbidden content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" get: summary: Retrieve all publishers operationId: listPublishers tags: - Registry responses: "200": description: A list of publishers content: application/json: schema: type: array items: $ref: "#/components/schemas/Publisher" "400": description: Bad request, invalid input data content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /publishers/{publisherId}: get: summary: Retrieve a publisher by ID operationId: getPublisher tags: - Registry parameters: - in: path name: publisherId required: true schema: type: string responses: "200": description: Publisher retrieved successfully content: application/json: schema: $ref: "#/components/schemas/Publisher" "404": description: Publisher not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" put: summary: Update a publisher operationId: updatePublisher security: - BearerAuth: [] tags: - Registry parameters: - in: path name: publisherId required: true schema: type: string requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/Publisher" responses: "200": description: Publisher updated successfully content: application/json: schema: $ref: "#/components/schemas/Publisher" "400": description: Bad request, invalid input data content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "404": description: Publisher not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" delete: summary: Delete a publisher operationId: deletePublisher security: - BearerAuth: [] tags: - Registry parameters: - in: path name: publisherId required: true schema: type: string responses: "204": description: Publisher deleted successfully "404": description: Publisher not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /publishers/{publisherId}/ban: post: summary: Ban a publisher operationId: BanPublisher tags: - Registry x-excluded: true parameters: - in: path name: publisherId required: true schema: type: string responses: "204": description: Publisher Banned Successfully "401": description: Unauthorized "403": description: Forbidden content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Publisher not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /publishers/{publisherId}/nodes/{nodeId}/claim-my-node: post: summary: Claim nodeId into publisherId for the authenticated publisher description: | This endpoint allows a publisher to claim an unclaimed node that they own the repo, which is identified by the nodeId. The unclaimed node's repository must be owned by the authenticated user. operationId: claimMyNode tags: - Registry security: - BearerAuth: [] parameters: - in: path name: publisherId required: true schema: type: string - in: path name: nodeId required: true schema: type: string requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ClaimMyNodeRequest" responses: "204": description: Node claimed successfully "400": description: Bad request, invalid input data content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "403": description: | Forbidden - various authorization and permission issues Includes: - The authenticated user does not have permission to claim the node - The node is already claimed by another publisher - The GH_TOKEN is invalid - The repository is not owned by the authenticated GitHub user content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "429": description: Too many requests - GitHub API rate limit exceeded 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 - GitHub API is currently unavailable content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /publishers/{publisherId}/nodes/v2: get: summary: Retrieve all nodes operationId: listNodesForPublisherV2 security: - BearerAuth: [] tags: - Registry parameters: - in: path name: publisherId required: true schema: type: string - in: query name: include_banned description: Number of nodes to return per page required: false schema: type: boolean - in: query name: page description: Page number of the nodes list required: false schema: type: integer default: 1 - in: query name: limit description: Number of nodes to return per page required: false schema: type: integer default: 10 responses: "200": description: List of all nodes content: application/json: schema: type: object properties: total: type: integer description: Total number of nodes available nodes: type: array items: $ref: "#/components/schemas/Node" page: type: integer description: Current page number limit: type: integer description: Maximum number of nodes per page totalPages: type: integer description: Total number of pages available "400": description: Bad request, invalid input data. content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /publishers/{publisherId}/nodes: post: summary: Create a new custom node operationId: createNode tags: - Registry security: - BearerAuth: [] parameters: - in: path name: publisherId required: true schema: type: string requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/Node" responses: "201": description: Node created successfully content: application/json: schema: $ref: "#/components/schemas/Node" "400": description: Bad request, invalid input data. content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" get: summary: Retrieve all nodes operationId: listNodesForPublisher security: - BearerAuth: [] tags: - Registry parameters: - in: path name: publisherId required: true schema: type: string - in: query name: include_banned description: Number of nodes to return per page required: false schema: type: boolean responses: "200": description: List of all nodes content: application/json: schema: type: array items: $ref: "#/components/schemas/Node" "400": description: Bad request, invalid input data. content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /publishers/{publisherId}/nodes/{nodeId}: put: summary: Update a specific node operationId: updateNode tags: - Registry security: - BearerAuth: [] parameters: - in: path name: publisherId required: true schema: type: string - in: path name: nodeId required: true schema: type: string requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/Node" responses: "200": description: Node updated successfully content: application/json: schema: $ref: "#/components/schemas/Node" "400": description: Bad request, invalid input data content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "403": description: Forbidden content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Node not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" delete: summary: Delete a specific node operationId: deleteNode tags: - Registry security: - BearerAuth: [] parameters: - in: path name: publisherId required: true schema: type: string - in: path name: nodeId required: true schema: type: string responses: "204": description: Node deleted successfully "404": description: Node not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "403": description: Forbidden content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /publishers/{publisherId}/nodes/{nodeId}/permissions: get: summary: Retrieve permissions the user has for a given publisher operationId: getPermissionOnPublisherNodes tags: - Registry parameters: - in: path name: publisherId required: true schema: type: string - in: path name: nodeId required: true schema: type: string responses: "200": description: A list of permissions content: application/json: schema: type: object properties: canEdit: type: boolean "400": description: Bad request, invalid input data content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /publishers/{publisherId}/nodes/{nodeId}/versions: post: summary: Publish a new version of a node operationId: publishNodeVersion tags: - Registry security: - BearerAuth: [] parameters: - in: path name: publisherId required: true schema: type: string - in: path name: nodeId required: true schema: type: string requestBody: required: true content: application/json: schema: type: object properties: personal_access_token: type: string node_version: $ref: "#/components/schemas/NodeVersion" node: $ref: "#/components/schemas/Node" required: - node - node_version - personal_access_token responses: "201": description: New version published successfully content: application/json: schema: type: object properties: signedUrl: type: string description: The signed URL to upload the node version token. node_version: $ref: "#/components/schemas/NodeVersion" "400": description: Bad request, invalid input data. content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "403": description: Forbidden content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /publishers/{publisherId}/nodes/{nodeId}/versions/{versionId}: delete: summary: Unpublish (delete) a specific version of a node operationId: deleteNodeVersion tags: - Registry security: - BearerAuth: [] parameters: - in: path name: publisherId required: true schema: type: string - in: path name: nodeId required: true schema: type: string - in: path name: versionId required: true schema: type: string responses: "204": description: Version unpublished (deleted) successfully "403": description: Version does not belong to the publisher content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Version not found content: application/json: schema: $ref: "#/components/schemas/Error" "500": description: Version not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" put: summary: Update changelog and deprecation status of a node version operationId: updateNodeVersion description: Update only the changelog and deprecated status of a specific version of a node. tags: - Registry security: - BearerAuth: [] parameters: - in: path name: publisherId required: true schema: type: string - in: path name: nodeId required: true schema: type: string - in: path name: versionId required: true schema: type: string requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/NodeVersionUpdateRequest" responses: "200": description: Version updated successfully content: application/json: schema: $ref: "#/components/schemas/NodeVersion" "400": description: Bad request, invalid input data. content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "403": description: Forbidden content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Version not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /publishers/{publisherId}/nodes/{nodeId}/ban: post: summary: Ban a publisher's Node operationId: BanPublisherNode tags: - Registry x-excluded: true parameters: - in: path name: publisherId required: true schema: type: string - in: path name: nodeId required: true schema: type: string responses: "204": description: Node Banned Successfully "401": description: Unauthorized "403": description: Forbidden content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Publisher or Node not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /publishers/{publisherId}/tokens: post: summary: Create a new personal access token operationId: createPersonalAccessToken security: - BearerAuth: [] tags: - Registry parameters: - in: path name: publisherId required: true schema: type: string requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/PersonalAccessToken" responses: "201": description: Token created successfully content: application/json: schema: type: object properties: token: type: string description: The newly created personal access token. "400": description: Bad request, invalid input data. content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "403": description: Forbidden content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" get: summary: Retrieve all personal access tokens for a publisher operationId: listPersonalAccessTokens security: - BearerAuth: [] tags: - Registry x-excluded: true parameters: - in: path name: publisherId required: true schema: type: string responses: "200": description: List of all personal access tokens content: application/json: schema: type: array items: $ref: "#/components/schemas/PersonalAccessToken" "403": description: Forbidden content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: No tokens found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /publishers/{publisherId}/tokens/{tokenId}: delete: summary: Delete a specific personal access token operationId: deletePersonalAccessToken security: - BearerAuth: [] tags: - Registry parameters: - in: path name: publisherId required: true schema: type: string - in: path name: tokenId required: true schema: type: string responses: "204": description: Token deleted successfully "403": description: Forbidden content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Token not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /nodes/search: get: summary: Retrieves a list of nodes description: Returns a paginated list of nodes across all publishers. operationId: searchNodes tags: - Registry parameters: - in: query name: page description: Page number of the nodes list required: false schema: type: integer default: 1 - in: query name: limit description: Number of nodes to return per page required: false schema: type: integer default: 10 - in: query name: search description: Keyword to search the nodes required: false schema: type: string - in: query name: repository_url_search description: Keyword to search the nodes by repository URL required: false schema: type: string - in: query name: comfy_node_search description: Keyword to search the nodes by comfy node name required: false schema: type: string - in: query name: supported_os description: Filter nodes by supported operating systems required: false schema: type: string examples: osIndependent: value: "OS Independent" windows: value: "Microsoft :: Windows" windows10: value: "Microsoft :: Windows :: Windows 10" linux: value: "POSIX :: Linux" ubuntu: value: "POSIX :: Linux :: Ubuntu" macos: value: "MacOS" macosx: value: "MacOS :: MacOS X" - in: query name: supported_accelerator description: Filter nodes by supported accelerator required: false schema: type: string - in: query name: include_banned description: Number of nodes to return per page required: false schema: type: boolean responses: "200": description: A paginated list of nodes content: application/json: schema: type: object properties: total: type: integer description: Total number of nodes available nodes: type: array items: $ref: "#/components/schemas/Node" page: type: integer description: Current page number limit: type: integer description: Maximum number of nodes per page totalPages: type: integer description: Total number of pages available "400": description: Invalid input, object invalid "404": description: Not found "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /nodes/reindex: post: summary: Reindex all nodes for searching. operationId: reindexNodes tags: - Registry x-excluded: true parameters: - in: query name: max_batch description: Maximum number of nodes to send to algolia at a time required: false schema: type: integer responses: "200": description: Reindex completed successfully. "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" /nodes/update-github-stars: post: summary: Update GitHub stars for nodes operationId: updateGithubStars tags: - Registry x-excluded: true parameters: - in: query name: max_batch schema: type: integer default: 100 description: Maximum number of nodes to update in one batch responses: "200": description: Update GithubStars request triggered successfully "400": description: Bad request. content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /nodes: get: summary: Retrieves a list of nodes description: Returns a paginated list of nodes across all publishers. operationId: listAllNodes tags: - Registry parameters: - in: query name: page description: Page number of the nodes list required: false schema: type: integer default: 1 - in: query name: limit description: Number of nodes to return per page required: false schema: type: integer default: 10 - in: query name: supported_os description: Filter nodes by supported operating systems required: false schema: type: string examples: osIndependent: value: "OS Independent" windows: value: "Microsoft :: Windows" windows10: value: "Microsoft :: Windows :: Windows 10" linux: value: "POSIX :: Linux" ubuntu: value: "POSIX :: Linux :: Ubuntu" macos: value: "MacOS" macosx: value: "MacOS :: MacOS X" - in: query name: supported_accelerator description: Filter nodes by supported accelerator required: false schema: type: string - in: query name: include_banned description: Number of nodes to return per page required: false schema: type: boolean - in: query name: timestamp description: Retrieve nodes created or updated after this timestamp (ISO 8601 format) required: false schema: type: string format: date-time - in: query name: latest description: Whether to fetch fresh result from database or use cached one if false required: false schema: type: boolean - in: query name: sort description: Database column to use as ascending ordering. Add `;desc` as suffix on each column for descending sort required: false schema: type: array items: type: string - in: query name: node_id description: node_id to use as filter required: false schema: type: array items: type: string - in: query name: comfyui_version description: Comfy UI version required: false schema: type: string - in: query name: form_factor description: The platform requesting the nodes required: false schema: type: string responses: "200": description: A paginated list of nodes content: application/json: schema: type: object properties: total: type: integer description: Total number of nodes available nodes: type: array items: $ref: "#/components/schemas/Node" page: type: integer description: Current page number limit: type: integer description: Maximum number of nodes per page totalPages: type: integer description: Total number of pages available "400": description: Invalid input, object invalid "404": description: Not found "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /comfy-nodes/{comfyNodeName}/node: get: summary: Retrieve a node by ComfyUI node name description: Returns the node that contains a ComfyUI node with the specified name operationId: getNodeByComfyNodeName tags: - Registry parameters: - in: path name: comfyNodeName required: true description: The name of the ComfyUI node schema: type: string responses: "200": description: Node details content: application/json: schema: $ref: "#/components/schemas/Node" "404": description: No node found containing the specified ComfyUI node name content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /nodes/{nodeId}: get: summary: Retrieve a specific node by ID description: Returns the details of a specific node. operationId: getNode tags: - Registry parameters: - in: path name: nodeId required: true schema: type: string - in: query name: include_translations description: Whether to include the translation or not schema: type: boolean responses: "200": description: Node details content: application/json: schema: $ref: "#/components/schemas/Node" "302": description: Redirect to node with normalized name match headers: Location: description: URL of the node with the correct ID schema: type: string "403": description: Forbidden content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Node not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /nodes/{nodeId}/reviews: post: summary: Add review to a specific version of a node operationId: postNodeReview tags: - Registry parameters: - in: path name: nodeId required: true schema: type: string - in: query name: star description: number of star given to the node version required: true schema: type: integer responses: "200": description: Detailed information about a specific node content: application/json: schema: $ref: "#/components/schemas/Node" "400": description: Bad Request "404": description: Node version not found content: application/json: schema: $ref: "#/components/schemas/Error" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /nodes/{nodeId}/install: get: summary: Returns a node version to be installed. description: Retrieves the node data for installation, either the latest or a specific version. operationId: installNode tags: - Registry parameters: - in: path name: nodeId required: true description: The unique identifier of the node. schema: type: string - in: query name: version required: false description: Specific version of the node to retrieve. If omitted, the latest version is returned. schema: type: string pattern: '^\d+\.\d+\.\d+$' responses: "200": description: Node data returned successfully. content: application/json: schema: $ref: "#/components/schemas/NodeVersion" "400": description: Invalid input, such as a bad version format. content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "403": description: Forbidden content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Node not found. content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /nodes/{nodeId}/translations: post: summary: Create Node Translations operationId: CreateNodeTranslations tags: - Registry parameters: - in: path name: nodeId required: true description: The unique identifier of the node. schema: type: string requestBody: required: true content: application/json: schema: type: object properties: data: type: object additionalProperties: type: object additionalProperties: true responses: "201": description: Detailed information about a specific node "400": description: Bad Request "404": description: Node version not found content: application/json: schema: $ref: "#/components/schemas/Error" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /nodes/{nodeId}/versions: get: summary: List all versions of a node operationId: listNodeVersions tags: - Registry parameters: - in: path name: nodeId required: true schema: type: string - in: query name: statuses required: false schema: type: array items: $ref: "#/components/schemas/NodeVersionStatus" # parameter to include status_reason, default to false - in: query name: include_status_reason required: false schema: type: boolean default: false responses: "200": description: List of all node versions content: application/json: schema: type: array items: $ref: "#/components/schemas/NodeVersion" "403": description: Node banned content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Node not found content: application/json: schema: $ref: "#/components/schemas/Error" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /nodes/{nodeId}/versions/{versionId}: get: summary: Retrieve a specific version of a node operationId: getNodeVersion tags: - Registry parameters: - in: path name: nodeId required: true schema: type: string - in: path name: versionId description: The version of the node. (Not a UUID). required: true schema: type: string responses: "200": description: Detailed information about a specific node version content: application/json: schema: $ref: "#/components/schemas/NodeVersion" "404": description: Node version not found content: application/json: schema: $ref: "#/components/schemas/Error" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /bulk/nodes/versions: post: summary: Retrieve multiple node versions in a single request operationId: getBulkNodeVersions tags: - Registry requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/BulkNodeVersionsRequest" responses: "200": description: Successfully retrieved node versions content: application/json: schema: $ref: "#/components/schemas/BulkNodeVersionsResponse" "400": description: Bad request, invalid input content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /versions: get: summary: List all node versions given some filters. operationId: listAllNodeVersions tags: - Registry parameters: - in: query name: nodeId required: false schema: type: string - in: query name: statuses required: false style: form explode: true schema: type: array items: $ref: "#/components/schemas/NodeVersionStatus" # parameter to include status_reason, default to false - in: query name: include_status_reason required: false schema: type: boolean default: false - in: query name: page required: false schema: type: integer default: 1 description: The page number to retrieve. - in: query name: pageSize required: false schema: type: integer default: 10 description: The number of items to include per page. - in: query name: status_reason required: false schema: type: string description: search for status_reason, case insensitive responses: "200": description: List of all node versions content: application/json: schema: type: object properties: total: type: integer description: Total number of node versions available versions: type: array items: $ref: "#/components/schemas/NodeVersion" page: type: integer description: Current page number pageSize: type: integer description: Maximum number of node versions per page. Maximum is 100. totalPages: type: integer description: Total number of pages available "400": description: Invalid input, object invalid content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "403": description: Node banned content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /admin/nodes: post: summary: Create a new custom node using admin priviledge operationId: adminCreateNode x-excluded: true tags: - Registry security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/Node" responses: "201": description: Node created successfully content: application/json: schema: $ref: "#/components/schemas/Node" "400": description: Bad request, invalid input data. content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "409": description: Duplicate error. content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /admin/nodes/{nodeId}: put: summary: Admin Update Node operationId: adminUpdateNode description: Only admins can update a node with admin privileges. x-excluded: true tags: - Registry security: - BearerAuth: [] parameters: - in: path name: nodeId required: true schema: type: string requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/Node" responses: "200": description: Node updated successfully content: application/json: schema: $ref: "#/components/schemas/Node" "400": description: Bad request, invalid input data. content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "403": description: Forbidden content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Node not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /admin/nodes/{nodeId}/versions/{versionNumber}: put: summary: Admin Update Node Version Status operationId: adminUpdateNodeVersion description: Only admins can approve a node version. x-excluded: true tags: - Registry security: - BearerAuth: [] parameters: - in: path name: nodeId required: true schema: type: string - in: path name: versionNumber required: true schema: type: string requestBody: required: true content: application/json: schema: type: object properties: status: $ref: "#/components/schemas/NodeVersionStatus" status_reason: type: string description: The reason for the status change. supported_comfyui_frontend_version: type: string description: Supported versions of ComfyUI frontend supported_comfyui_version: type: string description: Supported versions of ComfyUI supported_os: type: array items: type: string description: List of operating systems that this node supports supported_accelerators: type: array items: type: string description: List of accelerators (e.g. CUDA, DirectML, ROCm) that this node supports responses: "200": description: Version updated successfully content: application/json: schema: $ref: "#/components/schemas/NodeVersion" "400": description: Bad request, invalid input data. content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "403": description: Forbidden content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Version not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /customers/admin/coupons: get: summary: List all coupons operationId: listCoupons description: Retrieves a list of all coupons from Stripe. Only admins can list coupons. x-excluded: true tags: - Admin - API Nodes security: - BearerAuth: [] parameters: - name: limit in: query required: false schema: type: integer minimum: 1 maximum: 100 default: 10 description: Number of coupons to return responses: "200": description: List of coupons retrieved successfully content: application/json: schema: type: object properties: coupons: type: array items: $ref: "#/components/schemas/CouponResponse" has_more: type: boolean description: Whether there are more results available required: - coupons "401": description: Unauthorized "403": description: Forbidden - Admin access required content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" post: summary: Create a new Stripe coupon operationId: createCoupon description: Creates a new coupon in Stripe. Only admins can create coupons. x-excluded: true tags: - Admin - API Nodes security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateCouponRequest" responses: "201": description: Coupon created successfully content: application/json: schema: $ref: "#/components/schemas/CouponResponse" "400": description: Bad request content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "403": description: Forbidden - Admin access required content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /customers/admin/coupons/{coupon_id}: get: summary: Get a specific coupon operationId: getCoupon description: Retrieves details of a specific coupon from Stripe. Only admins can view coupons. x-excluded: true tags: - Admin - API Nodes security: - BearerAuth: [] parameters: - name: coupon_id in: path required: true schema: type: string description: The Stripe coupon ID responses: "200": description: Coupon retrieved successfully content: application/json: schema: $ref: "#/components/schemas/CouponResponse" "401": description: Unauthorized "403": description: Forbidden - Admin access required content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Coupon not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" patch: summary: Update a coupon operationId: updateCoupon description: Updates a coupon in Stripe. Only admins can update coupons. x-excluded: true tags: - Admin - API Nodes security: - BearerAuth: [] parameters: - name: coupon_id in: path required: true schema: type: string description: The Stripe coupon ID requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateCouponRequest" responses: "200": description: Coupon updated successfully content: application/json: schema: $ref: "#/components/schemas/CouponResponse" "400": description: Bad request content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "403": description: Forbidden - Admin access required content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Coupon not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" delete: summary: Delete a coupon operationId: deleteCoupon description: Deletes a coupon in Stripe. Only admins can delete coupons. x-excluded: true tags: - Admin - API Nodes security: - BearerAuth: [] parameters: - name: coupon_id in: path required: true schema: type: string description: The Stripe coupon ID responses: "200": description: Coupon deleted successfully content: application/json: schema: type: object properties: message: type: string description: Success message coupon_id: type: string description: The deleted coupon ID required: - message - coupon_id "401": description: Unauthorized "403": description: Forbidden - Admin access required content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Coupon not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /customers/admin/promo-codes: get: summary: List all promotional codes operationId: listPromoCodes description: Retrieves a list of all promotional codes from Stripe. Only admins can list promo codes. x-excluded: true tags: - Admin - API Nodes security: - BearerAuth: [] parameters: - name: active in: query required: false schema: type: boolean description: Filter by active status - name: limit in: query required: false schema: type: integer minimum: 1 maximum: 100 default: 10 description: Number of promo codes to return responses: "200": description: List of promo codes retrieved successfully content: application/json: schema: type: object properties: promo_codes: type: array items: $ref: "#/components/schemas/PromoCodeResponse" has_more: type: boolean description: Whether there are more results available required: - promo_codes "401": description: Unauthorized "403": description: Forbidden - Admin access required content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" post: summary: Generate a new Stripe promotional code operationId: createPromoCode description: Creates a new unique promotional code in Stripe for the specified coupon. Only admins can generate promo codes. x-excluded: true tags: - Admin - API Nodes security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreatePromoCodeRequest" responses: "201": description: Promo code created successfully content: application/json: schema: $ref: "#/components/schemas/PromoCodeResponse" "400": description: Bad request content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "403": description: Forbidden - Admin access required content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /customers/admin/promo-codes/{promo_code_id}: get: summary: Get a specific promotional code operationId: getPromoCode description: Retrieves details of a specific promotional code from Stripe. Only admins can view promo codes. x-excluded: true tags: - Admin - API Nodes security: - BearerAuth: [] parameters: - name: promo_code_id in: path required: true schema: type: string description: The Stripe promotion code ID responses: "200": description: Promo code retrieved successfully content: application/json: schema: $ref: "#/components/schemas/PromoCodeResponse" "401": description: Unauthorized "403": description: Forbidden - Admin access required content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Promo code not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" patch: summary: Update a promotional code operationId: updatePromoCode description: Updates a promotional code in Stripe. Only admins can update promo codes. x-excluded: true tags: - Admin - API Nodes security: - BearerAuth: [] parameters: - name: promo_code_id in: path required: true schema: type: string description: The Stripe promotion code ID requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdatePromoCodeRequest" responses: "200": description: Promo code updated successfully content: application/json: schema: $ref: "#/components/schemas/PromoCodeResponse" "400": description: Bad request content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "403": description: Forbidden - Admin access required content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Promo code not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" delete: summary: Deactivate a promotional code operationId: deletePromoCode description: Deactivates a promotional code in Stripe. Only admins can deactivate promo codes. x-excluded: true tags: - Admin - API Nodes security: - BearerAuth: [] parameters: - name: promo_code_id in: path required: true schema: type: string description: The Stripe promotion code ID responses: "200": description: Promo code deactivated successfully content: application/json: schema: type: object properties: message: type: string description: Success message promo_code_id: type: string description: The deactivated promo code ID required: - message - promo_code_id "401": description: Unauthorized "403": description: Forbidden - Admin access required content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Promo code not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /releases: post: summary: Process Github release webhook operationId: processReleaseWebhook description: Webhook endpoint to process Github release events and generate release notes tags: - Releases x-excluded: true parameters: - name: X-GitHub-Event in: header required: true schema: type: string enum: [release] description: The name of the event that triggered the delivery - name: X-GitHub-Delivery in: header required: true schema: type: string format: uuid description: A globally unique identifier (GUID) to identify the event - name: X-GitHub-Hook-ID in: header required: true schema: type: string description: The unique identifier of the webhook - name: X-Hub-Signature-256 in: header required: false schema: type: string description: HMAC hex digest of the request body using SHA-256 hash function - name: X-GitHub-Hook-Installation-Target-Type in: header required: false schema: type: string description: The type of resource where the webhook was created - name: X-GitHub-Hook-Installation-Target-ID in: header required: false schema: type: string description: The unique identifier of the resource where the webhook was created requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/GithubReleaseWebhook" responses: "200": description: Webhook processed successfully "400": description: Bad request content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "422": description: Validation failed or endpoint has been spammed content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" get: summary: Get release notes operationId: getReleaseNotes description: Fetch release notes from Strapi with caching tags: - Releases parameters: - in: query name: project required: true schema: type: string enum: [comfyui, comfyui_frontend, desktop, cloud] description: The project to get release notes for - in: query name: current_version required: false schema: type: string description: The current version to filter release notes - in: query name: locale required: false schema: type: string enum: [en, es, fr, ja, ko, ru, zh] default: en description: The locale for the release notes - in: query name: form_factor description: The platform requesting the release notes required: false schema: type: string responses: "200": description: Release notes retrieved successfully content: application/json: schema: type: array items: $ref: "#/components/schemas/ReleaseNote" "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" /security-scan: get: summary: Security Scan operationId: securityScan description: Pull all pending node versions and conduct security scans. tags: - Registry x-excluded: true parameters: - in: query name: minAge required: false schema: type: string x-go-type: time.Duration - in: query name: minSecurityScanAge required: false schema: type: string x-go-type: time.Duration - in: query name: maxNodes required: false schema: type: integer responses: "200": description: Scan completed successfully "400": description: Bad request, invalid input data. content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "403": description: Forbidden content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /nodes/{nodeId}/versions/{version}/comfy-nodes: parameters: - in: path name: nodeId required: true schema: type: string - in: path name: version required: true schema: type: string post: summary: create comfy-nodes for certain node operationId: CreateComfyNodes tags: - Registry requestBody: required: true content: application/json: schema: type: object properties: success: type: boolean status: type: string reason: type: string cloud_build_info: $ref: "#/components/schemas/ComfyNodeCloudBuildInfo" nodes: additionalProperties: $ref: "#/components/schemas/ComfyNode" responses: "204": description: Comy Nodes created successfully "401": description: Unauthorized "403": description: Forbidden content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Version not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "409": description: Existing Comfy Nodes exists content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" get: summary: list comfy-nodes for node version operationId: ListComfyNodes tags: - Registry parameters: - in: query name: page required: false schema: type: integer default: 1 description: The page number to retrieve. - in: query name: limit required: false schema: type: integer default: 10 description: The number of items to include per page. responses: "200": description: Comy Nodes obtained successfully content: application/json: schema: type: object properties: comfy_nodes: type: array items: $ref: "#/components/schemas/ComfyNode" totalNumberOfPages: type: integer "401": description: Unauthorized "403": description: Forbidden content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Version not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /nodes/{nodeId}/versions/{version}/comfy-nodes/{comfyNodeName}: get: summary: get specify comfy-node based on its id operationId: GetComfyNode tags: - Registry parameters: - in: path name: nodeId required: true schema: type: string - in: path name: version required: true schema: type: string - in: path name: comfyNodeName required: true schema: type: string responses: "200": description: Comy Nodes created successfully content: application/json: schema: $ref: "#/components/schemas/ComfyNode" "401": description: Unauthorized "403": description: Forbidden content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Version not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" put: summary: Update a specific comfy-node operationId: UpdateComfyNode tags: - Registry parameters: - in: path name: nodeId required: true schema: type: string - in: path name: version required: true schema: type: string - in: path name: comfyNodeName required: true schema: type: string requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ComfyNodeUpdateRequest' responses: '200': description: Comfy Node updated successfully content: application/json: schema: $ref: '#/components/schemas/ComfyNode' '400': description: Bad request, invalid input data content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '401': description: Unauthorized '403': description: Forbidden content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '404': description: ComfyNode not found content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '500': description: Internal server error content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' /comfy-nodes: get: summary: list all comfy-nodes operationId: ListAllComfyNodes tags: - Registry parameters: - in: query name: pageSize required: false schema: type: integer default: 100 - in: query name: page required: false description: Page number (1-based indexing) schema: type: integer default: 1 - in: query name: node_id required: false description: Filter by node ID schema: type: string - in: query name: node_version required: false description: Filter by node version schema: type: string - in: query name: comfy_node_name required: false description: Filter by ComfyUI node name schema: type: string responses: '200': description: OK content: application/json: schema: type: object properties: comfy_nodes: type: array items: $ref: '#/components/schemas/ComfyNode' total: type: integer description: Total number of comfy nodes '400': description: Bad request, invalid input data. content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '401': description: Unauthorized '403': description: Forbidden content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '500': description: Internal server error content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' /comfy-nodes/backfill: post: summary: trigger comfy nodes backfill operationId: ComfyNodesBackfill tags: - Registry x-excluded: true parameters: - in: query name: max_node required: false schema: type: integer default: 10 responses: "204": description: Backfill triggered "400": description: Bad request, invalid input data. content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "403": description: Forbidden content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/dummy: post: summary: Dummy proxy description: Dummy proxy endpoint that returns a simple string operationId: dummyProxy x-excluded: true tags: - API Nodes requestBody: content: application/json: schema: type: object properties: message: type: string responses: "200": description: Reindex completed successfully. /proxy/minimax/video_generation: post: summary: Proxy request to Minimax for video generation description: Forwards video generation requests to Minimax's API and returns the task ID for asynchronous processing. operationId: minimaxVideoGeneration x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MinimaxVideoGenerationRequest" responses: "200": description: Successful response from Minimax proxy content: application/json: schema: $ref: "#/components/schemas/MinimaxVideoGenerationResponse" "400": description: Bad Request (invalid input to proxy) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "402": description: Payment Required "429": description: Rate limit exceeded (either from proxy or Minimax) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal Server Error (proxy or upstream issue) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "502": description: Bad Gateway (error communicating with Minimax) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "504": description: Gateway Timeout (Minimax took too long to respond) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/minimax/query/video_generation: get: summary: Query status of a Minimax video generation task description: Proxies a request to Minimax to check the status of a video generation task operationId: getMinimaxVideoGeneration x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] parameters: - name: task_id in: query description: The task ID to be queried required: true schema: type: string responses: "200": description: Successful response with task status content: application/json: schema: $ref: "#/components/schemas/MinimaxTaskResultResponse" "400": description: Bad Request (invalid input to proxy) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "402": description: Payment Required "429": description: Rate limit exceeded (either from proxy or Minimax) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal Server Error (proxy or upstream issue) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "502": description: Bad Gateway (error communicating with Minimax) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "504": description: Gateway Timeout (Minimax took too long to respond) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/minimax/files/retrieve: post: summary: Retrieve download URL for a Minimax file description: Proxies a request to Minimax to get the download URL for a file operationId: retrieveMinimaxFile x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] parameters: - in: query name: file_id required: true schema: type: integer description: Unique identifier for the file, obtained from the generation response responses: "200": description: Successful response with file download URL content: application/json: schema: $ref: "#/components/schemas/MinimaxFileRetrieveResponse" "400": description: Bad Request (invalid input to proxy) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "402": description: Payment Required "429": description: Rate limit exceeded (either from proxy or Minimax) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal Server Error (proxy or upstream issue) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "502": description: Bad Gateway (error communicating with Minimax) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "504": description: Gateway Timeout (Minimax took too long to respond) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/ideogram/generate: post: summary: Proxy request to Ideogram for image generation description: Forwards image generation requests to Ideogram's API and returns the results. operationId: ideogramGenerate x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/IdeogramGenerateRequest" responses: "200": description: Successful response from Ideogram proxy content: application/json: schema: $ref: "#/components/schemas/IdeogramGenerateResponse" "400": description: Bad Request (invalid input to proxy) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "402": description: Payment Required "429": description: Rate limit exceeded (either from proxy or Ideogram) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal Server Error (proxy or upstream issue) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "502": description: Bad Gateway (error communicating with Ideogram) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "504": description: Gateway Timeout (Ideogram took too long to respond) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/ideogram/ideogram-v3/generate: post: summary: Proxy request to Ideogram for image generation description: Forwards image generation requests to Ideogram's API and returns the results. operationId: ideogramV3Generate x-excluded: true tags: - API Nodes - Released requestBody: description: Parameters for Ideogram V3 image generation required: true content: application/json: schema: $ref: "#/components/schemas/IdeogramV3Request" responses: "200": description: Successful response from Ideogram proxy content: application/json: schema: $ref: "#/components/schemas/IdeogramGenerateResponse" "500": description: Internal Server Error (proxy or upstream issue) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/ideogram/ideogram-v3/edit: post: summary: Proxy request to Ideogram for image editing description: Forwards image editing requests to Ideogram's API and returns the results. operationId: ideogramV3Edit x-excluded: true tags: - API Nodes - Released requestBody: description: Parameters for Ideogram V3 image editing required: true content: multipart/form-data: schema: $ref: "#/components/schemas/IdeogramV3EditRequest" responses: "200": description: Successful response from Ideogram proxy content: application/json: schema: $ref: "#/components/schemas/IdeogramGenerateResponse" "400": description: Bad Request (invalid input to proxy) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "422": description: Prompt or Initial Image failed the safety checks. content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "429": description: Rate limit exceeded (either from proxy or Ideogram) "500": description: Internal Server Error (proxy or upstream issue) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/ideogram/ideogram-v3/remix: post: summary: Remix an image using a prompt operationId: ideogramV3Remix x-excluded: true tags: - API Nodes - Released requestBody: required: true content: multipart/form-data: schema: $ref: "#/components/schemas/IdeogramV3RemixRequest" responses: "200": description: Remix generated successfully content: application/json: schema: $ref: "#/components/schemas/IdeogramV3IdeogramResponse" "400": description: Bad Request "403": description: Forbidden "422": description: Unprocessable Entity "429": description: Too Many Requests parameters: [] /proxy/ideogram/ideogram-v3/reframe: post: summary: Reframe an image to a chosen resolution operationId: ideogramV3Reframe x-excluded: true tags: - API Nodes - Released requestBody: required: true content: multipart/form-data: schema: $ref: "#/components/schemas/IdeogramV3ReframeRequest" responses: "200": description: Reframed image successfully returned content: application/json: schema: $ref: "#/components/schemas/IdeogramV3IdeogramResponse" "400": description: Bad Request "401": description: Unauthorized "422": description: Unprocessable Entity "429": description: Too Many Requests parameters: [] /proxy/ideogram/ideogram-v3/replace-background: post: summary: Replace background of an image using a prompt operationId: ideogramV3ReplaceBackground tags: - API Nodes - Released x-excluded: true requestBody: required: true content: multipart/form-data: schema: $ref: "#/components/schemas/IdeogramV3ReplaceBackgroundRequest" responses: "200": description: Background replaced successfully content: application/json: schema: $ref: "#/components/schemas/IdeogramV3IdeogramResponse" "400": description: Bad Request "401": description: Unauthorized "422": description: Unprocessable Entity "429": description: Too Many Requests parameters: [] /proxy/kling/v1/account/costs: get: summary: KlingAI Query Resource Package Information operationId: klingQueryResourcePackages x-excluded: true tags: - API Nodes - Released parameters: - name: start_time in: query required: true schema: type: integer description: Start time for the query, Unix timestamp in ms - name: end_time in: query required: true schema: type: integer description: End time for the query, Unix timestamp in ms - name: resource_pack_name in: query required: false schema: type: string description: Resource package name for precise querying of a specific package responses: "200": description: Successful response (Request successful) content: application/json: schema: $ref: "#/components/schemas/KlingResourcePackageResponse" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "403": description: Unauthorized access to requested resource content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "404": description: Resource not found content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "429": description: Account exception or Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "503": description: Service temporarily unavailable content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "504": description: Server timeout content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" /proxy/kling/v1/videos/text2video: post: summary: KlingAI Create Video from Text operationId: klingCreateVideoFromText x-excluded: true tags: - API Nodes - Released requestBody: description: Create task for generating video from text required: true content: application/json: schema: $ref: "#/components/schemas/KlingText2VideoRequest" responses: "200": description: Successful response (Request successful) content: application/json: schema: $ref: "#/components/schemas/KlingText2VideoResponse" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "403": description: Unauthorized access to requested resource content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "404": description: Resource not found content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "429": description: Account exception or Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "503": description: Service temporarily unavailable content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "504": description: Server timeout content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" get: summary: KlingAI Query Task List operationId: klingText2VideoQueryTaskList x-excluded: true tags: - API Nodes - Released parameters: - name: pageNum in: query description: Page number required: false schema: type: integer default: 1 minimum: 1 maximum: 1000 - name: pageSize in: query description: Data volume per page required: false schema: type: integer default: 30 minimum: 1 maximum: 500 responses: "200": description: Successful response (Request successful) content: application/json: schema: $ref: "#/components/schemas/KlingText2VideoResponse" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "403": description: Unauthorized access to requested resource content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "404": description: Resource not found content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "429": description: Account exception or Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "503": description: Service temporarily unavailable content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "504": description: Server timeout content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" /proxy/kling/v1/videos/text2video/{id}: get: summary: KlingAI Query Single Task operationId: klingText2VideoQuerySingleTask x-excluded: true tags: - API Nodes - Released parameters: - name: id in: path required: true schema: type: string description: Task ID or external_task_id responses: "200": description: Successful response (Request successful) content: application/json: schema: $ref: "#/components/schemas/KlingText2VideoResponse" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "403": description: Unauthorized access to requested resource content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "404": description: Resource not found content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "429": description: Account exception or Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "503": description: Service temporarily unavailable content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "504": description: Server timeout content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" /proxy/kling/v1/videos/image2video: post: summary: KlingAI Create Video from Image operationId: klingCreateVideoFromImage x-excluded: true tags: - API Nodes - Released requestBody: description: Create task for generating video from image required: true content: application/json: schema: $ref: "#/components/schemas/KlingImage2VideoRequest" responses: "200": description: Successful response (Request successful) content: application/json: schema: $ref: "#/components/schemas/KlingImage2VideoResponse" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "403": description: Unauthorized access to requested resource content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "404": description: Resource not found content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "429": description: Account exception or Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "503": description: Service temporarily unavailable content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "504": description: Server timeout content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" get: summary: KlingAI Query Image2Video Task List operationId: klingImage2VideoQueryTaskList x-excluded: true tags: - API Nodes - Released parameters: - name: pageNum in: query description: Page number required: false schema: type: integer default: 1 minimum: 1 maximum: 1000 - name: pageSize in: query description: Data volume per page required: false schema: type: integer default: 30 minimum: 1 maximum: 500 responses: "200": description: Successful response (Request successful) content: application/json: schema: $ref: "#/components/schemas/KlingImage2VideoResponse" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "403": description: Unauthorized access to requested resource content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "404": description: Resource not found content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "429": description: Account exception or Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "503": description: Service temporarily unavailable content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "504": description: Server timeout content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" /proxy/kling/v1/videos/image2video/{id}: get: summary: KlingAI Query Single Image2Video Task operationId: klingImage2VideoQuerySingleTask x-excluded: true tags: - API Nodes - Released parameters: - name: id in: path required: true schema: type: string description: Task ID or external_task_id responses: "200": description: Successful response (Request successful) content: application/json: schema: $ref: "#/components/schemas/KlingImage2VideoResponse" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "403": description: Unauthorized access to requested resource content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "404": description: Resource not found content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "429": description: Account exception or Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "503": description: Service temporarily unavailable content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "504": description: Server timeout content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" /proxy/kling/v1/videos/video-extend: post: summary: KlingAI Extend Video Duration operationId: klingExtendVideo x-excluded: true tags: - API Nodes - Released requestBody: description: Create task for extending video duration required: true content: application/json: schema: $ref: "#/components/schemas/KlingVideoExtendRequest" responses: "200": description: Successful response (Request successful) content: application/json: schema: $ref: "#/components/schemas/KlingVideoExtendResponse" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "403": description: Unauthorized access to requested resource content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "404": description: Resource not found content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "429": description: Account exception or Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "503": description: Service temporarily unavailable content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "504": description: Server timeout content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" get: summary: KlingAI Query Video-Extend Task List operationId: klingVideoExtendQueryTaskList x-excluded: true tags: - API Nodes - Released parameters: - name: pageNum in: query description: Page number required: false schema: type: integer default: 1 minimum: 1 maximum: 1000 - name: pageSize in: query description: Data volume per page required: false schema: type: integer default: 30 minimum: 1 maximum: 500 responses: "200": description: Successful response (Request successful) content: application/json: schema: $ref: "#/components/schemas/KlingVideoExtendResponse" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "403": description: Unauthorized access to requested resource content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "404": description: Resource not found content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "429": description: Account exception or Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "503": description: Service temporarily unavailable content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "504": description: Server timeout content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" /proxy/kling/v1/videos/video-extend/{id}: get: summary: KlingAI Query Single Video-Extend Task operationId: klingVideoExtendQuerySingleTask x-excluded: true tags: - API Nodes - Released parameters: - name: id in: path required: true schema: type: string description: Task ID responses: "200": description: Successful response (Request successful) content: application/json: schema: $ref: "#/components/schemas/KlingVideoExtendResponse" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "403": description: Unauthorized access to requested resource content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "404": description: Resource not found content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "429": description: Account exception or Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "503": description: Service temporarily unavailable content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "504": description: Server timeout content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" /proxy/kling/v1/videos/lip-sync: post: summary: KlingAI Create Lip-Sync Video operationId: klingCreateLipSyncVideo x-excluded: true tags: - API Nodes - Released requestBody: description: Create task for generating lip-sync video required: true content: application/json: schema: $ref: "#/components/schemas/KlingLipSyncRequest" responses: "200": description: Successful response (Request successful) content: application/json: schema: $ref: "#/components/schemas/KlingLipSyncResponse" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "403": description: Unauthorized access to requested resource content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "404": description: Resource not found content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "429": description: Account exception or Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "503": description: Service temporarily unavailable content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "504": description: Server timeout content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" get: summary: KlingAI Query Lip-Sync Task List operationId: klingLipSyncQueryTaskList x-excluded: true tags: - API Nodes - Released parameters: - name: pageNum in: query description: Page number required: false schema: type: integer default: 1 minimum: 1 maximum: 1000 - name: pageSize in: query description: Data volume per page required: false schema: type: integer default: 30 minimum: 1 maximum: 500 responses: "200": description: Successful response (Request successful) content: application/json: schema: $ref: "#/components/schemas/KlingLipSyncResponse" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "403": description: Unauthorized access to requested resource content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "404": description: Resource not found content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "429": description: Account exception or Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "503": description: Service temporarily unavailable content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "504": description: Server timeout content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" /proxy/kling/v1/videos/lip-sync/{id}: get: summary: KlingAI Query Single Lip-Sync Task operationId: klingLipSyncQuerySingleTask x-excluded: true tags: - API Nodes - Released parameters: - name: id in: path required: true schema: type: string description: Task ID or external_task_id responses: "200": description: Successful response (Request successful) content: application/json: schema: $ref: "#/components/schemas/KlingLipSyncResponse" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "403": description: Unauthorized access to requested resource content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "404": description: Resource not found content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "429": description: Account exception or Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "503": description: Service temporarily unavailable content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "504": description: Server timeout content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" /proxy/kling/v1/videos/effects: post: summary: KlingAI Create Video Effects Task operationId: klingCreateVideoEffects x-excluded: true tags: - API Nodes - Released requestBody: description: Create task for generating video with effects required: true content: application/json: schema: $ref: "#/components/schemas/KlingVideoEffectsRequest" responses: "200": description: Successful response (Request successful) content: application/json: schema: $ref: "#/components/schemas/KlingVideoEffectsResponse" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "403": description: Unauthorized access to requested resource content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "404": description: Resource not found content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "429": description: Account exception or Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "503": description: Service temporarily unavailable content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "504": description: Server timeout content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" get: summary: KlingAI Query Video Effects Task List operationId: klingVideoEffectsQueryTaskList x-excluded: true tags: - API Nodes - Released parameters: - name: pageNum in: query description: Page number required: false schema: type: integer default: 1 minimum: 1 maximum: 1000 - name: pageSize in: query description: Data volume per page required: false schema: type: integer default: 30 minimum: 1 maximum: 500 responses: "200": description: Successful response (Request successful) content: application/json: schema: $ref: "#/components/schemas/KlingVideoEffectsResponse" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "403": description: Unauthorized access to requested resource content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "404": description: Resource not found content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "429": description: Account exception or Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "503": description: Service temporarily unavailable content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "504": description: Server timeout content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" /proxy/kling/v1/videos/effects/{id}: get: summary: KlingAI Query Single Video Effects Task operationId: klingVideoEffectsQuerySingleTask x-excluded: true tags: - API Nodes - Released parameters: - name: id in: path required: true schema: type: string description: Task ID or external_task_id responses: "200": description: Successful response (Request successful) content: application/json: schema: $ref: "#/components/schemas/KlingVideoEffectsResponse" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "403": description: Unauthorized access to requested resource content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "404": description: Resource not found content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "429": description: Account exception or Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "503": description: Service temporarily unavailable content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "504": description: Server timeout content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" /proxy/kling/v1/videos/motion-control: post: summary: KlingAI Create Motion Control Task operationId: klingCreateMotionControl x-excluded: true tags: - API Nodes - Released requestBody: description: Create task for generating motion control video required: true content: application/json: schema: $ref: "#/components/schemas/KlingMotionControlRequest" responses: "200": description: Successful response (Request successful) content: application/json: schema: $ref: "#/components/schemas/KlingMotionControlResponse" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "403": description: Unauthorized access to requested resource content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "404": description: Resource not found content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "429": description: Account exception or Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "503": description: Service temporarily unavailable content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "504": description: Server timeout content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" /proxy/kling/v1/videos/motion-control/{id}: get: summary: KlingAI Query Single Motion Control Task operationId: klingMotionControlQuerySingleTask x-excluded: true tags: - API Nodes - Released parameters: - name: id in: path required: true schema: type: string description: Task ID or external_task_id responses: "200": description: Successful response (Request successful) content: application/json: schema: $ref: "#/components/schemas/KlingMotionControlResponse" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "403": description: Unauthorized access to requested resource content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "404": description: Resource not found content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "429": description: Account exception or Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "503": description: Service temporarily unavailable content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "504": description: Server timeout content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" /proxy/kling/v1/videos/omni-video: post: summary: KlingAI Create Omni-Video Task operationId: klingCreateOmniVideo x-excluded: true tags: - API Nodes - Released requestBody: description: Create task for generating omni-video required: true content: application/json: schema: $ref: "#/components/schemas/KlingOmniVideoRequest" responses: "200": description: Successful response (Request successful) content: application/json: schema: $ref: "#/components/schemas/KlingOmniVideoResponse" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "403": description: Unauthorized access to requested resource content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "404": description: Resource not found content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "429": description: Account exception or Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "503": description: Service temporarily unavailable content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "504": description: Server timeout content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" /proxy/kling/v1/videos/omni-video/{id}: get: summary: KlingAI Query Single Omni-Video Task operationId: klingOmniVideoQuerySingleTask x-excluded: true tags: - API Nodes - Released parameters: - name: id in: path required: true schema: type: string description: Task ID or External Task ID. Can query by either task_id (generated by system) or external_task_id (customized task ID) responses: "200": description: Successful response (Request successful) content: application/json: schema: $ref: "#/components/schemas/KlingOmniVideoResponse" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "403": description: Unauthorized access to requested resource content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "404": description: Resource not found content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "429": description: Account exception or Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "503": description: Service temporarily unavailable content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "504": description: Server timeout content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" /proxy/kling/v1/videos/avatar/image2video: post: summary: KlingAI Create Avatar Video operationId: klingCreateAvatarVideo x-excluded: true tags: - API Nodes - Released requestBody: description: Create task for generating avatar video from image and audio required: true content: application/json: schema: $ref: "#/components/schemas/KlingAvatarRequest" responses: "200": description: Successful response (Request successful) content: application/json: schema: $ref: "#/components/schemas/KlingAvatarResponse" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "403": description: Unauthorized access to requested resource content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "404": description: Resource not found content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "429": description: Account exception or Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "503": description: Service temporarily unavailable content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "504": description: Server timeout content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" /proxy/kling/v1/videos/avatar/image2video/{id}: get: summary: KlingAI Query Avatar Task operationId: klingAvatarQueryTask x-excluded: true tags: - API Nodes - Released parameters: - name: id in: path required: true schema: type: string description: Task ID or external_task_id responses: "200": description: Successful response (Request successful) content: application/json: schema: $ref: "#/components/schemas/KlingAvatarResponse" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "403": description: Unauthorized access to requested resource content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "404": description: Resource not found content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "429": description: Account exception or Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "503": description: Service temporarily unavailable content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "504": description: Server timeout content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" /proxy/kling/v1/images/generations: post: summary: KlingAI Create Image Generation Task operationId: klingCreateImageGeneration x-excluded: true tags: - API Nodes - Released requestBody: description: Create task for generating images required: true content: application/json: schema: $ref: "#/components/schemas/KlingImageGenerationsRequest" responses: "200": description: Successful response (Request successful) content: application/json: schema: $ref: "#/components/schemas/KlingImageGenerationsResponse" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "403": description: Unauthorized access to requested resource content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "404": description: Resource not found content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "429": description: Account exception or Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "503": description: Service temporarily unavailable content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "504": description: Server timeout content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" get: summary: KlingAI Query Image Generation Task List operationId: klingImageGenerationsQueryTaskList x-excluded: true tags: - API Nodes - Released parameters: - name: pageNum in: query description: Page number required: false schema: type: integer default: 1 minimum: 1 maximum: 1000 - name: pageSize in: query description: Data volume per page required: false schema: type: integer default: 30 minimum: 1 maximum: 500 responses: "200": description: Successful response (Request successful) content: application/json: schema: $ref: "#/components/schemas/KlingImageGenerationsResponse" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "403": description: Unauthorized access to requested resource content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "404": description: Resource not found content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "429": description: Account exception or Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "503": description: Service temporarily unavailable content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "504": description: Server timeout content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" /proxy/kling/v1/images/generations/{id}: get: summary: KlingAI Query Single Image Generation Task operationId: klingImageGenerationsQuerySingleTask x-excluded: true tags: - API Nodes - Released parameters: - name: id in: path required: true schema: type: string description: Task ID responses: "200": description: Successful response (Request successful) content: application/json: schema: $ref: "#/components/schemas/KlingImageGenerationsResponse" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "403": description: Unauthorized access to requested resource content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "404": description: Resource not found content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "429": description: Account exception or Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "503": description: Service temporarily unavailable content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "504": description: Server timeout content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" /proxy/kling/v1/images/omni-image: post: summary: KlingAI Create Omni-Image Task operationId: klingCreateOmniImage x-excluded: true tags: - API Nodes - Released requestBody: description: Create task for generating omni-image required: true content: application/json: schema: $ref: "#/components/schemas/KlingOmniImageRequest" responses: "200": description: Successful response (Request successful) content: application/json: schema: $ref: "#/components/schemas/KlingOmniImageResponse" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "403": description: Unauthorized access to requested resource content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "404": description: Resource not found content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "429": description: Account exception or Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "503": description: Service temporarily unavailable content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "504": description: Server timeout content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" /proxy/kling/v1/images/omni-image/{id}: get: summary: KlingAI Query Single Omni-Image Task operationId: klingOmniImageQuerySingleTask x-excluded: true tags: - API Nodes - Released parameters: - name: id in: path required: true schema: type: string description: Task ID or External Task ID. Can query by either task_id (generated by system) or external_task_id (customized task ID) responses: "200": description: Successful response (Request successful) content: application/json: schema: $ref: "#/components/schemas/KlingOmniImageResponse" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "403": description: Unauthorized access to requested resource content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "404": description: Resource not found content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "429": description: Account exception or Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "503": description: Service temporarily unavailable content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "504": description: Server timeout content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" /proxy/kling/v1/images/kolors-virtual-try-on: post: summary: KlingAI Create Virtual Try-On Task operationId: klingCreateVirtualTryOn x-excluded: true tags: - API Nodes - Released requestBody: description: Create task for virtual try-on of clothing on human images required: true content: application/json: schema: $ref: "#/components/schemas/KlingVirtualTryOnRequest" responses: "200": description: Successful response (Request successful) content: application/json: schema: $ref: "#/components/schemas/KlingVirtualTryOnResponse" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "403": description: Unauthorized access to requested resource content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "404": description: Resource not found content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "429": description: Account exception or Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "503": description: Service temporarily unavailable content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "504": description: Server timeout content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" get: summary: KlingAI Query Virtual Try-On Task List operationId: klingVirtualTryOnQueryTaskList x-excluded: true tags: - API Nodes - Released parameters: - name: pageNum in: query description: Page number required: false schema: type: integer default: 1 minimum: 1 maximum: 1000 - name: pageSize in: query description: Data volume per page required: false schema: type: integer default: 30 minimum: 1 maximum: 500 responses: "200": description: Successful response (Request successful) content: application/json: schema: $ref: "#/components/schemas/KlingVirtualTryOnResponse" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "403": description: Unauthorized access to requested resource content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "404": description: Resource not found content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "429": description: Account exception or Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "503": description: Service temporarily unavailable content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "504": description: Server timeout content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" /proxy/kling/v1/images/kolors-virtual-try-on/{id}: get: summary: KlingAI Query Single Virtual Try-On Task operationId: klingVirtualTryOnQuerySingleTask x-excluded: true tags: - API Nodes - Released parameters: - name: id in: path required: true schema: type: string description: Task ID responses: "200": description: Successful response (Request successful) content: application/json: schema: $ref: "#/components/schemas/KlingVirtualTryOnResponse" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "403": description: Unauthorized access to requested resource content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "404": description: Resource not found content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "429": description: Account exception or Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "503": description: Service temporarily unavailable content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" "504": description: Server timeout content: application/json: schema: $ref: "#/components/schemas/KlingErrorResponse" /proxy/ltx/v1/text-to-video: post: summary: LTX Video Generate Video from Text description: Generate a video from a text prompt using LTX Video AI models operationId: ltxCreateVideoFromText x-excluded: true tags: - API Nodes - Released requestBody: description: Create video from text prompt required: true content: application/json: schema: $ref: "#/components/schemas/LTXText2VideoRequest" responses: "200": description: Video generated successfully content: video/mp4: schema: type: string format: binary default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/ltx/v1/image-to-video: post: summary: LTX Video Generate Video from Image description: Transform a static image into a dynamic video using LTX Video AI models operationId: ltxCreateVideoFromImage x-excluded: true tags: - API Nodes - Released requestBody: description: Create video from image required: true content: application/json: schema: $ref: "#/components/schemas/LTXImage2VideoRequest" responses: "200": description: Video generated successfully content: video/mp4: schema: type: string format: binary default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/bfl/flux-kontext-pro/generate: post: summary: Proxy request to BFL Flux Kontext Pro for image editing description: Forwards image editing requests to BFL's Flux Kontext Pro API and returns the results. operationId: bflFluxKontextProGenerate x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/BFLFluxKontextProGenerateRequest" responses: "200": description: Successful response from BFL Flux Kontext Pro proxy content: application/json: schema: $ref: "#/components/schemas/BFLFluxKontextProGenerateResponse" "400": description: Bad Request (invalid input to proxy) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "402": description: Payment Required "429": description: Rate limit exceeded (either from proxy or BFL) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal Server Error (proxy or upstream issue) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "502": description: Bad Gateway (error communicating with BFL) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "504": description: Gateway Timeout (BFL took too long to respond) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/bfl/flux-kontext-max/generate: post: summary: Proxy request to BFL Flux Kontext Max for image editing description: Forwards image editing requests to BFL's Flux Kontext Max API and returns the results. operationId: bflFluxKontextMaxGenerate x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/BFLFluxKontextMaxGenerateRequest" responses: "200": description: Successful response from BFL Flux Kontext Max proxy content: application/json: schema: $ref: "#/components/schemas/BFLFluxKontextMaxGenerateResponse" "400": description: Bad Request (invalid input to proxy) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "402": description: Payment Required "429": description: Rate limit exceeded (either from proxy or BFL) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal Server Error (proxy or upstream issue) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "502": description: Bad Gateway (error communicating with BFL) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "504": description: Gateway Timeout (BFL took too long to respond) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/bfl/flux-pro-1.1/generate: post: summary: Proxy request to BFL Flux Pro 1.1 for image generation description: Forwards image generation requests to BFL's Flux Pro 1.1 API and returns the results. operationId: bflFluxPro1_1Generate x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/BFLFluxPro1_1GenerateRequest" responses: "200": description: Successful response from BFL Flux Pro proxy content: application/json: schema: $ref: "#/components/schemas/BFLFluxPro1_1GenerateResponse" "400": description: Bad Request (invalid input to proxy) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "402": description: Payment Required "429": description: Rate limit exceeded (either from proxy or BFL) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal Server Error (proxy or upstream issue) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "502": description: Bad Gateway (error communicating with BFL) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "504": description: Gateway Timeout (BFL took too long to respond) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/bfl/flux-pro-1.1-ultra/generate: post: summary: Proxy request to BFL Flux Pro 1.1 Ultra for image generation description: Forwards image generation requests to BFL's Flux Pro 1.1 Ultra API and returns the results. operationId: bflFluxProGenerate x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/BFLFluxProGenerateRequest" responses: "200": description: Successful response from BFL Flux Pro proxy content: application/json: schema: $ref: "#/components/schemas/BFLFluxProGenerateResponse" "400": description: Bad Request (invalid input to proxy) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "402": description: Payment Required "429": description: Rate limit exceeded (either from proxy or BFL) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal Server Error (proxy or upstream issue) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "502": description: Bad Gateway (error communicating with BFL) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "504": description: Gateway Timeout (BFL took too long to respond) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/bfl/flux-2-pro/generate: post: summary: Proxy request to BFL Flux 2 Pro for image generation description: Forwards image generation requests to BFL's Flux 2 Pro API and returns the results. Supports image-to-image generation with up to 5 input images. operationId: bflFlux2ProGenerate x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/BFLFlux2ProGenerateRequest" responses: "200": description: Successful response from BFL Flux 2 Pro proxy content: application/json: schema: $ref: "#/components/schemas/BFLFluxProGenerateResponse" "400": description: Bad Request (invalid input to proxy) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "402": description: Payment Required "429": description: Rate limit exceeded (either from proxy or BFL) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal Server Error (proxy or upstream issue) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "502": description: Bad Gateway (error communicating with BFL) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "504": description: Gateway Timeout (BFL took too long to respond) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/bfl/flux-2-max/generate: post: summary: Proxy request to BFL Flux 2 Max for image generation description: Forwards image generation requests to BFL's Flux 2 Max API and returns the results. Supports image-to-image generation with up to 8 input images. operationId: bflFlux2MaxGenerate x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/BFLFlux2ProGenerateRequest" responses: "200": description: Successful response from BFL Flux 2 Max proxy content: application/json: schema: $ref: "#/components/schemas/BFLFluxProGenerateResponse" "400": description: Bad Request (invalid input to proxy) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "402": description: Payment Required "429": description: Rate limit exceeded (either from proxy or BFL) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal Server Error (proxy or upstream issue) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "502": description: Bad Gateway (error communicating with BFL) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "504": description: Gateway Timeout (BFL took too long to respond) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/bfl/flux-pro-1.0-expand/generate: post: tags: - API Nodes - Released summary: Expand an image by adding pixels on any side. x-excluded: true description: >- Submits an image expansion task that adds the specified number of pixels to any combination of sides (top, bottom, left, right) while maintaining context. operationId: BFLExpand_v1_flux_pro_1_0_expand_post requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/BFLFluxProExpandInputs" responses: "200": description: Successful Response content: application/json: schema: anyOf: - $ref: "#/components/schemas/BFLAsyncResponse" - $ref: "#/components/schemas/BFLAsyncWebhookResponse" title: Response Expand V1 Flux Pro 1 0 Expand Post "422": description: Validation Error content: application/json: schema: $ref: "#/components/schemas/BFLHTTPValidationError" parameters: [] /proxy/bfl/flux-pro-1.0-fill/generate: post: tags: - API Nodes - Released summary: "Generate an image with FLUX.1 Fill [pro] using an input image and mask." x-excluded: true description: >- Submits an image generation task with the FLUX.1 Fill [pro] model using an input image and mask. Mask can be applied to alpha channel or submitted as a separate image. operationId: BFLFill_v1_flux_pro_1_0_fill_post requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/BFLFluxProFillInputs" responses: "200": description: Successful Response content: application/json: schema: anyOf: - $ref: "#/components/schemas/BFLAsyncResponse" - $ref: "#/components/schemas/BFLAsyncWebhookResponse" title: Response Fill V1 Flux Pro 1 0 Fill Post "422": description: Validation Error content: application/json: schema: $ref: "#/components/schemas/BFLHTTPValidationError" parameters: [] /proxy/bfl/flux-pro-1.0-canny/generate: post: tags: - API Nodes - Released x-excluded: true summary: "Generate an image with FLUX.1 Canny [pro] using a control image." description: "Submits an image generation task with FLUX.1 Canny [pro]." operationId: BFLPro_canny_v1_flux_pro_1_0_canny_post requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/BFLCannyInputs" responses: "200": description: Successful Response content: application/json: schema: anyOf: - $ref: "#/components/schemas/BFLAsyncResponse" - $ref: "#/components/schemas/BFLAsyncWebhookResponse" title: Response Pro Canny V1 Flux Pro 1 0 Canny Post "422": description: Validation Error content: application/json: schema: $ref: "#/components/schemas/BFLHTTPValidationError" parameters: [] /proxy/bfl/flux-pro-1.0-depth/generate: post: tags: - API Nodes - Released x-excluded: true summary: "Generate an image with FLUX.1 Depth [pro] using a control image." description: "Submits an image generation task with FLUX.1 Depth [pro]." operationId: BFLPro_depth_v1_flux_pro_1_0_depth_post requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/BFLDepthInputs" responses: "200": description: Successful Response content: application/json: schema: anyOf: - $ref: "#/components/schemas/BFLAsyncResponse" - $ref: "#/components/schemas/BFLAsyncWebhookResponse" title: Response Pro Depth V1 Flux Pro 1 0 Depth Post "422": description: Validation Error content: application/json: schema: $ref: "#/components/schemas/BFLHTTPValidationError" parameters: [] /proxy/luma/generations: post: summary: Create a generation description: Initiate a new generation with the provided prompt operationId: lumaCreateGeneration x-excluded: true tags: - API Nodes - Released requestBody: required: true description: The generation request object content: application/json: schema: $ref: "#/components/schemas/LumaGenerationRequest" examples: default: value: prompt: "A serene lake surrounded by mountains at sunset" aspect_ratio: "16:9" loop: true keyframes: frame0: type: image url: "https://example.com/image.jpg" frame1: type: generation id: "123e4567-e89b-12d3-a456-426614174000" responses: default: description: Error content: application/json: schema: $ref: "#/components/schemas/LumaError" "201": description: Generation created content: application/json: schema: $ref: "#/components/schemas/LumaGeneration" parameters: [] /proxy/luma/generations/{id}: get: summary: Get a generation description: Retrieve details of a specific generation by its ID operationId: lumaGetGeneration x-excluded: true tags: - API Nodes - Released parameters: - name: id in: path required: true schema: type: string description: The ID of the generation responses: default: description: Error content: application/json: schema: $ref: "#/components/schemas/LumaError" "200": description: Generation found content: application/json: schema: $ref: "#/components/schemas/LumaGeneration" /proxy/luma/generations/image: post: summary: Generate an image description: Generate an image with the provided prompt operationId: lumaGenerateImage x-excluded: true tags: - API Nodes - Released requestBody: required: true description: The image generation request object content: application/json: schema: $ref: "#/components/schemas/LumaImageGenerationRequest" responses: default: description: Error content: application/json: schema: $ref: "#/components/schemas/LumaError" "201": description: Image generated content: application/json: schema: $ref: "#/components/schemas/LumaGeneration" parameters: [] /proxy/pixverse/video/text/generate: post: summary: Generate video from text prompt. operationId: PixverseGenerateTextVideo x-excluded: true tags: - API Nodes - Released parameters: - $ref: "#/components/parameters/PixverseAiTraceId" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/PixverseTextVideoRequest" responses: "200": description: Success content: application/json: schema: $ref: "#/components/schemas/PixverseVideoResponse" "400": description: Bad Request (invalid input to proxy) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "402": description: Payment Required "429": description: Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal Server Error (proxy or upstream issue) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/pixverse/video/img/generate: post: summary: Generate video from image. operationId: PixverseGenerateImageVideo x-excluded: true tags: - API Nodes - Released parameters: - $ref: "#/components/parameters/PixverseAiTraceId" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/PixverseImageVideoRequest" responses: "200": description: Success content: application/json: schema: $ref: "#/components/schemas/PixverseVideoResponse" "400": description: Bad Request (invalid input to proxy) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "402": description: Payment Required "429": description: Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal Server Error (proxy or upstream issue) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/pixverse/video/transition/generate: post: summary: Generate transition video between two images. operationId: PixverseGenerateTransitionVideo x-excluded: true tags: - API Nodes - Released parameters: - $ref: "#/components/parameters/PixverseAiTraceId" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/PixverseTransitionVideoRequest" responses: "200": description: Success content: application/json: schema: $ref: "#/components/schemas/PixverseVideoResponse" "400": description: Bad Request (invalid input to proxy) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "402": description: Payment Required "429": description: Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal Server Error (proxy or upstream issue) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/pixverse/image/upload: post: summary: Upload an image to the server. operationId: PixverseUploadImage x-excluded: true tags: - API Nodes - Released parameters: - $ref: "#/components/parameters/PixverseAiTraceId" requestBody: required: true content: multipart/form-data: schema: type: object properties: image: type: string format: binary responses: "200": description: Image uploaded content: application/json: schema: $ref: "#/components/schemas/PixverseImageUploadResponse" "400": description: Bad Request (invalid input to proxy) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "402": description: Payment Required "429": description: Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal Server Error (proxy or upstream issue) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/pixverse/video/result/{id}: get: summary: Get the result of a video generation. operationId: PixverseGetVideoResult x-excluded: true tags: - API Nodes - Released parameters: - $ref: "#/components/parameters/PixverseAiTraceId" - name: id in: path required: true schema: type: integer responses: "200": description: Result fetched content: application/json: schema: $ref: "#/components/schemas/PixverseVideoResultResponse" /webhook/metronome/zero-balance: post: summary: receive alert on remaining balance is 0 operationId: metronomeZeroBalance x-excluded: true tags: - Webhook - Metronome requestBody: required: true content: application/json: schema: type: object required: [id, type, properties] properties: id: type: string description: the id of the webhook type: type: string description: the type of the webhook properties: type: object properties: customer_id: type: string description: the metronome customer id remaining_balance: type: number description: the customer remaining balance responses: "200": description: Webhook processed succesfully content: application/json: schema: $ref: "#/components/schemas/IdeogramGenerateResponse" "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "500": description: Internal Server Error (proxy or upstream issue) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /webhook/stripe/invoice-status: post: summary: Handle Stripe invoice.paid webhook event operationId: StripeInvoiceStatus x-excluded: true tags: - Billing - Stripe requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/StripeEvent" responses: "200": description: Webhook processed successfully "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "500": description: Internal Server Error (proxy or upstream issue) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /webhook/stripe/subscription: post: summary: Handle Stripe subscription webhook events operationId: StripeSubscriptionWebhook x-excluded: true tags: - Billing - Stripe requestBody: required: true content: application/json: schema: type: object description: Generic Stripe webhook event payload responses: "200": description: Webhook processed successfully "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "500": description: Internal Server Error (proxy or upstream issue) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/recraft/image_generation: post: summary: Proxy request to Recraft for image generation description: Forwards image generation requests to Recraft's API and returns the generated images. operationId: recraftImageGeneration x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/RecraftImageGenerationRequest" responses: "200": description: Successful response from Recraft proxy content: application/json: schema: $ref: "#/components/schemas/RecraftImageGenerationResponse" "400": description: Bad Request (invalid input to proxy) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "402": description: Payment Required "429": description: Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal Server Error (proxy or upstream issue) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "502": description: Bad Gateway (error communicating with Recraft) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "504": description: Gateway Timeout (Recraft took too long to respond) content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/recraft/images/vectorize: post: summary: Vectorize an image operationId: recraftVectorize x-excluded: true tags: - API Nodes - Released requestBody: required: true content: multipart/form-data: schema: type: object properties: file: type: string format: binary description: Image file to process required: - file responses: "200": description: Background removed successfully content: application/json: schema: $ref: "#/components/schemas/RecraftImageGenerationResponse" "401": description: Unauthorized - Invalid or missing API token "400": description: Bad request - Invalid parameters or file security: [] /proxy/recraft/images/crispUpscale: post: summary: Upscale an image operationId: recraftCrispUpscale x-excluded: true tags: - API Nodes - Released requestBody: required: true content: multipart/form-data: schema: type: object properties: file: type: string format: binary description: Image file to process required: - file responses: "200": description: Background removed successfully content: application/json: schema: $ref: "#/components/schemas/RecraftImageGenerationResponse" "401": description: Unauthorized - Invalid or missing API token "400": description: Bad request - Invalid parameters or file security: [] /proxy/recraft/images/removeBackground: post: summary: Remove background from an image operationId: recraftRemoveBackground x-excluded: true tags: - API Nodes - Released requestBody: required: true content: multipart/form-data: schema: type: object properties: file: type: string format: binary description: Image file to process required: - file responses: "200": description: Background removed successfully content: application/json: schema: type: object properties: image: type: object properties: url: type: string format: uri description: URL of the processed image "401": description: Unauthorized - Invalid or missing API token "400": description: Bad request - Invalid parameters or file security: [] /proxy/recraft/images/imageToImage: post: operationId: RecraftImageToImage x-excluded: true requestBody: content: multipart/form-data: schema: $ref: "#/components/schemas/RecraftImageToImageRequest" responses: "200": content: application/json: schema: $ref: "#/components/schemas/RecraftGenerateImageResponse" description: OK summary: Generate image from image and prompt tags: - API Nodes - Released parameters: [] /proxy/recraft/images/inpaint: post: operationId: RecraftInpaintImage x-excluded: true requestBody: content: multipart/form-data: schema: $ref: "#/components/schemas/RecraftTransformImageWithMaskRequest" responses: "200": content: application/json: schema: $ref: "#/components/schemas/RecraftGenerateImageResponse" description: OK summary: Inpaint Image tags: - API Nodes - Released parameters: [] /proxy/recraft/images/replaceBackground: post: operationId: RecraftReplaceBackground x-excluded: true requestBody: content: multipart/form-data: schema: $ref: "#/components/schemas/RecraftTransformImageWithMaskRequest" responses: "200": content: application/json: schema: $ref: "#/components/schemas/RecraftGenerateImageResponse" description: OK summary: Replace Background tags: - API Nodes - Released parameters: [] /proxy/recraft/images/creativeUpscale: post: operationId: RecraftCreativeUpscale x-excluded: true requestBody: content: multipart/form-data: schema: $ref: "#/components/schemas/RecraftProcessImageRequest" responses: "200": content: application/json: schema: $ref: "#/components/schemas/RecraftProcessImageResponse" description: OK summary: Creative Upscale tags: - API Nodes - Released parameters: [] /proxy/recraft/styles: post: operationId: RecraftCreateStyle x-excluded: true tags: - API Nodes - Released summary: Create Style description: Upload a set of images to create a style reference. requestBody: required: true content: multipart/form-data: schema: $ref: "#/components/schemas/RecraftCreateStyleRequest" responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/RecraftCreateStyleResponse" /proxy/runway/image_to_video: post: summary: Runway Image to Video Generation x-excluded: true tags: - API Nodes - Released description: Converts an image to a video using Runway's API operationId: runwayImageToVideo requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/RunwayImageToVideoRequest" responses: "200": description: Successful response content: application/json: schema: $ref: "#/components/schemas/RunwayImageToVideoResponse" "400": description: Bad request content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "402": description: Payment Required content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/runway/tasks/{task_id}: get: summary: Get Runway Task Status description: Get the status and output of a Runway task operationId: runwayGetTaskStatus x-excluded: true tags: - API Nodes - Released parameters: - name: task_id in: path required: true schema: type: string description: ID of the task to check responses: "200": description: Successful response content: application/json: schema: $ref: "#/components/schemas/RunwayTaskStatusResponse" "400": description: Bad request content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "402": description: Payment Required content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Task not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/runway/text_to_image: post: summary: Runway Text to Image Generation x-excluded: true tags: - API Nodes - Released description: Generates an image from text using Runway's API operationId: runwayTextToImage requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/RunwayTextToImageRequest" responses: "200": description: Successful response content: application/json: schema: $ref: "#/components/schemas/RunwayTextToImageResponse" "400": description: Bad request content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "402": description: Payment Required content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/veo/generate: post: summary: Generate a video from a text prompt and optional image. Deprecated. Use /proxy/veo/{modelId}/generate instead. operationId: veoGenerate x-excluded: true tags: - API Nodes - Released requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/Veo2GenVidRequest" responses: "200": description: Video generation successful content: application/json: schema: $ref: "#/components/schemas/Veo2GenVidResponse" "400": description: Bad request "401": description: Unauthorized "403": description: Forbidden "500": description: Internal server error /proxy/veo/poll: post: summary: Poll the status of a Veo prediction operation. Deprecated. Use /proxy/veo/{modelId}/generate instead. operationId: veoPoll x-excluded: true tags: - API Nodes - Released requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/Veo2GenVidPollRequest" responses: "200": description: Operation status and result content: application/json: schema: $ref: "#/components/schemas/Veo2GenVidPollResponse" "400": description: Bad request "401": description: Unauthorized "404": description: Operation not found "500": description: Internal error /proxy/veo/{modelId}/generate: post: summary: Generate a video from a text prompt and optional image operationId: veoGenerateNew x-excluded: true tags: - API Nodes - Released parameters: - name: modelId in: path required: true schema: type: string description: The ID of the model to use for generation requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/VeoGenVidRequest" responses: "200": description: Video generation successful content: application/json: schema: $ref: "#/components/schemas/VeoGenVidResponse" "400": description: Bad request "401": description: Unauthorized "403": description: Forbidden "500": description: Internal server error /proxy/veo/{modelId}/poll: post: summary: Poll the status of a Veo prediction operation operationId: veoPollNew x-excluded: true tags: - API Nodes - Released parameters: - name: modelId in: path required: true schema: type: string description: The ID of the model to use for generation requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/VeoGenVidPollRequest" responses: "200": description: Operation status and result content: application/json: schema: $ref: "#/components/schemas/VeoGenVidPollResponse" "400": description: Bad request "401": description: Unauthorized "404": description: Operation not found "500": description: Internal error /proxy/openai/v1/responses: post: operationId: createOpenAIResponse tags: - API Nodes - Released x-excluded: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/OpenAICreateResponse" responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/OpenAIResponse" text/event-stream: schema: $ref: "#/components/schemas/OpenAIResponseStreamEvent" /proxy/openai/v1/responses/{id}: get: operationId: getOpenAIResponse tags: - API Nodes - Released x-excluded: true summary: | Retrieves a model response with the given ID. parameters: - in: path name: id required: true schema: type: string example: resp_677efb5139a88190b512bc3fef8e535d description: The ID of the response to retrieve. - in: query name: include schema: type: array items: $ref: "#/components/schemas/Includable" description: | Additional fields to include in the response. See the `include` parameter for Response creation above for more information. responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/OpenAIResponse" /proxy/openai/images/generations: post: summary: Generate an image using OpenAI's models operationId: openAIGenerateImage x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/OpenAIImageGenerationRequest" responses: "200": description: Image generated successfully content: application/json: schema: $ref: "#/components/schemas/OpenAIImageGenerationResponse" "400": description: Bad request content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "402": description: Payment Required "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/openai/images/edits: post: summary: Edit an image using OpenAI's DALL-E model operationId: openAIEditImage x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: multipart/form-data: schema: $ref: "#/components/schemas/OpenAIImageEditRequest" responses: "200": description: Image edited successfully content: application/json: schema: $ref: "#/components/schemas/OpenAIImageGenerationResponse" "400": description: Bad request content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "402": description: Payment Required "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/openai/v1/videos: post: summary: Create a video using OpenAI's Sora model operationId: openAICreateVideo x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: multipart/form-data: schema: $ref: "#/components/schemas/OpenAIVideoCreateRequest" responses: "200": description: Video generation job created successfully content: application/json: schema: $ref: "#/components/schemas/OpenAIVideoJob" "400": description: Bad request content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "402": description: Payment Required "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/openai/v1/videos/{video_id}: get: summary: Retrieve a video operationId: openAIGetVideo x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] parameters: - in: path name: video_id required: true schema: type: string description: The identifier of the video to retrieve responses: "200": description: Video job details content: application/json: schema: $ref: "#/components/schemas/OpenAIVideoJob" "400": description: Bad request content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "404": description: Video not found "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/openai/v1/videos/{video_id}/content: get: summary: Download video content operationId: openAIDownloadVideoContent x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] parameters: - in: path name: video_id required: true schema: type: string description: The identifier of the video whose media to download - in: query name: variant schema: type: string description: Which downloadable asset to return responses: "200": description: Video content stream content: video/mp4: schema: type: string format: binary "400": description: Bad request content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "404": description: Video not found "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/pika/generate/pikadditions: post: summary: Generate Pikadditions operationId: PikaGenerate_pikadditions_generate_pikadditions_post tags: - API Nodes - Released x-excluded: true requestBody: content: multipart/form-data: schema: $ref: "#/components/schemas/PikaBody_generate_pikadditions_generate_pikadditions_post" required: true responses: "200": description: Successful Response content: application/json: schema: $ref: "#/components/schemas/PikaGenerateResponse" "422": description: Validation Error content: application/json: schema: $ref: "#/components/schemas/PikaHTTPValidationError" parameters: [] /proxy/pika/generate/pikaswaps: post: summary: Generate Pikaswaps description: >- Exactly one of `modifyRegionMask` and `modifyRegionRoi` must be provided. tags: - API Nodes - Released x-excluded: true operationId: PikaGenerate_pikaswaps_generate_pikaswaps_post requestBody: content: multipart/form-data: schema: $ref: "#/components/schemas/PikaBody_generate_pikaswaps_generate_pikaswaps_post" required: true responses: "200": description: Successful Response content: application/json: schema: $ref: "#/components/schemas/PikaGenerateResponse" "422": description: Validation Error content: application/json: schema: $ref: "#/components/schemas/PikaHTTPValidationError" parameters: [] /proxy/pika/generate/pikaffects: post: summary: Generate Pikaffects operationId: PikaGenerate_pikaffects_generate_pikaffects_post tags: - API Nodes - Released x-excluded: true requestBody: content: multipart/form-data: schema: $ref: "#/components/schemas/PikaBody_generate_pikaffects_generate_pikaffects_post" required: true responses: "200": description: Successful Response content: application/json: schema: $ref: "#/components/schemas/PikaGenerateResponse" "422": description: Validation Error content: application/json: schema: $ref: "#/components/schemas/PikaHTTPValidationError" description: >- Generate a video with a specific Pikaffect. Supported Pikaffects: Cake-ify, Crumble, Crush, Decapitate, Deflate, Dissolve, Explode, Eye-pop, Inflate, Levitate, Melt, Peel, Poke, Squish, Ta-da, Tear parameters: [] /proxy/pika/generate/2.2/t2v: post: summary: Generate 2 2 T2V x-excluded: true tags: - API Nodes - Released operationId: PikaGenerate_2_2_t2v_generate_2_2_t2v_post requestBody: content: application/x-www-form-urlencoded: schema: $ref: "#/components/schemas/PikaBody_generate_2_2_t2v_generate_2_2_t2v_post" required: true responses: "200": description: Successful Response content: application/json: schema: $ref: "#/components/schemas/PikaGenerateResponse" "422": description: Validation Error content: application/json: schema: $ref: "#/components/schemas/PikaHTTPValidationError" parameters: [] /proxy/pika/generate/2.2/pikaframes: post: summary: Generate 2 2 Keyframe x-excluded: true tags: - API Nodes - Released operationId: PikaGenerate_2_2_keyframe_generate_2_2_pikaframes_post requestBody: content: multipart/form-data: schema: $ref: "#/components/schemas/PikaBody_generate_2_2_keyframe_generate_2_2_pikaframes_post" required: true responses: "200": description: Successful Response content: application/json: schema: $ref: "#/components/schemas/PikaGenerateResponse" "422": description: Validation Error content: application/json: schema: $ref: "#/components/schemas/PikaHTTPValidationError" parameters: [] /proxy/pika/generate/2.2/pikascenes: post: summary: Generate 2 2 C2V x-excluded: true tags: - API Nodes - Released operationId: PikaGenerate_2_2_c2v_generate_2_2_pikascenes_post requestBody: content: multipart/form-data: schema: $ref: "#/components/schemas/PikaBody_generate_2_2_c2v_generate_2_2_pikascenes_post" required: true responses: "200": description: Successful Response content: application/json: schema: $ref: "#/components/schemas/PikaGenerateResponse" "422": description: Validation Error content: application/json: schema: $ref: "#/components/schemas/PikaHTTPValidationError" parameters: [] /proxy/pika/generate/2.2/i2v: post: summary: Generate 2 2 I2V x-excluded: true tags: - API Nodes - Released operationId: PikaGenerate_2_2_i2v_generate_2_2_i2v_post requestBody: content: multipart/form-data: schema: $ref: "#/components/schemas/PikaBody_generate_2_2_i2v_generate_2_2_i2v_post" required: true responses: "200": description: Successful Response content: application/json: schema: $ref: "#/components/schemas/PikaGenerateResponse" "422": description: Validation Error content: application/json: schema: $ref: "#/components/schemas/PikaHTTPValidationError" parameters: [] /proxy/pika/videos/{video_id}: get: summary: Get Video operationId: PikaGet_video_videos__video_id__get x-excluded: true tags: - API Nodes - Released parameters: - name: video_id in: path required: true schema: type: string title: Video Id responses: "200": description: Successful Response content: application/json: schema: $ref: "#/components/schemas/PikaVideoResponse" "422": description: Validation Error content: application/json: schema: $ref: "#/components/schemas/PikaHTTPValidationError" /proxy/stability/v2beta/stable-image/generate/ultra: post: operationId: StabilityImageGenrationUltra x-excluded: true tags: - API Nodes - Released summary: Stable Image Ultra description: >- Our most advanced text to image generation service, Stable Image Ultra creates the highest quality images with unprecedented prompt understanding. Ultra excels in typography, complex compositions, dynamic lighting, vibrant hues, and overall cohesion and structure of an art piece. Made from the most advanced models, including Stable Diffusion 3.5, Ultra offers the best of the Stable Diffusion ecosystem. ### Try it out Grab your [API key](https://platform.stability.ai/account/keys) and head over to [![Open Google Colab](https://platform.stability.ai/svg/google-colab.svg)](https://colab.research.google.com/github/stability-ai/stability-sdk/blob/main/nbs/Stable_Image_API_Public.ipynb#scrollTo=yXhs626oZdr1) ### How to use Please invoke this endpoint with a `POST` request. The headers of the request must include an API key in the `authorization` field. The body of the request must be `multipart/form-data`. The accept header should be set to one of the following: - `image/*` to receive the image in the format specified by the `output_format` parameter. - `application/json` to receive the image in the format specified by the `output_format` parameter, but encoded to base64 in a JSON response. The only required parameter is the `prompt` field, which should contain the text prompt for the image generation. The body of the request should include: - `prompt` - text to generate the image from The body may optionally include: - `image` - the image to use as the starting point for the generation - `strength` - controls how much influence the `image` parameter has on the output image - `aspect_ratio` - the aspect ratio of the output image - `negative_prompt` - keywords of what you **do not** wish to see in the output image - `seed` - the randomness seed to use for the generation - `output_format` - the the format of the output image > **Note:** for the full list of optional parameters, please see the request schema below. ### Output The resolution of the generated image will be 1 megapixel. The default resolution is 1024x1024. ### Credits The Ultra service uses 8 credits per successful result. You will not be charged for failed results. x-codeSamples: - lang: python label: Python source: |- import requests response = requests.post( f"https://api.stability.ai/v2beta/stable-image/generate/ultra", headers={ "authorization": f"Bearer sk-MYAPIKEY", "accept": "image/*" }, files={"none": ''}, data={ "prompt": "Lighthouse on a cliff overlooking the ocean", "output_format": "webp", }, ) if response.status_code == 200: with open("./lighthouse.webp", 'wb') as file: file.write(response.content) else: raise Exception(str(response.json())) - lang: javascript label: JavaScript source: "import fs from \"node:fs\";\nimport axios from \"axios\";\nimport FormData from \"form-data\";\n\nconst payload = {\n prompt: \"Lighthouse on a cliff overlooking the ocean\",\n output_format: \"webp\"\n};\n\nconst response = await axios.postForm(\n `https://api.stability.ai/v2beta/stable-image/generate/ultra`,\n axios.toFormData(payload, new FormData()),\n {\n validateStatus: undefined,\n responseType: \"arraybuffer\",\n headers: { \n Authorization: `Bearer sk-MYAPIKEY`, \n Accept: \"image/*\" \n },\n },\n);\n\nif(response.status === 200) {\n fs.writeFileSync(\"./lighthouse.webp\", Buffer.from(response.data));\n} else {\n throw new Error(`${response.status}: ${response.data.toString()}`);\n}" - lang: terminal label: cURL source: >- curl -f -sS "https://api.stability.ai/v2beta/stable-image/generate/ultra" \ -H "authorization: Bearer sk-MYAPIKEY" \ -H "accept: image/*" \ -F prompt="Lighthouse on a cliff overlooking the ocean" \ -F output_format="webp" \ -o "./lighthouse.webp" parameters: - schema: type: string description: >- Your [Stability API key](https://platform.stability.ai/account/keys), used to authenticate your requests. Although you may have multiple keys in your account, you should use the same key for all requests to this API. minLength: 1 required: true name: authorization in: header - schema: type: string minLength: 1 description: >- The content type of the request body. Do not manually specify this header; your HTTP client library will automatically include the appropriate boundary parameter. example: multipart/form-data required: true name: content-type in: header - schema: type: string default: image/* description: >- Specify `image/*` to receive the bytes of the image directly. Otherwise specify `application/json` to receive the image as base64 encoded JSON. enum: - image/* - application/json required: false name: accept in: header - schema: $ref: "#/components/schemas/StabilityStabilityClientID" required: false name: stability-client-id in: header - schema: $ref: "#/components/schemas/StabilityStabilityClientUserID" required: false name: stability-client-user-id in: header - schema: $ref: "#/components/schemas/StabilityStabilityClientVersion" required: false name: stability-client-version in: header requestBody: content: multipart/form-data: schema: type: object properties: prompt: type: string minLength: 1 maxLength: 10000 description: >- What you wish to see in the output image. A strong, descriptive prompt that clearly defines elements, colors, and subjects will lead to better results. To control the weight of a given word use the format `(word:weight)`, where `word` is the word you'd like to control the weight of and `weight` is a value between 0 and 1. For example: `The sky was a crisp (blue:0.3) and (green:0.8)` would convey a sky that was blue and green, but more green than blue. negative_prompt: type: string maxLength: 10000 description: >- A blurb of text describing what you **do not** wish to see in the output image. This is an advanced feature. aspect_ratio: type: string enum: - "21:9" - "16:9" - "3:2" - "5:4" - "1:1" - "4:5" - "2:3" - "9:16" - "9:21" default: "1:1" description: Controls the aspect ratio of the generated image. seed: type: number minimum: 0 maximum: 4294967294 default: 0 description: >- A specific value that is used to guide the 'randomness' of the generation. (Omit this parameter or pass `0` to use a random seed.) output_format: type: string enum: - jpeg - png - webp default: png description: Dictates the `content-type` of the generated image. image: type: string description: >- The image to use as the starting point for the generation. > **Important:** The `strength` parameter is required when `image` is provided. Supported Formats: - jpeg - png - webp Validation Rules: - Width must be between 64 and 16,384 pixels - Height must be between 64 and 16,384 pixels - Total pixel count must be at least 4,096 pixels format: binary example: ./some/image.png style_preset: type: string enum: - enhance - anime - photographic - digital-art - comic-book - fantasy-art - line-art - analog-film - neon-punk - isometric - low-poly - origami - modeling-compound - cinematic - 3d-model - pixel-art - tile-texture description: Guides the image model towards a particular style. strength: type: number minimum: 0 maximum: 1 description: "Sometimes referred to as _denoising_, this parameter controls how much influence the \n`image` parameter has on the generated image. A value of 0 would yield an image that \nis identical to the input. A value of 1 would be as if you passed in no image at all.\n\n> **Important:** This parameter is required when `image` is provided." required: - prompt responses: "200": description: Generation was successful. headers: x-request-id: description: A unique identifier for this request. schema: type: string content-type: description: |- The format of the generated image. To receive the bytes of the image directly, specify `image/*` in the accept header. To receive the bytes base64 encoded inside of a JSON payload, specify `application/json`. examples: jpeg: description: raw bytes value: image/jpeg jpegJSON: description: base64 encoded value: application/json; type=image/jpeg png: description: raw bytes value: image/png pngJSON: description: base64 encoded value: application/json; type=image/png webp: description: raw bytes value: image/webp webpJSON: description: base64 encoded value: application/json; type=image/webp schema: type: string finish-reason: schema: type: string enum: - SUCCESS - CONTENT_FILTERED description: >- Indicates the reason the generation finished. - `SUCCESS` = successful generation. - `CONTENT_FILTERED` = successful generation, however the output violated our content moderation policy and has been blurred as a result. > **NOTE:** This header is absent on JSON encoded responses because it is present in the body as `finish_reason`. seed: description: >- The seed used as random noise for this generation. > **NOTE:** This header is absent on JSON encoded responses because it is present in the body as `seed`. example: "343940597" schema: type: string content: image/jpeg: schema: type: string description: |- The bytes of the generated image. The `finish-reason` and `seed` will be present as headers. format: binary example: The bytes of the generated jpeg application/json; type=image/jpeg: schema: type: object properties: image: type: string description: "The generated image, encoded to base64." example: AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1... seed: type: number minimum: 0 maximum: 4294967294 default: 0 description: The seed used as random noise for this generation. example: 343940597 finish_reason: type: string enum: - SUCCESS - CONTENT_FILTERED description: >- The reason the generation finished. - `SUCCESS` = successful generation. - `CONTENT_FILTERED` = successful generation, however the output violated our content moderation policy and has been blurred as a result. example: SUCCESS required: - image - finish_reason image/png: schema: type: string description: |- The bytes of the generated image. The `finish-reason` and `seed` will be present as headers. format: binary example: The bytes of the generated png application/json; type=image/png: schema: type: object properties: image: type: string description: "The generated image, encoded to base64." example: AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1... seed: type: number minimum: 0 maximum: 4294967294 default: 0 description: The seed used as random noise for this generation. example: 343940597 finish_reason: type: string enum: - SUCCESS - CONTENT_FILTERED description: >- The reason the generation finished. - `SUCCESS` = successful generation. - `CONTENT_FILTERED` = successful generation, however the output violated our content moderation policy and has been blurred as a result. example: SUCCESS required: - image - finish_reason image/webp: schema: type: string description: |- The bytes of the generated image. The `finish-reason` and `seed` will be present as headers. format: binary example: The bytes of the generated webp application/json; type=image/webp: schema: type: object properties: image: type: string description: "The generated image, encoded to base64." example: AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1... seed: type: number minimum: 0 maximum: 4294967294 default: 0 description: The seed used as random noise for this generation. example: 343940597 finish_reason: type: string enum: - SUCCESS - CONTENT_FILTERED description: >- The reason the generation finished. - `SUCCESS` = successful generation. - `CONTENT_FILTERED` = successful generation, however the output violated our content moderation policy and has been blurred as a result. example: SUCCESS required: - image - finish_reason "400": description: "Invalid parameter(s), see the `errors` field for details." content: application/json: schema: type: object properties: id: type: string minLength: 1 description: >- A unique identifier associated with this error. Please include this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new) you file, as it will greatly assist us in diagnosing the root cause of the problem. example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 name: type: string minLength: 1 description: >- Short-hand name for an error, useful for discriminating between errors with the same status code. example: bad_request errors: type: array items: type: string minItems: 1 description: One or more error messages indicating what went wrong. example: - "some-field: is required" required: - id - name - errors "403": description: Your request was flagged by our content moderation system. content: application/json: schema: $ref: "#/components/schemas/StabilityContentModerationResponse" "413": description: Your request was larger than 10MiB. content: application/json: schema: type: object properties: id: type: string minLength: 1 description: >- A unique identifier associated with this error. Please include this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new) you file, as it will greatly assist us in diagnosing the root cause of the problem. example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 name: type: string minLength: 1 description: >- Short-hand name for an error, useful for discriminating between errors with the same status code. example: bad_request errors: type: array items: type: string minItems: 1 description: One or more error messages indicating what went wrong. example: - "some-field: is required" required: - id - name - errors example: id: 4212a4b66fbe1cedca4bf2133d35dca5 name: payload_too_large errors: - "body: payloads cannot be larger than 10MiB in size" "422": description: >- Your request was well-formed, but rejected. See the `errors` field for details. content: application/json: schema: type: object properties: id: type: string minLength: 1 description: >- A unique identifier associated with this error. Please include this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new) you file, as it will greatly assist us in diagnosing the root cause of the problem. example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 name: type: string minLength: 1 description: >- Short-hand name for an error, useful for discriminating between errors with the same status code. example: bad_request errors: type: array items: type: string minItems: 1 description: One or more error messages indicating what went wrong. example: - "some-field: is required" required: - id - name - errors examples: Invalid Language: value: id: ff54b236a3acdde1522cb1ba641c43ed name: invalid_language errors: - English is the only supported language for this service. Public Figure Detected: value: id: ff54b236a3acdde1522cb1ba641c43ed name: public_figure errors: - >- Our system detected the likeness of a public figure in your image. To comply with our guidelines, this request cannot be processed. Please upload a different image. "429": description: You have made more than 150 requests in 10 seconds. content: application/json: schema: type: object properties: id: type: string minLength: 1 description: >- A unique identifier associated with this error. Please include this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new) you file, as it will greatly assist us in diagnosing the root cause of the problem. example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 name: type: string minLength: 1 description: >- Short-hand name for an error, useful for discriminating between errors with the same status code. example: bad_request errors: type: array items: type: string minItems: 1 description: One or more error messages indicating what went wrong. example: - "some-field: is required" required: - id - name - errors example: id: rate_limit_exceeded name: rate_limit_exceeded errors: - >- You have exceeded the rate limit of 150 requests within a 10 second period, and have been timed out for 60 seconds. "500": description: >- An internal error occurred. If the problem persists [contact support](https://kb.stability.ai/knowledge-base/kb-tickets/new). content: application/json: schema: type: object properties: id: type: string minLength: 1 description: >- A unique identifier associated with this error. Please include this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new) you file, as it will greatly assist us in diagnosing the root cause of the problem. example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 name: type: string minLength: 1 description: >- Short-hand name for an error, useful for discriminating between errors with the same status code. example: bad_request errors: type: array items: type: string minItems: 1 description: One or more error messages indicating what went wrong. example: - "some-field: is required" required: - id - name - errors example: id: 2a1b2d4eafe2bc6ab4cd4d5c6133f513 name: internal_error errors: - >- An unexpected server error has occurred, please try again later. /proxy/stability/v2beta/stable-image/generate/sd3: post: operationId: StabilityImageGenrationSD3 x-excluded: true tags: - API Nodes - Released summary: Stable Diffusion 3.5 description: "Generate using Stable Diffusion 3.5 models, Stability AI latest\ \ base model:\n\n- **Stable Diffusion 3.5 Large**: At 8 billion parameters,\ \ with superior quality and\n\n\n\n prompt adherence, this base model is\ \ the most powerful in the Stable Diffusion\n family. This model is ideal\ \ for professional use cases at 1 megapixel resolution.\n\n- **Stable Diffusion\ \ 3.5 Large Turbo**: A distilled version of Stable Diffusion 3.5 Large.\n\n\ \n\n SD3.5 Large Turbo generates high-quality images with exceptional prompt\ \ adherence\n in just 4 steps, making it considerably faster than Stable\ \ Diffusion 3.5 Large.\n\n- **Stable Diffusion 3.5 Medium**: With 2.5 billion\ \ parameters, the model delivers an\n\n\n\n optimal balance between prompt\ \ accuracy and image quality, making it an efficient\n choice for fast high-performance\ \ image generation.\n\nRead more about the model capabilities [here](https://stability.ai/news/introducing-stable-diffusion-3-5).\n\ \nAs of April 17, 2025, we have deprecated the Stable Diffusion 3.0 APIs and\ \ will be automatically\nre-routing calls to Stable Diffusion 3.0 models to\ \ Stable Diffusion 3.5 APIs at no extra cost.\nYou can read more in the [release\ \ notes](/docs/release-notes#api-deprecation-notice).\n\n### Try it out\n\ Grab your [API key](https://platform.stability.ai/account/keys) and head over\ \ to [![Open Google Colab](https://platform.stability.ai/svg/google-colab.svg)](https://colab.research.google.com/github/stability-ai/stability-sdk/blob/main/nbs/SD3_API.ipynb)\n\ \n### How to use\nPlease invoke this endpoint with a `POST` request.\n\nThe\ \ headers of the request must include an API key in the `authorization` field.\ \ The body of the request must be\n`multipart/form-data`. The accept header\ \ should be set to one of the following:\n- `image/*` to receive the image\ \ in the format specified by the `output_format` parameter.\n- `application/json`\ \ to receive the image encoded as base64 in a JSON response.\n\n#### **Generating\ \ with a prompt**\nCommonly referred to as **text-to-image**, this mode generates\ \ an image from text alone. While the only required\nparameter is the `prompt`,\ \ it also supports an `aspect_ratio` parameter which can be used to control\ \ the\naspect ratio of the generated image.\n\n#### **Generating with a prompt\ \ *and* an image**\nCommonly referred to as **image-to-image**, this mode\ \ also generates an image from text but uses an existing image as the\nstarting\ \ point. The required parameters are:\n- `prompt` - text to generate the image\ \ from\n- `image` - the image to use as the starting point for the generation\n\ - `strength` - controls how much influence the `image` parameter has on the\ \ output image\n- `mode` - must be set to `image-to-image`\n\n> **Note:**\ \ maximum request size is 10MiB.\n\n#### **Optional Parameters:**\nBoth modes\ \ support the following optional parameters:\n- `model` - the model to use\ \ (SD3.5 Large, SD3.5 Large Turbo, SD3.5 Medium)\n- `output_format` - the\ \ the format of the output image\n- `seed` - the randomness seed to use for\ \ the generation\n- `negative_prompt` - keywords of what you **do not** wish\ \ to see in the output image\n- `cfg_scale` - controls how strictly the diffusion\ \ process adheres to the prompt text\n- `style_preset` - guides the image\ \ model towards a particular style\n\n> **Note:** for more details about these\ \ parameters please see the request schema below.\n\n### Output\nThe resolution\ \ of the generated image will be 1MP. The default resolution is 1024x1024.\n\ \n### Credits\n- **SD 3.5 Large**: Flat rate of 6.5 credits per successful\ \ generation.\n- **SD 3.5 Large Turbo**: Flat rate of 4 credits per successful\ \ generation.\n- **SD 3.5 Medium**: Flat rate of 3.5 credits per successful\ \ generation.\n\nAs always, you will not be charged for failed generations." x-codeSamples: - lang: python label: Python source: "import requests\n\nresponse = requests.post(\n f\"https://api.stability.ai/v2beta/stable-image/generate/sd3\"\ ,\n headers={\n \"authorization\": f\"Bearer sk-MYAPIKEY\",\n\ \ \"accept\": \"image/*\"\n },\n files={\"none\": ''},\n \ \ data={\n \"prompt\": \"Lighthouse on a cliff overlooking the ocean\"\ ,\n \"output_format\": \"jpeg\",\n },\n)\n\nif response.status_code\ \ == 200:\n with open(\"./lighthouse.jpeg\", 'wb') as file:\n \ \ file.write(response.content)\nelse:\n raise Exception(str(response.json()))" - lang: javascript label: JavaScript source: "import fs from \"node:fs\";\nimport axios from \"axios\";\nimport\ \ FormData from \"form-data\";\n\nconst payload = {\n prompt: \"Lighthouse\ \ on a cliff overlooking the ocean\",\n output_format: \"jpeg\"\n};\n\n\ const response = await axios.postForm(\n `https://api.stability.ai/v2beta/stable-image/generate/sd3`,\n\ \ axios.toFormData(payload, new FormData()),\n {\n validateStatus:\ \ undefined,\n responseType: \"arraybuffer\",\n headers: { \n \ \ Authorization: `Bearer sk-MYAPIKEY`, \n Accept: \"image/*\" \n \ \ },\n },\n);\n\nif(response.status === 200) {\n fs.writeFileSync(\"\ ./lighthouse.jpeg\", Buffer.from(response.data));\n} else {\n throw new\ \ Error(`${response.status}: ${response.data.toString()}`);\n}" - lang: terminal label: cURL source: "curl -f -sS \"https://api.stability.ai/v2beta/stable-image/generate/sd3\"\ \ \\\n\n\n\n\n\n\n -H \"authorization: Bearer sk-MYAPIKEY\" \\\n -H \"\ accept: image/*\" \\\n -F prompt=\"Lighthouse on a cliff overlooking the\ \ ocean\" \\\n -F output_format=\"jpeg\" \\\n -o \"./lighthouse.jpeg\"" parameters: - schema: type: string description: Your [Stability API key](https://platform.stability.ai/account/keys), used to authenticate your requests. Although you may have multiple keys in your account, you should use the same key for all requests to this API. minLength: 1 required: true name: authorization in: header - schema: type: string minLength: 1 description: The content type of the request body. Do not manually specify this header; your HTTP client library will automatically include the appropriate boundary parameter. example: multipart/form-data required: true name: content-type in: header - schema: type: string default: image/* description: Specify `image/*` to receive the bytes of the image directly. Otherwise specify `application/json` to receive the image as base64 encoded JSON. enum: - image/* - application/json required: false name: accept in: header - schema: $ref: "#/components/schemas/StabilityStabilityClientID" required: false name: stability-client-id in: header - schema: $ref: "#/components/schemas/StabilityStabilityClientUserID" required: false name: stability-client-user-id in: header - schema: $ref: "#/components/schemas/StabilityStabilityClientVersion" required: false name: stability-client-version in: header requestBody: content: multipart/form-data: schema: $ref: "#/components/schemas/StabilityImageGenerationSD3_Request" responses: "200": description: Generation was successful. headers: x-request-id: description: A unique identifier for this request. schema: type: string content-type: description: "The format of the generated image.\n\n To receive the\ \ bytes of the image directly, specify `image/*` in the accept header.\ \ To receive the bytes base64 encoded inside of a JSON payload, specify\ \ `application/json`." examples: png: description: raw bytes value: image/png pngJSON: description: base64 encoded value: application/json; type=image/png jpeg: description: raw bytes value: image/jpeg jpegJSON: description: base64 encoded value: application/json; type=image/jpeg schema: type: string finish-reason: schema: type: string enum: - SUCCESS - CONTENT_FILTERED description: "Indicates the reason the generation finished. - `SUCCESS` = successful generation. - `CONTENT_FILTERED` = successful generation, however the output violated our content moderation policy and has been blurred as a result. > **NOTE:** This header is absent on JSON encoded responses because it is present in the body as `finish_reason`." seed: description: "The seed used as random noise for this generation. > **NOTE:** This header is absent on JSON encoded responses because it is present in the body as `seed`." example: "343940597" schema: type: string content: image/png: schema: type: string description: "The bytes of the generated image. The `finish-reason` and `seed` will be present as headers." format: binary example: The bytes of the generated png application/json; type=image/png: schema: $ref: "#/components/schemas/StabilityImageGenrationSD3_Response_200" image/jpeg: schema: type: string description: "The bytes of the generated image. The `finish-reason` and `seed` will be present as headers." format: binary example: The bytes of the generated jpeg application/json; type=image/jpeg: schema: $ref: "#/components/schemas/StabilityImageGenrationSD3_Response_200" "400": description: Invalid parameter(s), see the `errors` field for details. content: application/json: schema: $ref: "#/components/schemas/StabilityImageGenrationSD3_Response_400" "403": description: Your request was flagged by our content moderation system. content: application/json: schema: $ref: "#/components/schemas/StabilityContentModerationResponse" "413": description: Your request was larger than 10MiB. content: application/json: schema: $ref: "#/components/schemas/StabilityImageGenrationSD3_Response_413" "422": description: Your request was well-formed, but rejected. See the `errors` field for details. content: application/json: schema: $ref: "#/components/schemas/StabilityImageGenrationSD3_Response_422" examples: Invalid Language: value: id: ff54b236a3acdde1522cb1ba641c43ed name: invalid_language errors: - English is the only supported language for this service. Public Figure Detected: value: id: ff54b236a3acdde1522cb1ba641c43ed name: public_figure errors: - Our system detected the likeness of a public figure in your image. To comply with our guidelines, this request cannot be processed. Please upload a different image. "429": description: You have made more than 150 requests in 10 seconds. content: application/json: schema: $ref: "#/components/schemas/StabilityImageGenrationSD3_Response_429" "500": description: An internal error occurred. If the problem persists [contact support](https://kb.stability.ai/knowledge-base/kb-tickets/new). content: application/json: schema: $ref: "#/components/schemas/StabilityImageGenrationSD3_Response_500" /proxy/stability/v2beta/stable-image/upscale/conservative: post: tags: - API Nodes - Released x-excluded: true summary: Conservative description: "Takes images between 64x64 and 1 megapixel and upscales them all\ \ the way to 4K resolution. Put more generally, it can upscale images ~20-40x\ \ times while preserving all aspects. Conservative Upscale minimizes alterations\ \ to the image and should not be used to reimagine an image.\n\n### Try it\ \ out\nGrab your [API key](https://platform.stability.ai/account/keys) and\ \ head over to [![Open Google Colab](https://platform.stability.ai/svg/google-colab.svg)](https://colab.research.google.com/github/stability-ai/stability-sdk/blob/main/nbs/Stable_Image_API_Public.ipynb#scrollTo=t1Q4w2uvvza0)\n\ \n### How to use\n\nPlease invoke this endpoint with a `POST` request.\n\n\ The headers of the request must include an API key in the `authorization`\ \ field. The body of the request must be\n`multipart/form-data`, and the `accept`\ \ header should be set to one of the following:\n\n\n\n - `image/*` to receive\ \ the image in the format specified by the `output_format` parameter.\n -\ \ `application/json` to receive the image encoded as base64 in a JSON response.\n\ \nThe body of the request must include:\n- `image`\n- `prompt`\n\nOptionally,\ \ the body of the request may also include:\n- `negative_prompt`\n- `seed`\n\ - `output_format`\n- `creativity`\n\n> **Note:** for more details about these\ \ parameters please see the request schema below.\n\n### Output\nThe resolution\ \ of the generated image will be 4 megapixels.\n\n### Credits\nFlat rate of\ \ 25 credits per successful generation. You will not be charged for failed\ \ generations." x-codeSamples: - lang: python label: Python source: "import requests\n\nresponse = requests.post(\n f\"https://api.stability.ai/v2beta/stable-image/upscale/conservative\"\ ,\n headers={\n \"authorization\": f\"Bearer sk-MYAPIKEY\",\n\ \ \"accept\": \"image/*\"\n },\n files={\n \"image\"\ : open(\"./low-res-flower.jpg\", \"rb\"),\n },\n data={\n \"\ prompt\": \"a flower\",\n \"output_format\": \"webp\",\n },\n\ )\n\nif response.status_code == 200:\n with open(\"./flower.webp\", 'wb')\ \ as file:\n file.write(response.content)\nelse:\n raise Exception(str(response.json()))" - lang: javascript label: JavaScript source: "import fs from \"node:fs\";\nimport axios from \"axios\";\nimport\ \ FormData from \"form-data\";\n\nconst payload = {\n image: fs.createReadStream(\"\ ./low-res-flower.jpg\"),\n prompt: \"a flower\",\n output_format: \"webp\"\ \n};\n\nconst response = await axios.postForm(\n `https://api.stability.ai/v2beta/stable-image/upscale/conservative`,\n\ \ axios.toFormData(payload, new FormData()),\n {\n validateStatus:\ \ undefined,\n responseType: \"arraybuffer\",\n headers: { \n \ \ Authorization: `Bearer sk-MYAPIKEY`, \n Accept: \"image/*\" \n \ \ },\n },\n);\n\nif(response.status === 200) {\n fs.writeFileSync(\"\ ./flower.webp\", Buffer.from(response.data));\n} else {\n throw new Error(`${response.status}:\ \ ${response.data.toString()}`);\n}" - lang: terminal label: cURL source: "curl -f -sS \"https://api.stability.ai/v2beta/stable-image/upscale/conservative\"\ \ \\\n\n\n\n\n\n\n -H \"authorization: Bearer sk-MYAPIKEY\" \\\n -H \"\ accept: image/*\" \\\n -F image=@\"./low-res-flower.jpg\" \\\n -F prompt=\"\ a flower\" \\\n -F output_format=\"webp\" \\\n -o \"./flower.webp\"" parameters: - schema: type: string description: Your [Stability API key](https://platform.stability.ai/account/keys), used to authenticate your requests. Although you may have multiple keys in your account, you should use the same key for all requests to this API. minLength: 1 required: true name: authorization in: header - schema: type: string minLength: 1 description: The content type of the request body. Do not manually specify this header; your HTTP client library will automatically include the appropriate boundary parameter. example: multipart/form-data required: true name: content-type in: header - schema: type: string default: image/* description: Specify `image/*` to receive the bytes of the image directly. Otherwise specify `application/json` to receive the image as base64 encoded JSON. enum: - image/* - application/json required: false name: accept in: header - schema: $ref: "#/components/schemas/StabilityStabilityClientID" required: false name: stability-client-id in: header - schema: $ref: "#/components/schemas/StabilityStabilityClientUserID" required: false name: stability-client-user-id in: header - schema: $ref: "#/components/schemas/StabilityStabilityClientVersion" required: false name: stability-client-version in: header requestBody: content: multipart/form-data: schema: $ref: "#/components/schemas/StabilityImageGenrationUpscaleConservative_Request" responses: "200": description: Upscale was successful. headers: x-request-id: description: A unique identifier for this request. schema: type: string content-type: description: "The format of the generated image.\n\n To receive the\ \ bytes of the image directly, specify `image/*` in the accept header.\ \ To receive the bytes base64 encoded inside of a JSON payload, specify\ \ `application/json`." examples: jpeg: description: raw bytes value: image/jpeg jpegJSON: description: base64 encoded value: application/json; type=image/jpeg png: description: raw bytes value: image/png pngJSON: description: base64 encoded value: application/json; type=image/png webp: description: raw bytes value: image/webp webpJSON: description: base64 encoded value: application/json; type=image/webp schema: type: string finish-reason: schema: type: string enum: - SUCCESS - CONTENT_FILTERED description: "Indicates the reason the generation finished. - `SUCCESS` = successful generation. - `CONTENT_FILTERED` = successful generation, however the output violated our content moderation policy and has been blurred as a result. > **NOTE:** This header is absent on JSON encoded responses because it is present in the body as `finish_reason`." seed: description: "The seed used as random noise for this generation. > **NOTE:** This header is absent on JSON encoded responses because it is present in the body as `seed`." example: "343940597" schema: type: string content: image/jpeg: schema: type: string description: "The bytes of the generated image. The `finish-reason` and `seed` will be present as headers." format: binary example: The bytes of the generated jpeg application/json; type=image/jpeg: schema: $ref: "#/components/schemas/StabilityImageGenrationUpscaleConservative_Response_200" image/png: schema: type: string description: "The bytes of the generated image. The `finish-reason` and `seed` will be present as headers." format: binary example: The bytes of the generated png application/json; type=image/png: schema: $ref: "#/components/schemas/StabilityImageGenrationUpscaleConservative_Response_200" image/webp: schema: type: string description: "The bytes of the generated image. The `finish-reason` and `seed` will be present as headers." format: binary example: The bytes of the generated webp application/json; type=image/webp: schema: $ref: "#/components/schemas/StabilityImageGenrationUpscaleConservative_Response_200" "400": description: Invalid parameter(s), see the `errors` field for details. content: application/json: schema: $ref: "#/components/schemas/StabilityImageGenrationUpscaleConservative_Response_400" "403": description: Your request was flagged by our content moderation system. content: application/json: schema: $ref: "#/components/schemas/StabilityContentModerationResponse" "413": description: Your request was larger than 10MiB. content: application/json: schema: $ref: "#/components/schemas/StabilityImageGenrationUpscaleConservative_Response_413" "422": description: Your request was well-formed, but rejected. See the `errors` field for details. content: application/json: schema: $ref: "#/components/schemas/StabilityImageGenrationUpscaleConservative_Response_422" examples: Invalid Language: value: id: ff54b236a3acdde1522cb1ba641c43ed name: invalid_language errors: - English is the only supported language for this service. Public Figure Detected: value: id: ff54b236a3acdde1522cb1ba641c43ed name: public_figure errors: - Our system detected the likeness of a public figure in your image. To comply with our guidelines, this request cannot be processed. Please upload a different image. "429": description: You have made more than 150 requests in 10 seconds. content: application/json: schema: $ref: "#/components/schemas/StabilityImageGenrationUpscaleConservative_Response_429" "500": description: An internal error occurred. If the problem persists [contact support](https://kb.stability.ai/knowledge-base/kb-tickets/new). content: application/json: schema: $ref: "#/components/schemas/StabilityImageGenrationUpscaleConservative_Response_500" operationId: StabilityImageGenrationUpscaleConservative /proxy/stability/v2beta/stable-image/upscale/creative: post: tags: - API Nodes - Released x-excluded: true summary: Creative Upscale (async) description: "Takes images between 64x64 and 1 megapixel and upscales them all the way to **4K** resolution. Put more generally, it can upscale images ~20-40x times while preserving, and often enhancing, quality. Creative Upscale **works best on highly degraded images and is not for photos of 1mp or above** as it performs heavy reimagining (controlled by creativity scale). ### Try it out Grab your [API key](https://platform.stability.ai/account/keys) and head over to [![Open Google Colab](https://platform.stability.ai/svg/google-colab.svg)](https://colab.research.google.com/github/stability-ai/stability-sdk/blob/main/nbs/Stable_Image_API_Public.ipynb#scrollTo=QXxi9tfI425t) ### How to use Please invoke this endpoint with a `POST` request. The headers of the request must include an API key in the `authorization` field. The body of the request must be `multipart/form-data`. The body of the request should include: - `image` - `prompt` The body may optionally include: - `seed` - `negative_prompt` - `output_format` - `creativity` - `style_preset` > **Note:** for more details about these parameters please see the request schema below. ### Results After invoking this endpoint with the required parameters, use the `id` in the response to poll for results at the [results/{id} endpoint](#tag/Results/paths/~1v2beta~1results~1%7Bid%7D/get). Rate-limiting or other errors may occur if you poll more than once every 10 seconds. ### Credits Flat rate of 25 credits per successful generation. You will not be charged for failed generations." x-codeSamples: - lang: python label: Python source: "import requests\n\nresponse = requests.post(\n f\"https://api.stability.ai/v2beta/stable-image/upscale/creative\"\ ,\n headers={\n \"authorization\": f\"Bearer sk-MYAPIKEY\",\n\ \ \"accept\": \"image/*\"\n },\n files={\n \"image\"\ : open(\"./kitten-in-space.png\", \"rb\")\n },\n data={\n \"\ prompt\": \"cute fluffy white kitten floating in space, pastel colors\"\ ,\n \"output_format\": \"webp\",\n },\n)\n\nprint(\"Generation\ \ ID:\", response.json().get('id'))" - lang: javascript label: JavaScript source: "import fs from \"node:fs\";\nimport axios from \"axios\";\nimport\ \ FormData from \"form-data\";\n\nconst payload = {\n image: fs.createReadStream(\"\ ./kitten-in-space.png\"),\n prompt: \"cute fluffy white kitten floating\ \ in space, pastel colors\",\n output_format: \"webp\"\n};\n\nconst response\ \ = await axios.postForm(\n `https://api.stability.ai/v2beta/stable-image/upscale/creative`,\n\ \ axios.toFormData(payload, new FormData()),\n {\n validateStatus:\ \ undefined,\n headers: { \n Authorization: `Bearer sk-MYAPIKEY`\n\ \ },\n },\n);\n\nconsole.log(\"Generation ID:\", response.data.id);" - lang: terminal label: cURL source: "curl -f -sS \"https://api.stability.ai/v2beta/stable-image/upscale/creative\"\ \ \\\n\n\n\n\n\n\n -H \"authorization: Bearer sk-MYAPIKEY\" \\\n -F image=@\"\ ./kitten-in-rainforest.png\" \\\n -F prompt=\"cute fluffy white kitten\ \ sitting in a rainforest, pastel colors\" \\\n -F output_format=webp \\\ \n -o \"./output.json\"" parameters: - schema: type: string description: Your [Stability API key](https://platform.stability.ai/account/keys), used to authenticate your requests. Although you may have multiple keys in your account, you should use the same key for all requests to this API. minLength: 1 required: true name: authorization in: header - schema: type: string minLength: 1 description: The content type of the request body. Do not manually specify this header; your HTTP client library will automatically include the appropriate boundary parameter. example: multipart/form-data required: true name: content-type in: header - schema: $ref: "#/components/schemas/StabilityStabilityClientID" required: false name: stability-client-id in: header - schema: $ref: "#/components/schemas/StabilityStabilityClientUserID" required: false name: stability-client-user-id in: header - schema: $ref: "#/components/schemas/StabilityStabilityClientVersion" required: false name: stability-client-version in: header requestBody: content: multipart/form-data: schema: $ref: "#/components/schemas/StabilityImageGenrationUpscaleCreative_Request" responses: "200": description: Upscale was started. content: application/json: schema: $ref: "#/components/schemas/StabilityImageGenrationUpscaleCreative_Response_200" "400": description: Invalid parameter(s), see the `errors` field for details. content: application/json: schema: $ref: "#/components/schemas/StabilityImageGenrationUpscaleCreative_Response_400" "403": description: Your request was flagged by our content moderation system. content: application/json: schema: $ref: "#/components/schemas/StabilityContentModerationResponse" "413": description: Your request was larger than 10MiB. content: application/json: schema: $ref: "#/components/schemas/StabilityImageGenrationUpscaleCreative_Response_413" "422": description: Your request was well-formed, but rejected. See the `errors` field for details. content: application/json: schema: $ref: "#/components/schemas/StabilityImageGenrationUpscaleCreative_Response_422" examples: Invalid Language: value: id: ff54b236a3acdde1522cb1ba641c43ed name: invalid_language errors: - English is the only supported language for this service. Public Figure Detected: value: id: ff54b236a3acdde1522cb1ba641c43ed name: public_figure errors: - Our system detected the likeness of a public figure in your image. To comply with our guidelines, this request cannot be processed. Please upload a different image. "429": description: You have made more than 150 requests in 10 seconds. content: application/json: schema: $ref: "#/components/schemas/StabilityImageGenrationUpscaleCreative_Response_429" "500": description: An internal error occurred. If the problem persists [contact support](https://kb.stability.ai/knowledge-base/kb-tickets/new). content: application/json: schema: $ref: "#/components/schemas/StabilityImageGenrationUpscaleCreative_Response_500" operationId: StabilityImageGenrationUpscaleCreative /proxy/stability/v2beta/stable-image/upscale/fast: post: tags: - API Nodes - Released x-excluded: true summary: Fast description: "Our Fast Upscaler service enhances image resolution by 4x using\ \ predictive and generative AI. This lightweight and fast service (processing\ \ in ~1 second) is ideal for enhancing the quality of compressed images, making\ \ it suitable for social media posts and other applications.\n\n### Try it\ \ out\nGrab your [API key](https://platform.stability.ai/account/keys) and\ \ head over to [![Open Google Colab](https://platform.stability.ai/svg/google-colab.svg)](https://colab.research.google.com/github/stability-ai/stability-sdk/blob/main/nbs/Stable_Image_API_Public.ipynb#scrollTo=t1Q4w2uvvza0)\n\ \n### How to use\n\nPlease invoke this endpoint with a `POST` request.\n\n\ The headers of the request must include an API key in the `authorization`\ \ field. The body of the request must be\n`multipart/form-data`, and the `accept`\ \ header should be set to one of the following:\n\n\n\n - `image/*` to receive\ \ the image in the format specified by the `output_format` parameter.\n -\ \ `application/json` to receive the image encoded as base64 in a JSON response.\n\ \nThe body of the request must include:\n- `image`\n\nOptionally, the body\ \ of the request may also include:\n- `output_format`\n\n> **Note:** for more\ \ details about these parameters please see the request schema below.\n\n\ ### Output\nThe resolution of the generated image is 4 times that of the input\ \ image with a maximum size of 16 megapixels.\n\n### Credits\nFlat rate of\ \ 1 credit per successful generation. You will not be charged for failed generations." x-codeSamples: - lang: python label: Python source: "import requests\n\nresponse = requests.post(\n f\"https://api.stability.ai/v2beta/stable-image/upscale/fast\"\ ,\n headers={\n \"authorization\": f\"Bearer sk-MYAPIKEY\",\n\ \ \"accept\": \"image/*\"\n },\n files={\n \"image\"\ : open(\"./low-res-flower.jpg\", \"rb\"),\n },\n data={\n \"\ output_format\": \"webp\",\n },\n)\n\nif response.status_code == 200:\n\ \ with open(\"./flower.webp\", 'wb') as file:\n file.write(response.content)\n\ else:\n raise Exception(str(response.json()))" - lang: javascript label: JavaScript source: "import fs from \"node:fs\";\nimport axios from \"axios\";\nimport\ \ FormData from \"form-data\";\n\nconst payload = {\n image: fs.createReadStream(\"\ ./low-res-flower.jpg\"),\n output_format: \"webp\"\n};\n\nconst response\ \ = await axios.postForm(\n `https://api.stability.ai/v2beta/stable-image/upscale/fast`,\n\ \ axios.toFormData(payload, new FormData()),\n {\n validateStatus:\ \ undefined,\n responseType: \"arraybuffer\",\n headers: { \n \ \ Authorization: `Bearer sk-MYAPIKEY`, \n Accept: \"image/*\" \n \ \ },\n },\n);\n\nif(response.status === 200) {\n fs.writeFileSync(\"\ ./flower.webp\", Buffer.from(response.data));\n} else {\n throw new Error(`${response.status}:\ \ ${response.data.toString()}`);\n}" - lang: terminal label: cURL source: "curl -f -sS \"https://api.stability.ai/v2beta/stable-image/upscale/fast\"\ \ \\\n\n\n\n\n\n\n -H \"authorization: Bearer sk-MYAPIKEY\" \\\n -H \"\ accept: image/*\" \\\n -F image=@\"./low-res-flower.jpg\" \\\n -F output_format=\"\ webp\" \\\n -o \"./flower.webp\"" parameters: - schema: type: string description: Your [Stability API key](https://platform.stability.ai/account/keys), used to authenticate your requests. Although you may have multiple keys in your account, you should use the same key for all requests to this API. minLength: 1 required: true name: authorization in: header - schema: type: string minLength: 1 description: The content type of the request body. Do not manually specify this header; your HTTP client library will automatically include the appropriate boundary parameter. example: multipart/form-data required: true name: content-type in: header - schema: type: string default: image/* description: Specify `image/*` to receive the bytes of the image directly. Otherwise specify `application/json` to receive the image as base64 encoded JSON. enum: - image/* - application/json required: false name: accept in: header - schema: $ref: "#/components/schemas/StabilityStabilityClientID" required: false name: stability-client-id in: header - schema: $ref: "#/components/schemas/StabilityStabilityClientUserID" required: false name: stability-client-user-id in: header - schema: $ref: "#/components/schemas/StabilityStabilityClientVersion" required: false name: stability-client-version in: header requestBody: content: multipart/form-data: schema: $ref: "#/components/schemas/StabilityImageGenrationUpscaleFast_Request" responses: "200": description: Upscale was successful. headers: x-request-id: description: A unique identifier for this request. schema: type: string content-type: description: "The format of the generated image.\n\n To receive the\ \ bytes of the image directly, specify `image/*` in the accept header.\ \ To receive the bytes base64 encoded inside of a JSON payload, specify\ \ `application/json`." examples: jpeg: description: raw bytes value: image/jpeg jpegJSON: description: base64 encoded value: application/json; type=image/jpeg png: description: raw bytes value: image/png pngJSON: description: base64 encoded value: application/json; type=image/png webp: description: raw bytes value: image/webp webpJSON: description: base64 encoded value: application/json; type=image/webp schema: type: string finish-reason: schema: type: string enum: - SUCCESS - CONTENT_FILTERED description: "Indicates the reason the generation finished. - `SUCCESS` = successful generation. - `CONTENT_FILTERED` = successful generation, however the output violated our content moderation policy and has been blurred as a result. > **NOTE:** This header is absent on JSON encoded responses because it is present in the body as `finish_reason`." seed: description: "The seed used as random noise for this generation. > **NOTE:** This header is absent on JSON encoded responses because it is present in the body as `seed`." example: "343940597" schema: type: string content: image/jpeg: schema: type: string description: "The bytes of the generated image. The `finish-reason` and `seed` will be present as headers." format: binary example: The bytes of the generated jpeg application/json; type=image/jpeg: schema: $ref: "#/components/schemas/StabilityImageGenrationUpscaleFast_Response_200" image/png: schema: type: string description: "The bytes of the generated image. The `finish-reason` and `seed` will be present as headers." format: binary example: The bytes of the generated png application/json; type=image/png: schema: $ref: "#/components/schemas/StabilityImageGenrationUpscaleFast_Response_200" image/webp: schema: type: string description: "The bytes of the generated image. The `finish-reason` and `seed` will be present as headers." format: binary example: The bytes of the generated webp application/json; type=image/webp: schema: $ref: "#/components/schemas/StabilityImageGenrationUpscaleFast_Response_200" "400": description: Invalid parameter(s), see the `errors` field for details. content: application/json: schema: $ref: "#/components/schemas/StabilityImageGenrationUpscaleFast_Response_400" "403": description: Your request was flagged by our content moderation system. content: application/json: schema: $ref: "#/components/schemas/StabilityContentModerationResponse" "413": description: Your request was larger than 10MiB. content: application/json: schema: $ref: "#/components/schemas/StabilityImageGenrationUpscaleFast_Response_413" "422": description: Your request was well-formed, but rejected. See the `errors` field for details. content: application/json: schema: $ref: "#/components/schemas/StabilityImageGenrationUpscaleFast_Response_422" examples: Invalid Language: value: id: ff54b236a3acdde1522cb1ba641c43ed name: invalid_language errors: - English is the only supported language for this service. Public Figure Detected: value: id: ff54b236a3acdde1522cb1ba641c43ed name: public_figure errors: - Our system detected the likeness of a public figure in your image. To comply with our guidelines, this request cannot be processed. Please upload a different image. "429": description: You have made more than 150 requests in 10 seconds. content: application/json: schema: $ref: "#/components/schemas/StabilityImageGenrationUpscaleFast_Response_429" "500": description: An internal error occurred. If the problem persists [contact support](https://kb.stability.ai/knowledge-base/kb-tickets/new). content: application/json: schema: $ref: "#/components/schemas/StabilityImageGenrationUpscaleFast_Response_500" operationId: StabilityImageGenerationUpscaleFast /proxy/stability/v2beta/results/{id}: get: summary: Get Result description: Get the result of a generation operationId: StabilityGetResult x-excluded: true tags: - API Nodes - Released parameters: - name: id in: path required: true schema: type: string description: The ID of the generation result to retrieve. - name: Accept in: header required: false schema: type: string default: image/* description: Set to image/* to receive image bytes. responses: "200": description: The generated image as JPEG bytes. content: image/jpeg: schema: type: string format: binary application/json; type=image/jpeg: schema: type: object properties: image: type: string description: The generated image, encoded to base64. example: AAAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1... seed: type: number minimum: 0 maximum: 4294967294 default: 0 description: The seed used as random noise for this generation. example: 343940597 finish_reason: type: string enum: [SUCCESS, CONTENT_FILTERED] description: |- The reason the generation finished. - `SUCCESS` = successful generation. - `CONTENT_FILTERED` = successful generation, however the output violated our content moderation policy and has been blurred as a result. example: SUCCESS required: - image - finish_reason image/png: schema: type: string description: |- The bytes of the generated image. The `finish-reason` and `seed` will be present as headers. format: binary example: The bytes of the generated png application/json; type=image/png: schema: type: object properties: image: type: string description: The generated image, encoded to base64. example: AAAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1... seed: type: number minimum: 0 maximum: 4294967294 default: 0 description: The seed used as random noise for this generation. example: 343940597 finish_reason: type: string enum: [SUCCESS, CONTENT_FILTERED] description: |- The reason the generation finished. - `SUCCESS` = successful generation. - `CONTENT_FILTERED` = successful generation, however the output violated our content moderation policy and has been blurred as a result. example: SUCCESS required: - image - finish_reason image/webp: schema: type: string description: |- The bytes of the generated image. The `finish-reason` and `seed` will be present as headers. format: binary example: The bytes of the generated webp application/json; type=image/webp: schema: type: object properties: image: type: string description: The generated image, encoded to base64. example: AAAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1... seed: type: number minimum: 0 maximum: 4294967294 default: 0 description: The seed used as random noise for this generation. example: 343940597 finish_reason: type: string enum: [SUCCESS, CONTENT_FILTERED] description: |- The reason the generation finished. - `SUCCESS` = successful generation. - `CONTENT_FILTERED` = successful generation, however the output violated our content moderation policy and has been blurred as a result. example: SUCCESS required: - image - finish_reason "202": description: The generation is still in progress. content: application/json: schema: $ref: "#/components/schemas/StabilityGetResultResponse_202" "400": description: Invalid result ID. content: application/json: schema: $ref: "#/components/schemas/StabilityError" "404": description: Result not found. content: application/json: schema: $ref: "#/components/schemas/StabilityError" "500": description: An internal error occurred. If the problem persists [contact support](https://kb.stability.ai/knowledge-base/kb-tickets/new). content: application/json: schema: $ref: "#/components/schemas/StabilityError" /proxy/stability/v2beta/audio/stable-audio-2/text-to-audio: post: summary: Proxy request to Stable Audio 2.5 for text-to-audio generation operationId: stableAudio25TextToAudio x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: multipart/form-data: schema: $ref: "#/components/schemas/StableAudio25TextToAudioRequest" responses: "200": description: Successful response from Stable Audio proxy content: application/json: schema: $ref: "#/components/schemas/StableAudio25AudioResponse" default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/stability/v2beta/audio/stable-audio-2/audio-to-audio: post: summary: Proxy request to Stable Audio for audio-to-audio transformation operationId: stableAudio25AudioToAudio x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: multipart/form-data: schema: $ref: "#/components/schemas/StableAudio25AudioToAudioRequest" responses: "200": description: Successful response from Stable Audio proxy content: application/json: schema: $ref: "#/components/schemas/StableAudio25AudioResponse" default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/stability/v2beta/audio/stable-audio-2/inpaint: post: summary: Proxy request to Stable Audio 2.5 for audio inpainting operationId: stableAudio25Inpaint x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: multipart/form-data: schema: $ref: "#/components/schemas/StableAudio25InpaintRequest" responses: "200": description: Successful response from Stable Audio proxy content: application/json: schema: $ref: "#/components/schemas/StableAudio25AudioResponse" default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/vertexai/gemini/{model}: post: summary: Generate content using a specified model. operationId: GeminiGenerateContent x-excluded: true tags: - API Nodes - Released parameters: - name: model in: path schema: type: string required: true description: Full resource name of the model. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/GeminiGenerateContentRequest" responses: "200": description: Generated content response. content: application/json: schema: $ref: "#/components/schemas/GeminiGenerateContentResponse" "400": description: Bad Request "401": description: Unauthorized "403": description: Forbidden "404": description: Not Found "500": description: Internal Server Error /proxy/vertexai/imagen/{model}: parameters: - name: model in: path required: true schema: type: string enum: - imagen-3.0-generate-002 - imagen-3.0-generate-001 - imagen-3.0-fast-generate-001 - imagegeneration@006 - imagegeneration@005 - imagegeneration@002 description: image generation model post: summary: Generate images from a text prompt operationId: ImagenGenerateImages x-excluded: true tags: - API Nodes - Released requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ImagenGenerateImageRequest" responses: "200": description: Successful image generation content: application/json: schema: $ref: "#/components/schemas/ImagenGenerateImageResponse" "4XX": description: Client error "5XX": description: Server error /proxy/tripo/v2/openapi/task/{task_id}: get: summary: Get Task Status operationId: tripoGetTask x-excluded: true tags: - API Nodes - Released parameters: - name: task_id in: path required: true schema: type: string responses: "200": description: Request successful content: application/json: schema: type: object properties: code: $ref: "#/components/schemas/TripoResponseSuccessCode" data: $ref: "#/components/schemas/TripoTask" required: - code - data "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/TripoErrorResponse" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/TripoErrorResponse" "403": description: Unauthorized access to requested resource content: application/json: schema: $ref: "#/components/schemas/TripoErrorResponse" "404": description: Resource not found content: application/json: schema: $ref: "#/components/schemas/TripoErrorResponse" "429": description: Account exception or Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/TripoErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/TripoErrorResponse" "503": description: Service temporarily unavailable content: application/json: schema: $ref: "#/components/schemas/TripoErrorResponse" "504": description: Server timeout content: application/json: schema: $ref: "#/components/schemas/TripoErrorResponse" /proxy/tripo/v2/openapi/upload: post: summary: Upload File for 3D Generation operationId: tripoUploadFile x-excluded: true tags: - API Nodes - Released requestBody: content: multipart/form-data: schema: type: object properties: file: type: string format: binary required: - file encoding: profileImage: contentType: image/png, image/jpeg responses: "200": description: Request successful content: application/json: schema: type: object properties: code: $ref: "#/components/schemas/TripoResponseSuccessCode" data: type: object properties: image_token: type: string required: - image_token required: - code - data "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/TripoErrorResponse" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/TripoErrorResponse" "403": description: Unauthorized access to requested resource content: application/json: schema: $ref: "#/components/schemas/TripoErrorResponse" "404": description: Resource not found content: application/json: schema: $ref: "#/components/schemas/TripoErrorResponse" "429": description: Account exception or Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/TripoErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/TripoErrorResponse" "503": description: Service temporarily unavailable content: application/json: schema: $ref: "#/components/schemas/TripoErrorResponse" "504": description: Server timeout content: application/json: schema: $ref: "#/components/schemas/TripoErrorResponse" /proxy/tripo/v2/openapi/task: post: summary: Create 3D Generation Task operationId: tripoCreateTask x-excluded: true tags: - API Nodes - Released requestBody: content: application/json: schema: oneOf: - type: object properties: type: $ref: "#/components/schemas/TripoTextToModel" prompt: type: string maxLength: 1024 negative_prompt: type: string maxLength: 1024 model_version: $ref: "#/components/schemas/TripoModelVersion" face_limit: type: integer texture: type: boolean default: true pbr: type: boolean default: true text_seed: type: integer model_seed: type: integer texture_seed: type: integer texture_quality: $ref: "#/components/schemas/TripoTextureQuality" default: standard style: $ref: "#/components/schemas/TripoModelStyle" auto_size: type: boolean default: false quad: type: boolean default: false geometry_quality: $ref: "#/components/schemas/TripoGeometryQuality" required: - type - prompt - type: object properties: type: $ref: "#/components/schemas/TripoImageToModel" file: type: object properties: type: type: string file_token: type: string required: - type - file_token model_version: $ref: "#/components/schemas/TripoModelVersion" face_limit: type: integer texture: type: boolean default: true pbr: type: boolean default: true model_seed: type: integer texture_seed: type: integer texture_quality: $ref: "#/components/schemas/TripoTextureQuality" default: standard texture_alignment: $ref: "#/components/schemas/TripoTextureAlignment" default: original_image style: $ref: "#/components/schemas/TripoModelStyle" auto_size: type: boolean default: false orientation: $ref: "#/components/schemas/TripoOrientation" default: default quad: type: boolean default: false geometry_quality: $ref: "#/components/schemas/TripoGeometryQuality" required: - type - file - type: object properties: type: $ref: "#/components/schemas/TripoMultiviewToModel" files: type: array items: type: object properties: type: type: string file_token: type: string required: - type - file_token mode: $ref: "#/components/schemas/TripoMultiviewMode" model_version: $ref: "#/components/schemas/TripoModelVersion" orthographic_projection: type: boolean default: false face_limit: type: integer texture: type: boolean default: true pbr: type: boolean default: true model_seed: type: integer texture_seed: type: integer texture_quality: $ref: "#/components/schemas/TripoTextureQuality" default: standard texture_alignment: $ref: "#/components/schemas/TripoTextureAlignment" default: original_image auto_size: type: boolean default: false orientation: $ref: "#/components/schemas/TripoOrientation" default: default quad: type: boolean default: false geometry_quality: $ref: "#/components/schemas/TripoGeometryQuality" required: - type - files - type: object properties: type: $ref: "#/components/schemas/TripoTypeTextureModel" texture: type: boolean default: true pbr: type: boolean default: true model_seed: type: integer texture_seed: type: integer texture_quality: $ref: "#/components/schemas/TripoTextureQuality" texture_alignment: $ref: "#/components/schemas/TripoTextureAlignment" default: original_image original_model_task_id: type: string required: - type - original_model_task_id - type: object properties: type: $ref: "#/components/schemas/TripoTypeRefineModel" draft_model_task_id: type: string required: - type - draft_model_task_id - type: object properties: type: $ref: "#/components/schemas/TripoTypeAnimatePrerigcheck" original_model_task_id: type: string required: - type - original_model_task_id - type: object properties: type: $ref: "#/components/schemas/TripoTypeAnimateRig" original_model_task_id: type: string out_format: $ref: "#/components/schemas/TripoStandardFormat" default: glb topology: $ref: "#/components/schemas/TripoTopology" spec: $ref: "#/components/schemas/TripoSpec" default: "tripo" required: - type - original_model_task_id - type: object properties: type: $ref: "#/components/schemas/TripoTypeAnimateRetarget" original_model_task_id: type: string out_format: $ref: "#/components/schemas/TripoStandardFormat" default: glb animation: $ref: "#/components/schemas/TripoAnimation" bake_animation: type: boolean default: true required: - type - original_model_task_id - animation - type: object properties: type: $ref: "#/components/schemas/TripoTypeStylizeModel" style: $ref: "#/components/schemas/TripoStylizeOptions" original_model_task_id: type: string block_size: type: integer default: 80 required: - type - style - original_model_task_id - type: object properties: type: $ref: "#/components/schemas/TripoTypeConvertModel" format: $ref: "#/components/schemas/TripoConvertFormat" original_model_task_id: type: string quad: type: boolean default: false force_symmetry: type: boolean default: false face_limit: type: integer default: 10000 flatten_bottom: type: boolean default: false flatten_bottom_threshold: type: number default: 0.01 texture_size: type: integer default: 4096 texture_format: $ref: "#/components/schemas/TripoTextureFormat" default: JPEG pivot_to_center_bottom: type: boolean default: false required: - type - format - original_model_task_id responses: "200": description: Request successful content: application/json: schema: $ref: "#/components/schemas/TripoSuccessTask" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/TripoErrorResponse" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/TripoErrorResponse" "403": description: Unauthorized access to requested resource content: application/json: schema: $ref: "#/components/schemas/TripoErrorResponse" "404": description: Resource not found content: application/json: schema: $ref: "#/components/schemas/TripoErrorResponse" "429": description: Account exception or Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/TripoErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/TripoErrorResponse" "503": description: Service temporarily unavailable content: application/json: schema: $ref: "#/components/schemas/TripoErrorResponse" "504": description: Server timeout content: application/json: schema: $ref: "#/components/schemas/TripoErrorResponse" /proxy/tripo/v2/openapi/user/balance: get: summary: Query Account Balance operationId: tripoGetBalance x-excluded: true tags: - API Nodes - Released responses: "200": description: Request successful content: application/json: schema: type: object properties: code: $ref: "#/components/schemas/TripoResponseSuccessCode" data: $ref: "#/components/schemas/TripoBalance" required: - code - data "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/TripoErrorResponse" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/TripoErrorResponse" "403": description: Unauthorized access to requested resource content: application/json: schema: $ref: "#/components/schemas/TripoErrorResponse" "404": description: Resource not found content: application/json: schema: $ref: "#/components/schemas/TripoErrorResponse" "429": description: Account exception or Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/TripoErrorResponse" "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/TripoErrorResponse" "503": description: Service temporarily unavailable content: application/json: schema: $ref: "#/components/schemas/TripoErrorResponse" "504": description: Server timeout content: application/json: schema: $ref: "#/components/schemas/TripoErrorResponse" /proxy/rodin/api/v2/rodin: post: summary: Create 3D generate Task using Rodin API. operationId: rodinGenerate3DAsset x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: multipart/form-data: schema: $ref: "#/components/schemas/Rodin3DGenerateRequest" responses: "200": description: 3D generate Task submitted successfully. content: application/json: schema: $ref: "#/components/schemas/Rodin3DGenerateResponse" "400": description: Bad Request "401": description: Unauthorized "403": description: Forbidden "404": description: Not Found "500": description: Internal Server Error /proxy/rodin/api/v2/status: post: summary: Check Rodin 3D Generate Status. operationId: rodinCheckStatus x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: multipart/form-data: schema: $ref: "#/components/schemas/Rodin3DCheckStatusRequest" responses: "200": description: Get the status of the 3D Assets generation. content: application/json: schema: $ref: "#/components/schemas/Rodin3DCheckStatusResponse" "400": description: Bad Request "401": description: Unauthorized "403": description: Forbidden "404": description: Not Found "500": description: Internal Server Error /proxy/rodin/api/v2/download: post: summary: Get rodin 3D Assets download list. operationId: rodinDownload x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: multipart/form-data: schema: $ref: "#/components/schemas/Rodin3DDownloadRequest" responses: "200": description: Get the download list for the Rodin 3D Assets. content: application/json: schema: $ref: "#/components/schemas/Rodin3DDownloadResponse" "400": description: Bad Request "401": description: Unauthorized "403": description: Forbidden "404": description: Not Found "500": description: Internal Server Error /proxy/moonvalley/prompts/{prompt_id}: get: x-excluded: true summary: Get Prompt Details parameters: - name: prompt_id in: path required: true schema: type: string responses: "200": description: Prompt details retrieved content: application/json: schema: $ref: "#/components/schemas/MoonvalleyPromptResponse" operationId: MoonvalleyGetPrompt tags: - API Nodes /proxy/moonvalley/prompts/text-to-video: post: x-excluded: true summary: Create Text to Video Prompt requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MoonvalleyTextToVideoRequest" responses: "201": description: Prompt created content: application/json: schema: $ref: "#/components/schemas/MoonvalleyPromptResponse" operationId: MoonvalleyTextToVideo tags: - API Nodes parameters: [] /proxy/moonvalley/prompts/text-to-image: post: x-excluded: true summary: Create Text to Image Prompt requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MoonvalleyTextToImageRequest" responses: "201": description: Prompt created content: application/json: schema: $ref: "#/components/schemas/MoonvalleyPromptResponse" operationId: MoonvalleyTextToImage tags: - API Nodes parameters: [] /proxy/moonvalley/prompts/image-to-video: post: x-excluded: true summary: Create Image to Video Prompt requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MoonvalleyImageToVideoRequest" responses: "201": description: Prompt created content: application/json: schema: $ref: "#/components/schemas/MoonvalleyPromptResponse" operationId: MoonvalleyImageToVideo tags: - API Nodes parameters: [] /proxy/moonvalley/prompts/video-to-video: post: x-excluded: true summary: Create Video to Video Prompt requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MoonvalleyVideoToVideoRequest" responses: "201": description: Prompt created content: application/json: schema: $ref: "#/components/schemas/MoonvalleyPromptResponse" operationId: MoonvalleyVideoToVideo tags: - API Nodes parameters: [] /proxy/moonvalley/prompts/video-to-video/resize: post: x-excluded: true summary: Resize a video requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MoonvalleyResizeVideoRequest" responses: "201": description: Prompt created content: application/json: schema: $ref: "#/components/schemas/MoonvalleyPromptResponse" operationId: MoonvalleyVideoToVideoResize tags: - API Nodes parameters: [] /proxy/moonvalley/uploads: post: x-excluded: true summary: Upload Files requestBody: required: true content: multipart/form-data: schema: $ref: "#/components/schemas/MoonvalleyUploadFileRequest" responses: "200": description: File uploaded successfully content: application/json: schema: $ref: "#/components/schemas/MoonvalleyUploadFileResponse" operationId: MoonvalleyUpload tags: - API Nodes parameters: [] /proxy/vidu/img2video: post: tags: - API Nodes - Released operationId: ViduImg2Video requestBody: content: application/json: schema: $ref: "#/components/schemas/ViduTaskRequest" required: true responses: default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/Error" "200": description: OK content: application/json: schema: $ref: "#/components/schemas/ViduTaskReply" "400": description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/Error" /proxy/vidu/reference2video: post: tags: - API Nodes - Released operationId: ViduReference2Video requestBody: content: application/json: schema: $ref: "#/components/schemas/ViduTaskRequest" required: true responses: default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/Error" "200": description: OK content: application/json: schema: $ref: "#/components/schemas/ViduTaskReply" "400": description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/Error" /proxy/vidu/start-end2video: post: tags: - API Nodes - Released operationId: ViduStartEnd2Video requestBody: content: application/json: schema: $ref: "#/components/schemas/ViduTaskRequest" required: true responses: default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/Error" "200": description: OK content: application/json: schema: $ref: "#/components/schemas/ViduTaskReply" "400": description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/Error" /proxy/vidu/text2video: post: tags: - API Nodes - Released operationId: ViduText2Video requestBody: content: application/json: schema: $ref: "#/components/schemas/ViduTaskRequest" required: true responses: default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/Error" "200": description: OK content: application/json: schema: $ref: "#/components/schemas/ViduTaskReply" "400": description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/Error" /proxy/vidu/extend: post: tags: - API Nodes - Released operationId: ViduExtend requestBody: content: application/json: schema: $ref: "#/components/schemas/ViduExtendRequest" required: true responses: default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/Error" "200": description: OK content: application/json: schema: $ref: "#/components/schemas/ViduExtendReply" "400": description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/Error" /proxy/vidu/multiframe: post: tags: - API Nodes - Released operationId: ViduMultiframe requestBody: content: application/json: schema: $ref: "#/components/schemas/ViduMultiframeRequest" required: true responses: default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/Error" "200": description: OK content: application/json: schema: $ref: "#/components/schemas/ViduMultiframeReply" "400": description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/Error" /proxy/vidu/tasks/{id}/creations: get: tags: - API Nodes - Released operationId: ViduGetCreations parameters: - name: id in: path required: true schema: type: string responses: default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/Error" "200": description: OK content: application/json: schema: $ref: "#/components/schemas/ViduGetCreationsReply" "400": description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/Error" /proxy/byteplus/api/v3/images/generations: post: operationId: byteplusImageGeneration x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/BytePlusImageGenerationRequest" responses: "200": description: Image generation completed successfully content: application/json: schema: $ref: "#/components/schemas/BytePlusImageGenerationResponse" default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/byteplus/api/v3/contents/generations/tasks: post: operationId: byteplusVideoGeneration x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/BytePlusVideoGenerationRequest" responses: "200": description: Video generation task created successfully content: application/json: schema: $ref: "#/components/schemas/BytePlusVideoGenerationResponse" default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/byteplus/api/v3/contents/generations/tasks/{task_id}: get: operationId: byteplusVideoGenerationQuery x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] parameters: - name: task_id in: path required: true schema: type: string description: The ID of the video generation task to query responses: "200": description: Video generation task information retrieved successfully content: application/json: schema: $ref: "#/components/schemas/BytePlusVideoGenerationQueryResponse" default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/wan/api/v1/services/aigc/video-generation/video-synthesis: post: operationId: wanVideoGeneration x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/WanVideoGenerationRequest" responses: "200": description: Video generation task created successfully content: application/json: schema: $ref: "#/components/schemas/WanVideoGenerationResponse" default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/wan/api/v1/services/aigc/text2image/image-synthesis: post: operationId: wanImageGeneration x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/WanImageGenerationRequest" responses: "200": description: Image generation task created successfully content: application/json: schema: $ref: "#/components/schemas/WanImageGenerationResponse" default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/wan/api/v1/services/aigc/image2image/image-synthesis: post: operationId: wanImage2ImageGeneration x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/WanImage2ImageGenerationRequest" responses: "200": description: Image-to-image generation task created successfully content: application/json: schema: $ref: "#/components/schemas/WanImage2ImageGenerationResponse" default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/wan/api/v1/tasks/{task_id}: get: operationId: wanTaskQueryProxy x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] parameters: - in: path name: task_id required: true schema: type: string description: The ID of the generation task to query responses: "200": description: Generation task information retrieved successfully content: application/json: schema: $ref: "#/components/schemas/WanTaskQueryResponse" default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/topaz/image/v1/enhance-gen/async: post: operationId: topazEnhanceGenAsync x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: multipart/form-data: schema: $ref: "#/components/schemas/TopazEnhanceGenRequest" responses: default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/Error" "200": description: Image processing request has been successfully created content: application/json: schema: $ref: "#/components/schemas/TopazEnhanceGenResponse" /proxy/topaz/image/v1/status/{process_id}: get: operationId: topazGetStatus x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] parameters: - in: path name: process_id required: true schema: type: string description: The process ID returned from the enhance-gen request responses: default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/Error" "200": description: Status retrieved successfully content: application/json: schema: $ref: "#/components/schemas/TopazStatusResponse" /proxy/topaz/image/v1/download/{process_id}: get: operationId: topazDownloadResult x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] parameters: - in: path name: process_id required: true schema: type: string description: The process ID returned from the enhance-gen request responses: default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/Error" "200": description: Presigned download URL for the processed image content: application/json: schema: $ref: "#/components/schemas/TopazDownloadResponse" /proxy/topaz/video/: post: operationId: topazVideoCreate x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/TopazVideoCreateRequest" responses: "200": description: Video enhancement request created successfully content: application/json: schema: $ref: "#/components/schemas/TopazVideoCreateResponse" default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/Error" /proxy/topaz/video/{request_id}/accept: patch: operationId: topazVideoAccept x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] parameters: - in: path name: request_id required: true schema: type: string description: The request ID returned from the video create request responses: "200": description: Video request accepted successfully content: application/json: schema: $ref: "#/components/schemas/TopazVideoAcceptResponse" default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/Error" /proxy/topaz/video/{request_id}/complete-upload: patch: operationId: topazVideoCompleteUpload x-excluded: true tags: - API Nodes - Released summary: Complete Video Upload description: | Send metadata of the multi-part uploads to complete the upload and begin processing the video. Optionally include the MD5 hash of the source video file to validate successful upload before processing. security: - BearerAuth: [] parameters: - in: path name: request_id required: true schema: type: string format: uuid description: The request ID returned from the video create request requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/TopazVideoCompleteUploadRequest" responses: "202": description: Video upload completed successfully content: application/json: schema: $ref: "#/components/schemas/TopazVideoCompleteUploadResponse" default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/Error" /proxy/topaz/video/{request_id}/status: get: operationId: topazVideoGetStatus x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] parameters: - in: path name: request_id required: true schema: type: string description: The request ID returned from the video create request responses: "200": description: Video status retrieved successfully content: application/json: schema: $ref: "#/components/schemas/TopazVideoStatusResponse" default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/Error" /proxy/meshy/openapi/v2/text-to-3d: post: summary: Create a Text to 3D Preview Task description: | Create a new Text to 3D Preview task. This task costs 20 credits for Meshy-6 models and 5 credits for other models. operationId: meshyTextTo3DCreate x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MeshyTextTo3DRequest" responses: "200": description: Task created successfully content: application/json: schema: $ref: "#/components/schemas/MeshyTextTo3DCreateResponse" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/Error" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/Error" default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/Error" /proxy/meshy/openapi/v2/text-to-3d/{task_id}: get: summary: Get Text to 3D Task Status description: Retrieve the status and result of a Text to 3D task. operationId: meshyTextTo3DGetTask x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] parameters: - name: task_id in: path required: true schema: type: string description: The unique identifier of the task responses: "200": description: Task retrieved successfully content: application/json: schema: $ref: "#/components/schemas/MeshyTextTo3DTask" "404": description: Task not found content: application/json: schema: $ref: "#/components/schemas/Error" default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/Error" /proxy/meshy/openapi/v1/image-to-3d: post: summary: Create an Image to 3D Task description: | Create a new Image to 3D task. This task generates a 3D model from an image input. operationId: meshyImageTo3DCreate x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MeshyImageTo3DRequest" responses: "200": description: Task created successfully content: application/json: schema: $ref: "#/components/schemas/MeshyImageTo3DCreateResponse" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/Error" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/Error" default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/Error" /proxy/meshy/openapi/v1/image-to-3d/{task_id}: get: summary: Get Image to 3D Task Status description: Retrieve the status and result of an Image to 3D task. operationId: meshyImageTo3DGetTask x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] parameters: - name: task_id in: path required: true schema: type: string description: The unique identifier of the task responses: "200": description: Task retrieved successfully content: application/json: schema: $ref: "#/components/schemas/MeshyImageTo3DTask" "404": description: Task not found content: application/json: schema: $ref: "#/components/schemas/Error" default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/Error" /proxy/meshy/openapi/v1/multi-image-to-3d: post: summary: Create a Multi-Image to 3D Task description: | Create a new Multi-Image to 3D task. This task generates a 3D model from 1 to 4 images of the same object from different angles. Mesh generation uses Meshy-5 model, while texture generation supports Meshy-6-preview model. operationId: meshyMultiImageTo3DCreate x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MeshyMultiImageTo3DRequest" responses: "200": description: Task created successfully content: application/json: schema: $ref: "#/components/schemas/MeshyMultiImageTo3DCreateResponse" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/Error" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/Error" default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/Error" /proxy/meshy/openapi/v1/multi-image-to-3d/{task_id}: get: summary: Get Multi-Image to 3D Task Status description: Retrieve the status and result of a Multi-Image to 3D task. operationId: meshyMultiImageTo3DGetTask x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] parameters: - name: task_id in: path required: true schema: type: string description: The unique identifier of the task responses: "200": description: Task retrieved successfully content: application/json: schema: $ref: "#/components/schemas/MeshyMultiImageTo3DTask" "404": description: Task not found content: application/json: schema: $ref: "#/components/schemas/Error" default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/Error" /proxy/meshy/openapi/v1/remesh: post: summary: Create a Remesh Task description: | Create a new remesh task to remesh and export an existing 3D model into various formats. operationId: meshyRemeshCreate x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MeshyRemeshRequest" responses: "200": description: Task created successfully content: application/json: schema: $ref: "#/components/schemas/MeshyRemeshCreateResponse" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/Error" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/Error" default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/Error" /proxy/meshy/openapi/v1/remesh/{task_id}: get: summary: Get Remesh Task Status description: Retrieve the status and result of a Remesh task. operationId: meshyRemeshGetTask x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] parameters: - name: task_id in: path required: true schema: type: string description: The unique identifier of the task responses: "200": description: Task retrieved successfully content: application/json: schema: $ref: "#/components/schemas/MeshyRemeshTask" "404": description: Task not found content: application/json: schema: $ref: "#/components/schemas/Error" default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/Error" /proxy/meshy/openapi/v1/rigging: post: summary: Create a Rigging Task description: | Create a new rigging task for a given 3D model. Upon successful completion, provides a rigged character in standard formats and optionally basic walking/running animations. operationId: meshyRiggingCreate x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MeshyRiggingRequest" responses: "200": description: Task created successfully content: application/json: schema: $ref: "#/components/schemas/MeshyRiggingCreateResponse" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/Error" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/Error" default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/Error" /proxy/meshy/openapi/v1/rigging/{task_id}: get: summary: Get Rigging Task Status description: Retrieve the status and result of a Rigging task. operationId: meshyRiggingGetTask x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] parameters: - name: task_id in: path required: true schema: type: string description: The unique identifier of the task responses: "200": description: Task retrieved successfully content: application/json: schema: $ref: "#/components/schemas/MeshyRiggingTask" "404": description: Task not found content: application/json: schema: $ref: "#/components/schemas/Error" default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/Error" /proxy/meshy/openapi/v1/retexture: post: summary: Create a Retexture Task description: | Create a new Retexture task to generate 3D texture from text or image inputs. operationId: meshyRetextureCreate x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MeshyRetextureRequest" responses: "200": description: Task created successfully content: application/json: schema: $ref: "#/components/schemas/MeshyRetextureCreateResponse" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/Error" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/Error" default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/Error" /proxy/meshy/openapi/v1/retexture/{task_id}: get: summary: Get Retexture Task Status description: Retrieve the status and result of a Retexture task. operationId: meshyRetextureGetTask x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] parameters: - name: task_id in: path required: true schema: type: string description: The unique identifier of the task responses: "200": description: Task retrieved successfully content: application/json: schema: $ref: "#/components/schemas/MeshyRetextureTask" "404": description: Task not found content: application/json: schema: $ref: "#/components/schemas/Error" default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/Error" /proxy/meshy/openapi/v1/animations: post: summary: Create an Animation Task description: | Create a new task to apply a specific animation action to a previously rigged character. Includes post-processing options. operationId: meshyAnimationCreate x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MeshyAnimationRequest" responses: "200": description: Task created successfully content: application/json: schema: $ref: "#/components/schemas/MeshyAnimationCreateResponse" "400": description: Invalid request parameters content: application/json: schema: $ref: "#/components/schemas/Error" "401": description: Authentication failed content: application/json: schema: $ref: "#/components/schemas/Error" default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/Error" /proxy/meshy/openapi/v1/animations/{task_id}: get: summary: Get Animation Task Status description: Retrieve the status and result of an Animation task. operationId: meshyAnimationGetTask x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] parameters: - name: task_id in: path required: true schema: type: string description: The unique identifier of the task responses: "200": description: Task retrieved successfully content: application/json: schema: $ref: "#/components/schemas/MeshyAnimationTask" "404": description: Task not found content: application/json: schema: $ref: "#/components/schemas/Error" default: description: Error 4xx/5xx content: application/json: schema: $ref: "#/components/schemas/Error" /proxy/xai/v1/images/generations: post: summary: Generate images using xAI Grok Imagine description: Generate one or more images from a text prompt using the Grok Imagine API. operationId: xaiImageGenerate x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/XAIImageGenerationRequest" responses: "200": description: Images generated successfully content: application/json: schema: $ref: "#/components/schemas/XAIImageGenerationResponse" "400": description: Bad request content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "402": description: Payment Required "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/xai/v1/images/edits: post: summary: Edit images using xAI Grok Imagine description: Modify an existing image based on a text prompt using the Grok Imagine API. operationId: xaiImageEdit x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/XAIImageEditRequest" responses: "200": description: Image edited successfully content: application/json: schema: $ref: "#/components/schemas/XAIImageGenerationResponse" "400": description: Bad request content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "402": description: Payment Required "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/xai/v1/videos/generations: post: summary: Generate videos using xAI Grok Imagine description: | Generate a video from a text prompt (text-to-video) or from an image with optional text (image-to-video). Video generation is asynchronous. Returns a request_id to poll for the completed video. operationId: xaiVideoGenerate x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/XAIVideoGenerationRequest" responses: "200": description: Video generation job created successfully content: application/json: schema: $ref: "#/components/schemas/XAIVideoAsyncResponse" "400": description: Bad request content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "402": description: Payment Required "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/xai/v1/videos/edits: post: summary: Edit videos using xAI Grok Imagine description: | Edit an existing video based on a text prompt (video-to-video editing). Video editing is asynchronous. Returns a request_id to poll for the completed video. Input video limit is 8 seconds. Audio will not be modified. operationId: xaiVideoEdit x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/XAIVideoEditRequest" responses: "200": description: Video editing job created successfully content: application/json: schema: $ref: "#/components/schemas/XAIVideoAsyncResponse" "400": description: Bad request content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "402": description: Payment Required "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/xai/v1/videos/{request_id}: get: summary: Get xAI video generation result description: | Retrieve the result of a video generation or editing request. Poll this endpoint until the response includes a video object with the completed video URL. operationId: xaiVideoGetResult x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] parameters: - name: request_id in: path required: true schema: type: string description: The request ID returned by the video generation or editing endpoint responses: "200": description: Video generation result content: application/json: schema: $ref: "#/components/schemas/XAIVideoResultResponse" "202": description: Video generation still pending content: application/json: schema: $ref: "#/components/schemas/XAIVideoResultResponse" "401": description: Unauthorized "402": description: Payment Required "404": description: Request ID not found "500": description: Internal server error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/reve/v1/image/create: post: summary: Generate an image using Reve description: Forwards image creation requests to the Reve API and returns the generated image. operationId: reveImageCreate x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ReveImageCreateRequest" responses: "200": description: Successful response from Reve proxy content: application/json: schema: $ref: "#/components/schemas/ReveImageResponse" "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "402": description: Payment Required "429": description: Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal Server Error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/reve/v1/image/edit: post: summary: Edit an image using Reve description: Forwards image editing requests to the Reve API with an edit instruction and reference image. operationId: reveImageEdit x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ReveImageEditRequest" responses: "200": description: Successful response from Reve proxy content: application/json: schema: $ref: "#/components/schemas/ReveImageResponse" "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "402": description: Payment Required "429": description: Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal Server Error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/reve/v1/image/remix: post: summary: Remix images using Reve description: Forwards image remix requests to the Reve API with reference images and a text prompt. operationId: reveImageRemix x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ReveImageRemixRequest" responses: "200": description: Successful response from Reve proxy content: application/json: schema: $ref: "#/components/schemas/ReveImageResponse" "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "402": description: Payment Required "429": description: Rate limit exceeded content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal Server Error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/bria/v2/image/edit: post: summary: Edit an image using Bria FIBO description: | Edit an existing image using Bria's FIBO Edit API. You can provide: 1. A source image and a text-based instruction (prompt) 2. A source image and a structured_instruction 3. A source image, a mask, and a text-based instruction 4. A source image, a mask, and a structured_instruction This endpoint always uses async mode (sync: false) and returns a status_url to poll for results. operationId: briaFiboEdit x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/BriaFiboEditRequest" responses: "202": description: Request accepted, processing asynchronously content: application/json: schema: $ref: "#/components/schemas/BriaAsyncResponse" "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "402": description: Payment Required content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "422": description: Content moderation failure content: application/json: schema: $ref: "#/components/schemas/BriaErrorResponse" "500": description: Internal Server Error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/bria/v2/structured_instruction/generate: post: summary: Generate a structured instruction from text description: | Translates a user's text-based edit instruction and source image/mask into a detailed, machine-readable structured edit instruction in JSON format. This endpoint uses Gemini 2.5 Flash VLM to understand the edit context and returns only the JSON string without generating an image. The resulting structured_instruction can be used as input for the /proxy/bria/v2/image/edit endpoint. This endpoint always uses async mode (sync: false) and returns a status_url to poll for results. operationId: briaStructuredInstructionGenerate x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/BriaStructuredInstructionRequest" responses: "202": description: Request accepted, processing asynchronously content: application/json: schema: $ref: "#/components/schemas/BriaAsyncResponse" "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "402": description: Payment Required content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "422": description: Content moderation failure content: application/json: schema: $ref: "#/components/schemas/BriaErrorResponse" "500": description: Internal Server Error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/bria/v2/status/{request_id}: get: summary: Get Bria request status description: | Retrieves the current status of an asynchronous Bria request. Poll this endpoint until the status is COMPLETED or ERROR. Status values: - `IN_PROGRESS` – Request is being processed. Continue polling. - `COMPLETED` – Success. Response includes `result.image_url` for images, `result.video_url` for videos, or `result.structured_prompt` for structured prompt generation. Additional optional fields (seed, prompt, refined_prompt) may be included. - `ERROR` – Processing failed. Check error object for details. - `UNKNOWN` – Unexpected internal error. operationId: briaGetStatus x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] parameters: - name: request_id in: path required: true schema: type: string description: Unique identifier of the request (returned from edit, generate, or remove_background endpoints) responses: "200": description: Status retrieved successfully content: application/json: schema: $ref: "#/components/schemas/BriaStatusResponse" "401": description: Unauthorized "402": description: Payment Required content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Request ID not found or expired content: application/json: schema: $ref: "#/components/schemas/BriaStatusNotFoundResponse" "500": description: Internal Server Error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/bria/v2/video/edit/remove_background: post: summary: Remove background from a video using Bria description: | Initiates an asynchronous background removal job for a video using Bria's API. Returns HTTP 202 with request_id and status_url. Poll the status endpoint for results. Supported input containers: .mp4, .mov, .webm, .avi, .gif Supported input codecs: H.264, H.265 (HEVC), VP9, AV1, PhotoJPEG Max input duration: 60 seconds. Input resolution up to 16000x16000. operationId: briaVideoRemoveBackground x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/BriaVideoRemoveBackgroundRequest" responses: "202": description: Request accepted, processing asynchronously content: application/json: schema: $ref: "#/components/schemas/BriaAsyncResponse" "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/BriaErrorResponse" "401": description: Unauthorized "402": description: Payment Required content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "422": description: Unprocessable Entity content: application/json: schema: $ref: "#/components/schemas/BriaErrorResponse" "500": description: Internal Server Error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/bria/v2/image/edit/remove_background: post: summary: Remove background from an image using Bria description: | Remove the background of an image using Bria's RMBG 2.0 model. Returns HTTP 202 with request_id and status_url when async (default). Can return 200 with result directly when sync is true. Accepted image formats: JPEG, JPG, PNG, WEBP. operationId: briaImageRemoveBackground x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/BriaImageRemoveBackgroundRequest" responses: "202": description: Request accepted, processing asynchronously content: application/json: schema: $ref: "#/components/schemas/BriaAsyncResponse" "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/BriaErrorResponse" "401": description: Unauthorized "402": description: Payment Required content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "422": description: Content moderation failure content: application/json: schema: $ref: "#/components/schemas/BriaErrorResponse" "500": description: Internal Server Error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/wavespeed/api/v3/wavespeed-ai/flashvsr: post: summary: Submit a FlashVSR video upscaling task description: | Submit a video for upscaling using WavespeedAI's FlashVSR model. FlashVSR is a fast, high-quality video upscaler that boosts resolution and restores clarity for low-resolution or blurry footage. Supported target resolutions: 720p, 1080p, 2k, 4k Max clip length: up to 10 minutes Processing speed: approximately 3-20 seconds of wall time to process 1 second of video Returns a task ID that can be used to poll for the result. operationId: wavespeedFlashVSRSubmit x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/WavespeedFlashVSRRequest" responses: "200": description: Task submitted successfully content: application/json: schema: $ref: "#/components/schemas/WavespeedTaskResponse" "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "402": description: Payment Required content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal Server Error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/wavespeed/api/v3/predictions/{prediction_id}/result: get: summary: Get FlashVSR task result description: | Retrieve the status and result of a FlashVSR video upscaling task. Poll this endpoint until status is "completed" or "failed". Status values: - `created` - Task has been created - `processing` - Task is being processed - `completed` - Task completed successfully, outputs array contains result URLs - `failed` - Task failed, check error field for details operationId: wavespeedFlashVSRGetResult x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] parameters: - name: prediction_id in: path required: true schema: type: string description: The unique identifier of the prediction/task responses: "200": description: Task result retrieved successfully content: application/json: schema: $ref: "#/components/schemas/WavespeedTaskResultResponse" "401": description: Unauthorized "402": description: Payment Required content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Task not found content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal Server Error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/wavespeed/api/v3/wavespeed-ai/seedvr2/image: post: summary: Submit a SeedVR2 image upscaling task description: | Upscale an image using WavespeedAI's SeedVR2 Image Upscaler. SeedVR2 boosts image resolution and quality, upscaling photos to 2K, 4K, or 8K for sharp, detailed results. operationId: wavespeedSeedVR2ImageSubmit x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/WavespeedSeedVR2ImageRequest" responses: "200": description: Task submitted successfully content: application/json: schema: $ref: "#/components/schemas/WavespeedTaskResponse" "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "402": description: Payment Required content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal Server Error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/wavespeed/api/v3/wavespeed-ai/ultimate-image-upscaler: post: summary: Submit an Ultimate Image Upscaler task description: | Upscale an image using WavespeedAI's Ultimate Image Upscaler. The most advanced AI enhancer that reimagines fine detail while upscaling images to 2K, 4K, or 8K. operationId: wavespeedUltimateImageUpscalerSubmit x-excluded: true tags: - API Nodes - Released security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/WavespeedSeedVR2ImageRequest" responses: "200": description: Task submitted successfully content: application/json: schema: $ref: "#/components/schemas/WavespeedTaskResponse" "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "401": description: Unauthorized "402": description: Payment Required content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal Server Error content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /proxy/tencent/hunyuan/3d-pro: post: summary: Submit Tencent Hunyuan 3D Pro Generation Task description: | Submit a task to generate 3D content using Tencent HunYuan Large Model. Supports text-to-3D and image-to-3D generation. This API provides 3 concurrent tasks by default. A new task can be processed only after the previous one is completed. The returned JobId can be used with the query endpoint to check task status. operationId: tencentHunyuan3DProSubmit x-excluded: true tags: - API Nodes - Tencent security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/TencentHunyuan3DProRequest" responses: "200": description: Task submitted successfully content: application/json: schema: $ref: "#/components/schemas/TencentHunyuan3DProResponse" "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/TencentErrorResponse" "401": description: Unauthorized "402": description: Payment Required content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal Server Error content: application/json: schema: $ref: "#/components/schemas/TencentErrorResponse" /proxy/tencent/hunyuan/3d-pro/query: post: summary: Query Tencent Hunyuan 3D Pro Task Status description: | Query the status and result of a previously submitted 3D generation task. Poll this endpoint until the task status indicates completion. operationId: tencentHunyuan3DProQuery x-excluded: true tags: - API Nodes - Tencent security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/TencentHunyuan3DQueryRequest" responses: "200": description: Task status retrieved successfully content: application/json: schema: $ref: "#/components/schemas/TencentHunyuan3DQueryResponse" "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/TencentErrorResponse" "401": description: Unauthorized "500": description: Internal Server Error content: application/json: schema: $ref: "#/components/schemas/TencentErrorResponse" /proxy/tencent/hunyuan/3d-uv: post: summary: Submit Tencent Hunyuan 3D UV Unfolding Task description: | Submit a UV unwrapping task for a 3D model using Tencent Hunyuan. After inputting the model, UV unwrapping can be performed based on the model texture to output the corresponding UV map. The returned JobId can be used with the query endpoint to check task status. operationId: tencentHunyuan3DUVSubmit x-excluded: true tags: - API Nodes - Tencent security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/TencentHunyuan3DUVRequest" responses: "200": description: Task submitted successfully content: application/json: schema: $ref: "#/components/schemas/TencentHunyuan3DUVResponse" "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/TencentErrorResponse" "401": description: Unauthorized "402": description: Payment Required content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal Server Error content: application/json: schema: $ref: "#/components/schemas/TencentErrorResponse" /proxy/tencent/hunyuan/3d-uv/query: post: summary: Query Tencent Hunyuan 3D UV Unfolding Task Status description: | Query the status and result of a previously submitted UV unwrapping task. Poll this endpoint until the task status indicates completion. operationId: tencentHunyuan3DUVQuery x-excluded: true tags: - API Nodes - Tencent security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/TencentHunyuan3DQueryRequest" responses: "200": description: Task status retrieved successfully content: application/json: schema: $ref: "#/components/schemas/TencentHunyuan3DQueryResponse" "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/TencentErrorResponse" "401": description: Unauthorized "500": description: Internal Server Error content: application/json: schema: $ref: "#/components/schemas/TencentErrorResponse" /proxy/tencent/hunyuan/3d-texture-edit: post: summary: Submit Tencent Hunyuan 3D Texture Edit Task description: | Submit a 3D model texture redrawing task using Tencent Hunyuan. After inputting the 3D model, perform 3D model texture redrawing based on semantics or images. Supported format: FBX. 3D model limit: less than 100000 faces. Either Image or Prompt is required; they cannot coexist. EnablePBR only supports enabling when using Prompt. The returned JobId can be used with the query endpoint to check task status. operationId: tencentHunyuan3DTextureEditSubmit x-excluded: true tags: - API Nodes - Tencent security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/TencentHunyuan3DTextureEditRequest" responses: "200": description: Task submitted successfully content: application/json: schema: $ref: "#/components/schemas/TencentHunyuan3DUVResponse" "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/TencentErrorResponse" "401": description: Unauthorized "402": description: Payment Required content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal Server Error content: application/json: schema: $ref: "#/components/schemas/TencentErrorResponse" /proxy/tencent/hunyuan/3d-texture-edit/query: post: summary: Query Tencent Hunyuan 3D Texture Edit Task Status description: | Query the status and result of a previously submitted 3D texture edit task. Poll this endpoint until the task status indicates completion. operationId: tencentHunyuan3DTextureEditQuery x-excluded: true tags: - API Nodes - Tencent security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/TencentHunyuan3DQueryRequest" responses: "200": description: Task status retrieved successfully content: application/json: schema: $ref: "#/components/schemas/TencentHunyuan3DQueryResponse" "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/TencentErrorResponse" "401": description: Unauthorized "500": description: Internal Server Error content: application/json: schema: $ref: "#/components/schemas/TencentErrorResponse" /proxy/tencent/hunyuan/3d-part: post: summary: Submit Tencent Hunyuan 3D Part (Component Splitting) Task description: | Submit a component identification and generation task using Tencent Hunyuan. Automatically performs component splitting based on the model structure after inputting a 3D model file. Recommends inputting 3D models generated by AIGC. File size not greater than 100MB, face count not greater than 30,000. FBX format only. The returned JobId can be used with the query endpoint to check task status. operationId: tencentHunyuan3DPartSubmit x-excluded: true tags: - API Nodes - Tencent security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/TencentHunyuan3DUVRequest" responses: "200": description: Task submitted successfully content: application/json: schema: $ref: "#/components/schemas/TencentHunyuan3DUVResponse" "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/TencentErrorResponse" "401": description: Unauthorized "402": description: Payment Required content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal Server Error content: application/json: schema: $ref: "#/components/schemas/TencentErrorResponse" /proxy/tencent/hunyuan/3d-part/query: post: summary: Query Tencent Hunyuan 3D Part Task Status description: | Query the status and result of a previously submitted 3D part (component splitting) task. Poll this endpoint until the task status indicates completion. operationId: tencentHunyuan3DPartQuery x-excluded: true tags: - API Nodes - Tencent security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/TencentHunyuan3DQueryRequest" responses: "200": description: Task status retrieved successfully content: application/json: schema: $ref: "#/components/schemas/TencentHunyuan3DQueryResponse" "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/TencentErrorResponse" "401": description: Unauthorized "500": description: Internal Server Error content: application/json: schema: $ref: "#/components/schemas/TencentErrorResponse" /proxy/tencent/hunyuan/3d-smart-topology: post: summary: Submit Tencent Hunyuan 3D Smart Topology Task description: | Submit a 3D smart topology (retopology/polygon reduction) task using Tencent Hunyuan. Takes an input 3D model and performs intelligent topology optimization. Supported input formats: GLB, OBJ. File size max 200MB. The returned JobId can be used with the query endpoint to check task status. operationId: tencentHunyuan3DSmartTopologySubmit x-excluded: true tags: - API Nodes - Tencent security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/TencentHunyuan3DSmartTopologyRequest" responses: "200": description: Task submitted successfully content: application/json: schema: $ref: "#/components/schemas/TencentHunyuan3DUVResponse" "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/TencentErrorResponse" "401": description: Unauthorized "402": description: Payment Required content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal Server Error content: application/json: schema: $ref: "#/components/schemas/TencentErrorResponse" /proxy/tencent/hunyuan/3d-smart-topology/query: post: summary: Query Tencent Hunyuan 3D Smart Topology Task Status description: | Query the status and result of a previously submitted 3D smart topology task. Poll this endpoint until the task status indicates completion. operationId: tencentHunyuan3DSmartTopologyQuery x-excluded: true tags: - API Nodes - Tencent security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/TencentHunyuan3DQueryRequest" responses: "200": description: Task status retrieved successfully content: application/json: schema: $ref: "#/components/schemas/TencentHunyuan3DQueryResponse" "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/TencentErrorResponse" "401": description: Unauthorized "500": description: Internal Server Error content: application/json: schema: $ref: "#/components/schemas/TencentErrorResponse" /proxy/hitpaw/api/photo-enhancer: post: summary: Submit HitPaw Photo Enhancement Task description: | Submit an image processing task using HitPaw Photo Enhancement API. Supports multiple enhancement models for image super-resolution processing. The returned job_id can be used with the task-status endpoint to check processing results. **Available Models:** - Enhancement & Denoise Models (face_2x/4x, face_v2_2x/4x, general_2x/4x, high_fidelity_2x/4x, sharpen_denoise, detail_denoise): - Max input: 67 MP, Max output: 600 MP - Supported formats: bmp, jpeg, jpg, png, jfif, tga, tiff, webp, heif - Generative Models (generative_portrait, generative): - No input limit, Max output: 8K (33 MP) - Supported formats: bmp, jpeg, jpg, png, jfif, tga, tiff, webp, heif operationId: hitpawPhotoEnhancer x-excluded: true tags: - API Nodes - HitPaw security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/HitPawPhotoEnhancerRequest" responses: "200": description: Task submitted successfully content: application/json: schema: $ref: "#/components/schemas/HitPawJobResponse" "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/HitPawErrorResponse" "401": description: Unauthorized "402": description: Payment Required content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Internal Server Error content: application/json: schema: $ref: "#/components/schemas/HitPawErrorResponse" /proxy/hitpaw/api/task-status: post: summary: Query HitPaw Task Status description: | Query the status and result of a previously submitted photo or video enhancement task. Poll this endpoint until the task status indicates completion (COMPLETED). **Status Codes:** - CONVERTING: Job is currently being processed - COMPLETED: Job has completed successfully, result is available - ERROR: Job failed due to an error operationId: hitpawTaskStatus x-excluded: true tags: - API Nodes - HitPaw security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/HitPawTaskStatusRequest" responses: "200": description: Task status retrieved successfully content: application/json: schema: $ref: "#/components/schemas/HitPawTaskStatusResponse" "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/HitPawErrorResponse" "401": description: Unauthorized "500": description: Internal Server Error content: application/json: schema: $ref: "#/components/schemas/HitPawErrorResponse" /proxy/hitpaw/api/video-enhancer: post: summary: Submit HitPaw Video Enhancement Task description: | Submit a video processing task using HitPaw Video Enhancement API. Uses AI technology to upscale low-resolution videos to high resolution, eliminate artifacts and noise, and improve clarity and details. The returned job_id can be used with the task-status endpoint to check processing results. **Video Constraints:** - Duration: 0.5 seconds to 1 hour - Maximum output resolution: 36 MP (Total Pixels) - Supported input formats: dv, mlv, m2ts, m2t, m2v, nut, ser, 3g2, 3gp, asf, divx, f4v, h261, h263, m4v, mkv, mov, mp4, mpeg, mpeg4, mpg, mxf, ogv, rm, rmvb, webm, wmv, dmsm, dvdmedia, dvr-ms, mts, trp, ts, vob, vro, gif, xvid - Supported output formats: mp4, mov, mkv, m4v, avi, gif operationId: hitpawVideoEnhancer x-excluded: true tags: - API Nodes - HitPaw security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/HitPawVideoEnhancerRequest" responses: "200": description: Task submitted successfully content: application/json: schema: $ref: "#/components/schemas/HitPawJobResponse" "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/HitPawErrorResponse" "401": description: Unauthorized "402": description: Payment Required - Insufficient credits content: application/json: schema: $ref: "#/components/schemas/HitPawErrorResponse" "500": description: Internal Server Error content: application/json: schema: $ref: "#/components/schemas/HitPawErrorResponse" /proxy/elevenlabs/v1/text-to-speech/{voice_id}: post: summary: ElevenLabs Text to Speech description: | Converts text into speech using a specified voice and returns audio. The output format can be specified via the output_format query parameter. Supported formats include MP3, PCM, μ-law, and Opus with various sample rates and bitrates. operationId: ElevenLabsTextToSpeech x-excluded: true tags: - API Nodes - ElevenLabs security: - BearerAuth: [] parameters: - name: voice_id in: path description: ID of the voice to use. Use the Get voices endpoint to list all available voices. required: true schema: type: string - name: enable_logging in: query description: When set to false, enables zero retention mode (enterprise only). History features will be unavailable. required: false schema: type: boolean default: true - name: optimize_streaming_latency in: query description: | Deprecated. Latency optimization levels (0-4): 0 - default mode (no latency optimizations) 1 - normal latency optimizations (~50% improvement) 2 - strong latency optimizations (~75% improvement) 3 - max latency optimizations 4 - max latency with text normalizer off (best latency but may mispronounce) required: false schema: type: integer minimum: 0 maximum: 4 - name: output_format in: query description: | Output format of the generated audio. Formatted as codec_sample_rate_bitrate. Examples: mp3_22050_32, mp3_44100_128, pcm_16000, pcm_22050, ulaw_8000 required: false schema: type: string enum: - mp3_22050_32 - mp3_44100_32 - mp3_44100_64 - mp3_44100_96 - mp3_44100_128 - mp3_44100_192 - pcm_8000 - pcm_16000 - pcm_22050 - pcm_24000 - pcm_32000 - pcm_44100 - pcm_48000 - ulaw_8000 - alaw_8000 - opus_48000_32 - opus_48000_64 - opus_48000_96 - opus_48000_128 - opus_48000_192 - wav_8000 - wav_16000 - wav_22050 - wav_24000 - wav_32000 - wav_44100 - wav_48000 default: mp3_44100_128 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ElevenLabsTTSRequest" responses: "200": description: The generated audio file content: audio/mpeg: schema: type: string format: binary audio/wav: schema: type: string format: binary audio/ogg: schema: type: string format: binary "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/Error" "401": description: Unauthorized "422": description: Validation Error content: application/json: schema: $ref: "#/components/schemas/ElevenLabsValidationError" /proxy/elevenlabs/v1/speech-to-text: post: summary: Create transcript (Speech-to-Text) description: | Transcribe an audio or video file. If webhook is set to true, the request will be processed asynchronously and results sent to configured webhooks. When use_multi_channel is true and the provided audio has multiple channels, a 'transcripts' object with separate transcripts for each channel is returned. Otherwise, returns a single transcript. The optional webhook_metadata parameter allows you to attach custom data that will be included in webhook responses for request correlation and tracking. operationId: ElevenLabsSpeechToText x-excluded: true tags: - API Nodes - ElevenLabs security: - BearerAuth: [] parameters: - name: enable_logging in: query description: | When enable_logging is set to false zero retention mode will be used for the request. This will mean log and transcript storage features are unavailable for this request. Zero retention mode may only be used by enterprise customers. required: false schema: type: boolean default: true requestBody: required: true content: multipart/form-data: schema: $ref: "#/components/schemas/ElevenLabsSTTRequest" responses: "200": description: Synchronous transcription result content: application/json: schema: $ref: "#/components/schemas/ElevenLabsSTTResponse" "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/Error" "401": description: Unauthorized "422": description: Validation Error content: application/json: schema: $ref: "#/components/schemas/ElevenLabsValidationError" /proxy/elevenlabs/v1/speech-to-speech/{voice_id}: post: summary: Voice Changer (Speech-to-Speech) description: | Transform audio from one voice to another. Maintain full control over emotion, timing and delivery. operationId: ElevenLabsSpeechToSpeech x-excluded: true tags: - API Nodes - ElevenLabs security: - BearerAuth: [] parameters: - name: voice_id in: path description: ID of the voice to be used. Use the Get voices endpoint to list all available voices. required: true schema: type: string - name: enable_logging in: query description: | When enable_logging is set to false zero retention mode will be used for the request. This will mean history features are unavailable for this request, including request stitching. Zero retention mode may only be used by enterprise customers. required: false schema: type: boolean default: true - name: optimize_streaming_latency in: query description: | Latency optimization levels (0-4): 0 - default mode (no latency optimizations) 1 - normal latency optimizations (~50% improvement) 2 - strong latency optimizations (~75% improvement) 3 - max latency optimizations 4 - max latency with text normalizer off (best latency but may mispronounce) required: false schema: type: integer nullable: true - name: output_format in: query description: | Output format of the generated audio. Formatted as codec_sample_rate_bitrate. Examples: mp3_22050_32, mp3_44100_128, pcm_16000, ulaw_8000 required: false schema: type: string enum: - mp3_22050_32 - mp3_24000_48 - mp3_44100_32 - mp3_44100_64 - mp3_44100_96 - mp3_44100_128 - mp3_44100_192 - pcm_8000 - pcm_16000 - pcm_22050 - pcm_24000 - pcm_32000 - pcm_44100 - pcm_48000 - ulaw_8000 - alaw_8000 - opus_48000_32 - opus_48000_64 - opus_48000_96 - opus_48000_128 - opus_48000_192 default: mp3_44100_128 requestBody: required: true content: multipart/form-data: schema: $ref: "#/components/schemas/ElevenLabsSpeechToSpeechRequest" responses: "200": description: The generated audio file content: audio/mpeg: schema: type: string format: binary audio/wav: schema: type: string format: binary audio/ogg: schema: type: string format: binary application/octet-stream: schema: type: string format: binary "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/Error" "401": description: Unauthorized "422": description: Validation Error content: application/json: schema: $ref: "#/components/schemas/ElevenLabsValidationError" /proxy/elevenlabs/v1/audio-isolation: post: summary: Audio Isolation description: | Removes background noise from audio. Isolates vocals/speech from background sounds. operationId: ElevenLabsAudioIsolation x-excluded: true tags: - API Nodes - ElevenLabs security: - BearerAuth: [] requestBody: required: true content: multipart/form-data: schema: $ref: "#/components/schemas/ElevenLabsAudioIsolationRequest" responses: "200": description: The isolated audio file content: audio/mpeg: schema: type: string format: binary application/octet-stream: schema: type: string format: binary "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/Error" "401": description: Unauthorized "422": description: Validation Error content: application/json: schema: $ref: "#/components/schemas/ElevenLabsValidationError" /proxy/elevenlabs/v1/voices/add: post: summary: Create Voice Clone description: | Create an instant voice clone and add it to your Voices. operationId: ElevenLabsCreateVoice x-excluded: true tags: - API Nodes - ElevenLabs security: - BearerAuth: [] requestBody: required: true content: multipart/form-data: schema: $ref: "#/components/schemas/ElevenLabsCreateVoiceRequest" responses: "200": description: Voice created successfully content: application/json: schema: type: object properties: voice_id: type: string requires_verification: type: boolean "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/Error" "401": description: Unauthorized "422": description: Validation Error content: application/json: schema: $ref: "#/components/schemas/ElevenLabsValidationError" /proxy/elevenlabs/v1/sound-generation: post: summary: Create Sound Effect description: | Turn text into sound effects for your videos, voice-overs or video games using the most advanced sound effects models in the world. operationId: ElevenLabsSoundGeneration x-excluded: true tags: - API Nodes - ElevenLabs security: - BearerAuth: [] parameters: - name: output_format in: query description: | Output format of the generated audio. Formatted as codec_sample_rate_bitrate. Examples: mp3_22050_32, mp3_44100_128, pcm_16000, ulaw_8000 required: false schema: type: string enum: - mp3_22050_32 - mp3_24000_48 - mp3_44100_32 - mp3_44100_64 - mp3_44100_96 - mp3_44100_128 - mp3_44100_192 - pcm_8000 - pcm_16000 - pcm_22050 - pcm_24000 - pcm_32000 - pcm_44100 - pcm_48000 - ulaw_8000 - alaw_8000 - opus_48000_32 - opus_48000_64 - opus_48000_96 - opus_48000_128 - opus_48000_192 default: mp3_44100_128 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ElevenLabsSoundGenerationRequest" responses: "200": description: The generated sound effect audio file content: audio/mpeg: schema: type: string format: binary application/octet-stream: schema: type: string format: binary "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/Error" "401": description: Unauthorized "422": description: Validation Error content: application/json: schema: $ref: "#/components/schemas/ElevenLabsValidationError" /proxy/elevenlabs/v1/text-to-dialogue: post: summary: Create dialogue (Multi-voice TTS) description: | Converts a list of text and voice ID pairs into speech (dialogue) and returns audio. Useful for generating conversations between multiple characters. operationId: ElevenLabsTextToDialogue x-excluded: true tags: - API Nodes - ElevenLabs security: - BearerAuth: [] parameters: - name: output_format in: query description: | Output format of the generated audio. Formatted as codec_sample_rate_bitrate. Examples: mp3_22050_32, mp3_44100_128, pcm_16000, ulaw_8000 required: false schema: type: string enum: - mp3_22050_32 - mp3_24000_48 - mp3_44100_32 - mp3_44100_64 - mp3_44100_96 - mp3_44100_128 - mp3_44100_192 - pcm_8000 - pcm_16000 - pcm_22050 - pcm_24000 - pcm_32000 - pcm_44100 - pcm_48000 - ulaw_8000 - alaw_8000 - opus_48000_32 - opus_48000_64 - opus_48000_96 - opus_48000_128 - opus_48000_192 default: mp3_44100_128 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ElevenLabsTextToDialogueRequest" responses: "200": description: The generated audio file content: audio/mpeg: schema: type: string format: binary audio/wav: schema: type: string format: binary audio/ogg: schema: type: string format: binary application/octet-stream: schema: type: string format: binary "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/Error" "401": description: Unauthorized "422": description: Validation Error content: application/json: schema: $ref: "#/components/schemas/ElevenLabsValidationError" /features: get: tags: - Registry summary: Get server feature flags description: Returns the server's feature capabilities operationId: getFeatures responses: '200': description: Success content: application/json: schema: $ref: "#/components/schemas/FeaturesResponse" /proxy/freepik/v1/ai/image-upscaler: post: summary: Upscale an image with Magnific description: | This asynchronous endpoint enables image upscaling using advanced AI algorithms. Upon submission, it returns a unique task_id which can be used to track the progress. For real-time production use, include the optional webhook_url parameter to receive an automated notification once the task has been completed. operationId: freepikMagnificUpscalerCreative tags: - Freepik - Proxy security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/FreepikMagnificUpscalerCreativeRequest" responses: "200": description: OK - The upscaling process has started content: application/json: schema: $ref: "#/components/schemas/FreepikTaskResponse" "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/FreepikErrorResponse" "500": description: Internal Server Error content: application/json: schema: $ref: "#/components/schemas/FreepikErrorResponse" /proxy/freepik/v1/ai/image-upscaler/{task_id}: get: summary: Get the status of the upscaling task description: Get the status of the upscaling task operationId: freepikMagnificUpscalerCreativeGetStatus tags: - Freepik - Proxy security: - BearerAuth: [] parameters: - name: task_id in: path required: true schema: type: string description: ID of the task responses: "200": description: OK - The task status is returned content: application/json: schema: $ref: "#/components/schemas/FreepikTaskResponse" "404": description: Task not found "500": description: Internal Server Error content: application/json: schema: $ref: "#/components/schemas/FreepikErrorResponse" /proxy/freepik/v1/ai/image-upscaler-precision-v2: post: summary: Upscale an image with Precision V2 description: | Upscales an image while adding new visual elements or details (V2). This endpoint may modify the original image content based on the prompt and inferred context. Upon submission, it returns a unique task_id which can be used to track the progress. operationId: freepikMagnificUpscalerPrecisionV2 tags: - Freepik - Proxy security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/FreepikMagnificUpscalerPrecisionV2Request" responses: "200": description: OK - The upscaling process has started content: application/json: schema: $ref: "#/components/schemas/FreepikTaskResponse" "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/FreepikErrorResponse" "500": description: Internal Server Error content: application/json: schema: $ref: "#/components/schemas/FreepikErrorResponse" /proxy/freepik/v1/ai/image-upscaler-precision-v2/{task_id}: get: summary: Get the status of the Precision V2 upscaling task description: Returns the current status and output URL of a specific precision upscaler V2 task. operationId: freepikMagnificUpscalerPrecisionV2GetStatus tags: - Freepik - Proxy security: - BearerAuth: [] parameters: - name: task_id in: path required: true schema: type: string format: uuid description: ID of the task responses: "200": description: OK - The task status is returned content: application/json: schema: $ref: "#/components/schemas/FreepikTaskResponse" "404": description: Task not found "500": description: Internal Server Error content: application/json: schema: $ref: "#/components/schemas/FreepikErrorResponse" /proxy/freepik/v1/ai/image-relight: post: summary: Relight an image description: | Relight an image using AI. This endpoint accepts a variety of parameters to customize the generated images. operationId: freepikMagnificRelight tags: - Freepik - Proxy security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/FreepikMagnificRelightRequest" responses: "200": description: OK - The relight process has started content: application/json: schema: $ref: "#/components/schemas/FreepikTaskResponse" "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/FreepikErrorResponse" "500": description: Internal Server Error content: application/json: schema: $ref: "#/components/schemas/FreepikErrorResponse" /proxy/freepik/v1/ai/image-relight/{task_id}: get: summary: Get the status of the relight task description: Get the status of the relight task operationId: freepikMagnificRelightGetStatus tags: - Freepik - Proxy security: - BearerAuth: [] parameters: - name: task_id in: path required: true schema: type: string description: ID of the task responses: "200": description: OK - The task status is returned content: application/json: schema: $ref: "#/components/schemas/FreepikTaskResponse" "404": description: Task not found "500": description: Internal Server Error content: application/json: schema: $ref: "#/components/schemas/FreepikErrorResponse" /proxy/freepik/v1/ai/skin-enhancer/creative: post: summary: Skin enhancer using AI (Creative) description: Enhance skin in images using AI with the Creative mode. This mode provides more artistic and stylized enhancements. operationId: freepikSkinEnhancerCreative tags: - Freepik - Proxy security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/FreepikSkinEnhancerCreativeRequest" responses: "200": description: OK - The skin enhancer process has started content: application/json: schema: $ref: "#/components/schemas/FreepikTaskResponse" "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/FreepikErrorResponse" "500": description: Internal Server Error content: application/json: schema: $ref: "#/components/schemas/FreepikErrorResponse" /proxy/freepik/v1/ai/skin-enhancer/flexible: post: summary: Skin enhancer using AI (Flexible) description: Enhance skin in images using AI with the Flexible mode. This mode allows you to choose the optimization target for the enhancement. operationId: freepikSkinEnhancerFlexible tags: - Freepik - Proxy security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/FreepikSkinEnhancerFlexibleRequest" responses: "200": description: OK - The skin enhancer process has started content: application/json: schema: $ref: "#/components/schemas/FreepikTaskResponse" "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/FreepikErrorResponse" "500": description: Internal Server Error content: application/json: schema: $ref: "#/components/schemas/FreepikErrorResponse" /proxy/freepik/v1/ai/skin-enhancer/faithful: post: summary: Skin enhancer using AI (Faithful) description: Enhance skin in images using AI with the Faithful mode. This mode preserves the original appearance while improving skin quality. operationId: freepikSkinEnhancerFaithful tags: - Freepik - Proxy security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/FreepikSkinEnhancerFaithfulRequest" responses: "200": description: OK - The skin enhancer process has started content: application/json: schema: $ref: "#/components/schemas/FreepikTaskResponse" "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/FreepikErrorResponse" "500": description: Internal Server Error content: application/json: schema: $ref: "#/components/schemas/FreepikErrorResponse" /proxy/freepik/v1/ai/skin-enhancer/{task_id}: get: summary: Get the status of one skin enhancer task description: Get the status of a skin enhancer task (works for both Creative and Faithful modes) operationId: freepikSkinEnhancerGetStatus tags: - Freepik - Proxy security: - BearerAuth: [] parameters: - name: task_id in: path required: true schema: type: string description: ID of the task responses: "200": description: OK - The task status is returned content: application/json: schema: $ref: "#/components/schemas/FreepikTaskResponse" "404": description: Task not found "500": description: Internal Server Error content: application/json: schema: $ref: "#/components/schemas/FreepikErrorResponse" /proxy/freepik/v1/ai/image-style-transfer: post: summary: Style transfer an image description: Style transfer an image using AI. operationId: freepikMagnificStyleTransfer tags: - Freepik - Proxy security: - BearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/FreepikMagnificStyleTransferRequest" responses: "200": description: OK - The style transfer process has started content: application/json: schema: $ref: "#/components/schemas/FreepikTaskData" "400": description: Bad Request content: application/json: schema: $ref: "#/components/schemas/FreepikErrorResponse" "500": description: Internal Server Error content: application/json: schema: $ref: "#/components/schemas/FreepikErrorResponse" /proxy/freepik/v1/ai/image-style-transfer/{task_id}: get: summary: Get the status of the style transfer task description: Get the status of the style transfer task operationId: freepikMagnificStyleTransferGetStatus tags: - Freepik - Proxy security: - BearerAuth: [] parameters: - name: task_id in: path required: true schema: type: string description: ID of the task responses: "200": description: OK - The task status is returned content: application/json: schema: $ref: "#/components/schemas/FreepikTaskResponse" "404": description: Task not found "500": description: Internal Server Error content: application/json: schema: $ref: "#/components/schemas/FreepikErrorResponse" components: schemas: FreepikMagnificUpscalerCreativeRequest: type: object required: - image properties: image: type: string description: Base64 image or URL to upscale. The resulted image can't exceed maximum allowed size of 25.3 million pixels. webhook_url: type: string format: uri description: Optional callback URL that will receive asynchronous notifications whenever the task changes status. example: "https://www.example.com/webhook" scale_factor: type: string enum: ["2x", "4x", "8x", "16x"] default: "2x" description: Configure scale factor of the image. For higher scales, the image will take longer to process. optimized_for: type: string enum: [standard, soft_portraits, hard_portraits, art_n_illustration, videogame_assets, nature_n_landscapes, films_n_photography, 3d_renders, science_fiction_n_horror] default: standard description: Styles to optimize the upscale process. prompt: type: string description: Prompt to guide the upscale process. Reusing the same prompt for AI-generated images will improve the results. creativity: type: integer minimum: -10 maximum: 10 default: 0 description: Increase or decrease AI's creativity. Valid values range [-10, 10]. hdr: type: integer minimum: -10 maximum: 10 default: 0 description: Increase or decrease the level of definition and detail. Valid values range [-10, 10]. resemblance: type: integer minimum: -10 maximum: 10 default: 0 description: Adjust the level of resemblance to the original image. Valid values range [-10, 10]. fractality: type: integer minimum: -10 maximum: 10 default: 0 description: Control the strength of the prompt and intricacy per square pixel. Valid values range [-10, 10]. engine: type: string enum: [automatic, magnific_illusio, magnific_sharpy, magnific_sparkle] default: automatic description: Magnific model engines. FreepikTaskResponse: type: object required: - data properties: data: $ref: "#/components/schemas/FreepikTaskData" FreepikTaskData: type: object properties: task_id: type: string format: uuid example: "046b6c7f-0b8a-43b9-b35d-6489e6daee91" status: type: string enum: [CREATED, IN_PROGRESS, COMPLETED, FAILED] generated: type: array items: type: string format: uri description: URLs to the generated images. FreepikErrorResponse: type: object properties: error: type: string message: type: string FreepikSkinEnhancerFlexibleRequest: type: object required: - image properties: image: type: string description: Input image. Supports Base64 encoding or HTTPS URL (must be publicly accessible). example: "https://example.com/portrait.jpg" sharpen: type: integer minimum: 0 maximum: 100 default: 0 description: Sharpening intensity smart_grain: type: integer minimum: 0 maximum: 100 default: 2 description: Smart grain intensity optimized_for: type: string enum: [enhance_skin, improve_lighting, enhance_everything, transform_to_real, no_make_up] default: enhance_skin description: Optimization target for flexible skin enhancer webhook_url: type: string format: uri description: Optional callback URL for async notifications. example: "https://www.example.com/webhook" FreepikSkinEnhancerFaithfulRequest: type: object required: - image properties: image: type: string description: Input image. Supports Base64 encoding or HTTPS URL (must be publicly accessible). example: "https://example.com/portrait.jpg" sharpen: type: integer minimum: 0 maximum: 100 default: 0 description: Sharpening intensity smart_grain: type: integer minimum: 0 maximum: 100 default: 2 description: Smart grain intensity skin_detail: type: integer minimum: 0 maximum: 100 default: 80 description: Skin detail enhancement level webhook_url: type: string format: uri description: Optional callback URL for async notifications. example: "https://www.example.com/webhook" FreepikSkinEnhancerCreativeRequest: type: object required: - image properties: image: type: string description: Input image. Supports Base64 encoding or HTTPS URL (must be publicly accessible). example: "https://example.com/portrait.jpg" sharpen: type: integer minimum: 0 maximum: 100 default: 0 description: Sharpening intensity smart_grain: type: integer minimum: 0 maximum: 100 default: 2 description: Smart grain intensity webhook_url: type: string format: uri description: Optional callback URL for async notifications. example: "https://www.example.com/webhook" FreepikMagnificStyleTransferRequest: type: object required: - image - reference_image properties: image: type: string description: Base64 or URL of the image to do the style transfer reference_image: type: string description: Base64 or URL of the reference image for style transfer webhook_url: type: string format: uri description: Optional callback URL for async notifications. example: "https://www.example.com/webhook" prompt: type: string description: Prompt for the AI model style_strength: type: integer minimum: 0 maximum: 100 default: 100 description: Percentage of style strength structure_strength: type: integer minimum: 0 maximum: 100 default: 50 description: Allows to maintain the structure of the original image is_portrait: type: boolean default: false description: Indicates whether the image should be processed as a portrait. portrait_style: type: string enum: [standard, pop, super_pop] default: standard description: Visual style applied to portrait images. Only used if is_portrait is true. portrait_beautifier: type: string enum: [beautify_face, beautify_face_max] description: Facial beautification on portrait images. Only used if is_portrait is true. flavor: type: string enum: [faithful, gen_z, psychedelia, detaily, clear, donotstyle, donotstyle_sharp] default: faithful description: Flavor of the transferring style engine: type: string enum: [balanced, definio, illusio, 3d_cartoon, colorful_anime, caricature, real, super_real, softy] default: balanced description: Engine preset for style transfer fixed_generation: type: boolean default: false description: When enabled, using the same settings will consistently produce the same image. FreepikMagnificRelightRequest: type: object required: - image properties: image: type: string description: Base64 or URL of the image to do the relight webhook_url: type: string format: uri description: Optional callback URL that will receive asynchronous notifications whenever the task changes status. example: "https://www.example.com/webhook" prompt: type: string description: | You can guide the generation process and influence the light transfer with a descriptive prompt. IMPORTANT: You can emphasize specific aspects of the light in your prompt by using a number in parentheses, ranging from 1 to 1.4, like "(dark scene:1.3)". transfer_light_from_reference_image: type: string description: Base64 or URL of the reference image for light transfer. Incompatible with 'transfer_light_from_lightmap' transfer_light_from_lightmap: type: string description: Base64 or URL of the lightmap for light transfer. Incompatible with 'transfer_light_from_reference_image' light_transfer_strength: type: integer minimum: 0 maximum: 100 default: 100 description: Level of light transfer intensity. 0% keeps closest to original, 100% is maximum transfer. interpolate_from_original: type: boolean default: false description: When enabled, makes the final image interpolate from the original using the light transfer strength slider. change_background: type: boolean default: true description: When enabled, changes the background based on prompt and/or reference image. Useful for product placement and portraits. style: type: string enum: [standard, darker_but_realistic, clean, smooth, brighter, contrasted_n_hdr, just_composition] default: standard description: Style preset for the relight operation. preserve_details: type: boolean default: true description: Maintains texture and small details of the original image. Good for product photography, texts, etc. advanced_settings: type: object properties: whites: type: integer minimum: 0 maximum: 100 default: 50 description: Adjust the level of white color in the image. blacks: type: integer minimum: 0 maximum: 100 default: 50 description: Adjust the level of black color in the image. brightness: type: integer minimum: 0 maximum: 100 default: 50 description: Adjust the level of brightness in the image. contrast: type: integer minimum: 0 maximum: 100 default: 50 description: Adjust the level of contrast in the image. saturation: type: integer minimum: 0 maximum: 100 default: 50 description: Adjust the level of saturation in the image. engine: type: string enum: [automatic, balanced, cool, real, illusio, fairy, colorful_anime, hard_transform, softy] default: automatic description: | Engine preset for relighting: - balanced: Well-rounded, general-purpose option - cool: Brighter with cooler tones - real: Aims to enhance photographic quality (Experimental) - illusio: Optimized for illustrations and drawings - fairy: Suited for fantasy-themed images - colorful_anime: Ideal for anime, cartoons, and vibrant colors - hard_transform: Significantly alters the original image - softy: Slightly softer effect, suitable for graphic designs transfer_light_a: type: string enum: [automatic, low, medium, normal, high, high_on_faces] default: automatic description: Adjusts the intensity of light transfer. transfer_light_b: type: string enum: [automatic, composition, straight, smooth_in, smooth_out, smooth_both, reverse_both, soft_in, soft_out, soft_mid, strong_mid, style_shift, strong_shift] default: automatic description: Also modifies light transfer intensity. Can be combined with transfer_light_a for varied effects. fixed_generation: type: boolean default: false description: When enabled, using the same settings will consistently produce the same image. FreepikMagnificUpscalerPrecisionV2Request: type: object required: - image properties: image: type: string description: | Source image to upscale. Accepts either: - A publicly accessible HTTPS URL pointing to the image - A base64-encoded image string webhook_url: type: string format: uri description: Optional callback URL that will receive asynchronous notifications when the upscaling task completes. sharpen: type: integer minimum: 0 maximum: 100 default: 7 description: Image sharpness intensity control. Higher values increase edge definition and clarity. smart_grain: type: integer minimum: 0 maximum: 100 default: 7 description: Intelligent grain/texture enhancement. Higher values add more fine-grained texture. ultra_detail: type: integer minimum: 0 maximum: 100 default: 30 description: Ultra detail enhancement level. Higher values create more intricate details. flavor: type: string enum: [sublime, photo, photo_denoiser] description: | Image processing flavor: - sublime: Optimized for artistic and illustrated images - photo: Optimized for photographic images - photo_denoiser: Specialized for photos with noise reduction scale_factor: type: integer minimum: 2 maximum: 16 description: Image scaling factor. Determines how much larger the output will be compared to input. SubscriptionTier: type: string description: The subscription tier level enum: - FREE - STANDARD - CREATOR - PRO - FOUNDERS_EDITION SubscriptionDuration: type: string description: The subscription billing duration enum: - MONTHLY - ANNUAL FeaturesResponse: type: object properties: partner_node_conversion_rate: type: number description: The conversion rate for partner nodes example: 0.5 required: - partner_node_conversion_rate ClaimMyNodeRequest: type: object properties: GH_TOKEN: type: string description: GitHub token to verify if the user owns the repo of the node required: - GH_TOKEN BulkNodeVersionsRequest: type: object properties: node_versions: type: array items: $ref: "#/components/schemas/NodeVersionIdentifier" description: List of node ID and version pairs to retrieve required: - node_versions NodeVersionIdentifier: type: object properties: node_id: type: string description: The unique identifier of the node version: type: string description: The version of the node required: - node_id - version BulkNodeVersionsResponse: type: object properties: node_versions: type: array items: $ref: "#/components/schemas/BulkNodeVersionResult" description: List of retrieved node versions with their status required: - node_versions BulkNodeVersionResult: type: object properties: identifier: $ref: "#/components/schemas/NodeVersionIdentifier" description: The node and version identifier status: type: string enum: [success, not_found, error] description: Status of the retrieval operation node_version: $ref: "#/components/schemas/NodeVersion" description: The retrieved node version data (only present if status is success) error_message: type: string description: Error message if retrieval failed (only present if status is error) required: - identifier - status PersonalAccessToken: type: object properties: id: type: string format: uuid description: Unique identifier for the GitCommit name: type: string description: Required. The name of the token. Can be a simple description. description: type: string description: Optional. A more detailed description of the token's intended use. createdAt: type: string format: date-time description: "[Output Only]The date and time the token was created." token: type: string description: "[Output Only]. The personal access token. Only returned during creation." GitCommit: type: object properties: id: type: string format: uuid description: Unique identifier for the GitCommit commit_hash: type: string description: The hash of the commit commit_name: type: string description: The name of the commit branch_name: type: string description: The branch where the commit was made author: type: string description: The author of the commit timestamp: type: string format: date-time description: The timestamp when the commit was made GitCommitSummary: type: object properties: commit_hash: type: string description: The hash of the commit commit_name: type: string description: The name of the commit branch_name: type: string description: The branch where the commit was made author: type: string description: The author of the commit timestamp: type: string format: date-time description: The timestamp when the commit was made status_summary: type: object description: A map of operating system to status pairs additionalProperties: type: string User: type: object properties: id: type: string description: The unique id for this user. email: type: string description: The email address for this user. name: type: string description: The name for this user. isApproved: type: boolean description: Indicates if the user is approved. isAdmin: type: boolean description: Indicates if the user has admin privileges. PublisherUser: type: object properties: id: type: string description: The unique id for this user. email: type: string description: The email address for this user. name: type: string description: The name for this user. ErrorResponse: type: object properties: error: type: string message: type: string required: - error - message CreateCouponRequest: type: object properties: name: type: string description: Name of the coupon displayed to customers percent_off: type: number format: double description: Percent off discount (0-100) minimum: 0 maximum: 100 amount_off: type: integer description: Amount off in cents minimum: 0 currency: type: string description: Currency for amount_off (required if amount_off is set) enum: [usd] duration: type: string description: How long the coupon lasts enum: [once, repeating, forever] default: once duration_in_months: type: integer description: Required if duration is repeating minimum: 1 max_redemptions: type: integer description: Maximum number of times this coupon can be redeemed minimum: 1 redeem_by: type: integer format: int64 description: Unix timestamp specifying the last time at which the coupon can be redeemed metadata: type: object additionalProperties: type: string description: Set of key-value pairs for storing additional information UpdateCouponRequest: type: object properties: name: type: string description: Name of the coupon displayed to customers metadata: type: object additionalProperties: type: string description: Set of key-value pairs for storing additional information CouponResponse: type: object properties: id: type: string description: The Stripe coupon ID name: type: string description: Name of the coupon displayed to customers percent_off: type: number format: double description: Percent off discount (0-100) amount_off: type: integer description: Amount off in cents currency: type: string description: Currency for amount_off duration: type: string description: How long the coupon lasts enum: [once, repeating, forever] duration_in_months: type: integer description: Number of months for repeating coupons max_redemptions: type: integer description: Maximum number of times this coupon can be redeemed times_redeemed: type: integer description: Number of times this coupon has been redeemed redeem_by: type: integer format: int64 description: Unix timestamp specifying the last time at which the coupon can be redeemed valid: type: boolean description: Whether the coupon can still be redeemed metadata: type: object additionalProperties: type: string description: Set of key-value pairs for storing additional information required: - id - duration - valid CreatePromoCodeRequest: type: object properties: coupon_id: type: string description: The Stripe coupon ID to create the promotional code for expire_days: type: integer description: Number of days until the promotion code expires minimum: 1 default: 30 max_redemptions: type: integer description: Maximum number of times this code can be redeemed minimum: 1 required: - coupon_id UpdatePromoCodeRequest: type: object properties: active: type: boolean description: Whether the promo code is active metadata: type: object additionalProperties: type: string description: Set of key-value pairs for storing additional information PromoCodeResponse: type: object properties: id: type: string description: The Stripe promotion code ID code: type: string description: The generated promotional code coupon_id: type: string description: The Stripe coupon ID associated with this promo code active: type: boolean description: Whether the promo code is currently active expires_at: type: integer format: int64 description: Unix timestamp when the promo code expires max_redemptions: type: integer description: Maximum number of times this code can be redeemed times_redeemed: type: integer description: Number of times this code has been redeemed metadata: type: object additionalProperties: type: string description: Set of key-value pairs for storing additional information required: - id - code - coupon_id - active RunwayTextToImageRequest: type: object properties: promptText: type: string maxLength: 1000 description: Text prompt for the image generation model: type: string enum: [gen4_image] description: Model to use for generation ratio: $ref: "#/components/schemas/RunwayTextToImageAspectRatioEnum" description: The resolution (aspect ratio) of the output image referenceImages: type: array items: type: object properties: uri: type: string description: A HTTPS URL or data URI containing an encoded image description: Array of reference images to guide the generation required: - promptText - model - ratio ActionJobResult: type: object properties: id: type: string format: uuid description: Unique identifier for the job result workflow_name: type: string description: Name of the workflow operating_system: type: string description: Operating system used python_version: type: string description: PyTorch version used pytorch_version: type: string description: PyTorch version used action_run_id: type: string description: Identifier of the run this result belongs to action_job_id: type: string description: Identifier of the job this result belongs to cuda_version: type: string description: CUDA version used branch_name: type: string description: Name of the relevant git branch commit_hash: type: string description: The hash of the commit commit_id: type: string description: The ID of the commit commit_time: type: integer format: int64 description: The Unix timestamp when the commit was made commit_message: type: string description: The message of the commit comfy_run_flags: type: string description: The comfy run flags. E.g. `--low-vram` git_repo: type: string description: The repository name pr_number: type: string description: The pull request number start_time: type: integer format: int64 description: The start time of the job as a Unix timestamp. end_time: type: integer format: int64 description: The end time of the job as a Unix timestamp. avg_vram: type: integer description: The average VRAM used by the job peak_vram: type: integer description: The peak VRAM used by the job job_trigger_user: type: string description: The user who triggered the job. author: type: string description: The author of the commit machine_stats: $ref: "#/components/schemas/MachineStats" status: $ref: "#/components/schemas/WorkflowRunStatus" storage_file: $ref: "#/components/schemas/StorageFile" StorageFile: type: object properties: id: type: string format: uuid description: Unique identifier for the storage file file_path: type: string description: Path to the file in storage public_url: type: string description: Public URL Publisher: type: object properties: name: type: string id: type: string description: The unique identifier for the publisher. It's akin to a username. Should be lowercase. description: type: string website: type: string support: type: string source_code_repo: type: string logo: type: string description: URL to the publisher's logo. createdAt: type: string format: date-time description: The date and time the publisher was created. members: type: array items: $ref: "#/components/schemas/PublisherMember" description: A list of members in the publisher. status: $ref: "#/components/schemas/PublisherStatus" description: The status of the publisher. PublisherMember: type: object properties: id: type: string description: The unique identifier for the publisher member. user: $ref: "#/components/schemas/PublisherUser" description: The user associated with this publisher member. role: type: string description: The role of the user in the publisher. Node: type: object properties: id: type: string description: "The unique identifier of the node." name: type: string description: The display name of the node. category: type: string description: "DEPRECATED: The category of the node. Use 'tags' field instead. This field will be removed in a future version." deprecated: true description: type: string author: type: string license: type: string description: The path to the LICENSE file in the node's repository. icon: type: string description: URL to the node's icon. repository: type: string description: URL to the node's repository. tags: type: array items: type: string tags_admin: type: array items: type: string description: Admin-only tags for security warnings and admin metadata supported_os: type: array items: type: string description: List of operating systems that this node supports supported_accelerators: type: array items: type: string description: List of accelerators (e.g. CUDA, DirectML, ROCm) that this node supports supported_comfyui_version: type: string description: Supported versions of ComfyUI supported_comfyui_frontend_version: type: string description: Supported versions of ComfyUI frontend latest_version: $ref: "#/components/schemas/NodeVersion" description: The latest version of the node. rating: type: number description: The average rating of the node. downloads: type: integer description: The number of downloads of the node. publisher: $ref: "#/components/schemas/Publisher" description: The publisher of the node. status: $ref: "#/components/schemas/NodeStatus" description: The status of the node. status_detail: type: string description: The status detail of the node. translations: type: object additionalProperties: type: object additionalProperties: true description: Translations of node metadata in different languages. search_ranking: type: integer description: A numerical value representing the node's search ranking, used for sorting search results. preempted_comfy_node_names: type: array items: type: string description: A list of Comfy node names that are preempted by this node. banner_url: type: string description: URL to the node's banner. github_stars: type: integer description: Number of stars on the GitHub repository. created_at: type: string format: date-time description: The date and time when the node was created NodeVersion: type: object properties: id: type: string version: type: string description: The version identifier, following semantic versioning. Must be unique for the node. createdAt: type: string format: date-time description: The date and time the version was created. changelog: type: string description: Summary of changes made in this version dependencies: type: array items: type: string description: A list of pip dependencies required by the node. downloadUrl: type: string description: "[Output Only] URL to download this version of the node" deprecated: type: boolean description: Indicates if this version is deprecated. status: $ref: "#/components/schemas/NodeVersionStatus" description: The status of the node version. status_reason: type: string tags: type: array items: type: string tags_admin: type: array items: type: string description: Admin-only tags for security warnings and admin metadata node_id: type: string description: The unique identifier of the node. comfy_node_extract_status: type: string description: The status of comfy node extraction process. supported_comfyui_version: type: string description: Supported versions of ComfyUI supported_comfyui_frontend_version: type: string description: Supported versions of ComfyUI frontend supported_os: type: array items: type: string description: List of operating systems that this node supports supported_accelerators: type: array items: type: string description: List of accelerators (e.g. CUDA, DirectML, ROCm) that this node supports ComfyNode: type: object properties: comfy_node_name: type: string description: Unique identifier for the node category: type: string description: UI category where the node is listed, used for grouping nodes. description: type: string description: Brief description of the node's functionality or purpose. input_types: type: string description: Defines input parameters deprecated: type: boolean description: Indicates if the node is deprecated. Deprecated nodes are hidden in the UI. experimental: type: boolean description: Indicates if the node is experimental, subject to changes or removal. output_is_list: type: array items: type: boolean description: Boolean values indicating if each output is a list. return_names: type: string description: Names of the outputs for clarity in workflows. return_types: type: string description: Specifies the types of outputs produced by the node. function: type: string description: Name of the entry-point function to execute the node. policy: $ref: "#/components/schemas/ComfyNodePolicy" description: The policy associated with the comfy node. ComfyNodeCloudBuildInfo: type: object properties: project_id: type: string project_number: type: string location: type: string build_id: type: string Error: type: object properties: message: type: string description: A clear and concise description of the error. details: type: array items: type: string description: Optional detailed information about the error or hints for resolving it. # ======= Request body Definitions ======================= NodeVersionUpdateRequest: type: object properties: changelog: type: string description: The changelog describing the version changes. deprecated: type: boolean description: Whether the version is deprecated. # Enum of Node Status NodeStatus: type: string enum: - NodeStatusActive - NodeStatusDeleted - NodeStatusBanned # Enum of Comfy Node Policy ComfyNodePolicy: type: string enum: - ComfyNodePolicyActive - ComfyNodePolicyBanned - ComfyNodePolicyLocalOnly ComfyNodeUpdateRequest: type: object properties: category: type: string description: UI category where the node is listed, used for grouping nodes. description: type: string description: Brief description of the node's functionality or purpose. input_types: type: string description: Defines input parameters deprecated: type: boolean description: Indicates if the node is deprecated. Deprecated nodes are hidden in the UI. experimental: type: boolean description: Indicates if the node is experimental, subject to changes or removal. output_is_list: type: array items: type: boolean description: Boolean values indicating if each output is a list. return_names: type: string description: Names of the outputs for clarity in workflows. return_types: type: string description: Specifies the types of outputs produced by the node. function: type: string description: Name of the entry-point function to execute the node. policy: $ref: "#/components/schemas/ComfyNodePolicy" description: The policy associated with the comfy node. # Enum of Node Version Status NodeVersionStatus: type: string enum: - NodeVersionStatusActive - NodeVersionStatusDeleted - NodeVersionStatusBanned - NodeVersionStatusPending - NodeVersionStatusFlagged PublisherStatus: type: string enum: - PublisherStatusActive - PublisherStatusBanned WorkflowRunStatus: type: string enum: - WorkflowRunStatusStarted - WorkflowRunStatusFailed - WorkflowRunStatusCompleted MachineStats: type: object properties: machine_name: type: string description: Name of the machine. os_version: type: string description: The operating system version. eg. Ubuntu Linux 20.04 gpu_type: type: string description: The GPU type. eg. NVIDIA Tesla K80 cpu_capacity: type: string description: Total CPU on the machine. initial_cpu: type: string description: Initial CPU available before the job starts. memory_capacity: type: string description: Total memory on the machine. initial_ram: type: string description: Initial RAM available before the job starts. vram_time_series: type: object description: Time series of VRAM usage. disk_capacity: type: string description: Total disk capacity on the machine. initial_disk: type: string description: Initial disk available before the job starts. pip_freeze: type: string description: The pip freeze output Customer: type: object properties: id: type: string description: The firebase UID of the user email: type: string description: The email address for this user name: type: string description: The name for this user createdAt: type: string format: date-time description: The date and time the user was created updatedAt: type: string format: date-time description: The date and time the user was last updated is_admin: type: boolean description: Whether the user is an admin stripe_id: type: string description: The Stripe customer ID metronome_id: type: string description: The Metronome customer ID has_fund: type: boolean description: Whether the user has funds subscription_tier: allOf: - $ref: "#/components/schemas/SubscriptionTier" nullable: true description: The cached subscription tier level required: - id CustomerAdmin: type: object properties: id: type: string description: The firebase UID of the user email: type: string description: The email address for this user name: type: string description: The name for this user createdAt: type: string format: date-time description: The date and time the user was created updatedAt: type: string format: date-time description: The date and time the user was last updated is_admin: type: boolean description: Whether the user is an admin stripe_id: type: string description: The Stripe customer ID metronome_id: type: string description: The Metronome customer ID has_fund: type: boolean description: Whether the user has funds cloud_subscription_is_active: type: boolean description: Whether the customer has an active cloud subscription cloud_subscription_subscription_id: type: string description: The active subscription ID if one exists nullable: true cloud_subscription_renewal_date: type: string format: date-time description: The next renewal date for the subscription (ISO 8601 format) nullable: true cloud_subscription_end_date: type: string format: date-time description: The date when the subscription is set to end (ISO 8601 format) nullable: true subscription_tier: allOf: - $ref: "#/components/schemas/SubscriptionTier" nullable: true description: The subscription tier level (e.g. FREE, STANDARD, CREATOR, PRO) required: - id AuditLog: type: object properties: event_type: type: string description: the type of the event event_id: type: string description: the id of the event params: type: object description: data related to the event additionalProperties: true createdAt: type: string format: date-time description: The date and time the event was created IdeogramV3Request: type: object properties: prompt: type: string description: The text prompt for image generation seed: type: integer description: Seed value for reproducible generation resolution: type: string description: Image resolution in format WxH example: "1280x800" aspect_ratio: type: string description: Aspect ratio in format WxH example: "1x3" rendering_speed: $ref: "#/components/schemas/RenderingSpeed" magic_prompt: type: string enum: ["ON", "OFF"] description: Whether to enable magic prompt enhancement negative_prompt: type: string description: Text prompt specifying what to avoid in the generation num_images: type: integer description: Number of images to generate minimum: 1 color_palette: type: object properties: name: type: string description: Name of the color palette example: "PASTEL" required: - name style_codes: type: array items: type: string pattern: "^[0-9A-Fa-f]{8}$" description: Array of style codes in hexadecimal format style_type: $ref: "#/components/schemas/IdeogramStyleType" style_reference_images: type: array items: type: string format: binary description: Array of reference image URLs or identifiers character_reference_images: type: array items: type: string format: binary description: Generations with character reference are subject to the character reference pricing. A set of images to use as character references (maximum total size 10MB across all character references), currently only supports 1 character reference image. The images should be in JPEG, PNG or WebP format. character_reference_images_mask: type: array items: type: string format: binary description: Optional masks for character reference images. When provided, must match the number of character_reference_images. Each mask should be a grayscale image of the same dimensions as the corresponding character reference image. The images should be in JPEG, PNG or WebP format. required: - prompt - rendering_speed IdeogramV3EditRequest: type: object required: - prompt - rendering_speed properties: image: type: string format: binary description: The image being edited (max size 10MB); only JPEG, WebP and PNG formats are supported at this time. mask: type: string format: binary description: A black and white image of the same size as the image being edited (max size 10MB). Black regions in the mask should match up with the regions of the image that you would like to edit; only JPEG, WebP and PNG formats are supported at this time. prompt: type: string description: The prompt used to describe the edited result. magic_prompt: type: string description: Determine if MagicPrompt should be used in generating the request or not. num_images: type: integer description: The number of images to generate. seed: type: integer description: Random seed. Set for reproducible generation. rendering_speed: $ref: "#/components/schemas/RenderingSpeed" style_type: $ref: "#/components/schemas/IdeogramStyleType" color_palette: type: object description: A color palette for generation, must EITHER be specified via one of the presets (name) or explicitly via hexadecimal representations of the color with optional weights (members). Not supported by V_1, V_1_TURBO, V_2A and V_2A_TURBO models. $ref: "#/components/schemas/IdeogramColorPalette" style_codes: type: array items: type: string pattern: "^[0-9A-Fa-f]{8}$" description: A list of 8 character hexadecimal codes representing the style of the image. Cannot be used in conjunction with style_reference_images or style_type. style_reference_images: type: array items: type: string format: binary description: A set of images to use as style references (maximum total size 10MB across all style references). The images should be in JPEG, PNG or WebP format. character_reference_images: type: array items: type: string format: binary description: Generations with character reference are subject to the character reference pricing. A set of images to use as character references (maximum total size 10MB across all character references), currently only supports 1 character reference image. The images should be in JPEG, PNG or WebP format. character_reference_images_mask: type: array items: type: string format: binary description: Optional masks for character reference images. When provided, must match the number of character_reference_images. Each mask should be a grayscale image of the same dimensions as the corresponding character reference image. The images should be in JPEG, PNG or WebP format. IdeogramColorPalette: type: object description: A color palette specification that can either use a preset name or explicit color definitions with weights oneOf: - properties: name: type: string description: Name of the preset color palette required: - name - properties: members: type: array items: type: object properties: color: type: string pattern: "^#[0-9A-Fa-f]{6}$" description: Hexadecimal color code weight: type: number minimum: 0 maximum: 1 description: Optional weight for the color (0-1) description: Array of color definitions with optional weights required: - members IdeogramGenerateRequest: type: object description: Parameters for the Ideogram generation proxy request. Based on Ideogram's API. properties: image_request: type: object description: The image generation request parameters. properties: prompt: type: string description: Required. The prompt to use to generate the image. aspect_ratio: type: string description: "Optional. The aspect ratio (e.g., 'ASPECT_16_9', 'ASPECT_1_1'). Cannot be used with resolution. Defaults to 'ASPECT_1_1' if unspecified." model: type: string description: "The model used (e.g., 'V_2', 'V_2A_TURBO')" magic_prompt_option: type: string description: "Optional. MagicPrompt usage ('AUTO', 'ON', 'OFF')." seed: type: integer format: int64 description: "Optional. A number between 0 and 2147483647." minimum: 0 maximum: 2147483647 style_type: type: string description: "Optional. Style type ('AUTO', 'GENERAL', 'REALISTIC', 'DESIGN', 'RENDER_3D', 'ANIME'). Only for models V_2 and above." negative_prompt: type: string description: "Optional. Description of what to exclude. Only for V_1, V_1_TURBO, V_2, V_2_TURBO." num_images: type: integer description: "Optional. Number of images to generate (1-8). Defaults to 1." minimum: 1 maximum: 8 default: 1 resolution: type: string description: "Optional. Resolution (e.g., 'RESOLUTION_1024_1024'). Only for model V_2. Cannot be used with aspect_ratio." color_palette: type: object description: "Optional. Color palette object. Only for V_2, V_2_TURBO." additionalProperties: true required: - prompt - model required: - image_request IdeogramGenerateResponse: type: object description: Response from the Ideogram image generation API. properties: created: type: string format: date-time description: Timestamp when the generation was created. data: type: array description: Array of generated image information. items: type: object properties: prompt: type: string description: The prompt used to generate this image. resolution: type: string description: The resolution of the generated image (e.g., '1024x1024'). is_image_safe: type: boolean description: Indicates whether the image is considered safe. seed: type: integer description: The seed value used for this generation. url: type: string description: URL to the generated image. style_type: type: string description: The style type used for generation (e.g., 'REALISTIC', 'ANIME'). IdeogramV3RemixRequest: type: object required: - prompt properties: image: type: string format: binary prompt: type: string image_weight: type: integer minimum: 1 maximum: 100 default: 50 seed: type: integer minimum: 0 maximum: 2147483647 resolution: type: string aspect_ratio: type: string rendering_speed: $ref: "#/components/schemas/RenderingSpeed" magic_prompt: type: string enum: [AUTO, ON, OFF] negative_prompt: type: string num_images: type: integer minimum: 1 maximum: 8 color_palette: type: object style_codes: type: array items: type: string style_type: $ref: "#/components/schemas/IdeogramStyleType" style_reference_images: type: array items: type: string format: binary character_reference_images: type: array items: type: string format: binary description: Generations with character reference are subject to the character reference pricing. A set of images to use as character references (maximum total size 10MB across all character references), currently only supports 1 character reference image. The images should be in JPEG, PNG or WebP format. character_reference_images_mask: type: array items: type: string format: binary description: Optional masks for character reference images. When provided, must match the number of character_reference_images. Each mask should be a grayscale image of the same dimensions as the corresponding character reference image. The images should be in JPEG, PNG or WebP format. IdeogramV3IdeogramResponse: type: object properties: created: type: string format: date-time data: type: array items: type: object properties: prompt: type: string resolution: type: string is_image_safe: type: boolean seed: type: integer url: type: string style_type: type: string IdeogramV3ReframeRequest: type: object required: - resolution properties: image: type: string format: binary resolution: type: string num_images: type: integer minimum: 1 maximum: 8 seed: type: integer minimum: 0 maximum: 2147483647 rendering_speed: $ref: "#/components/schemas/RenderingSpeed" color_palette: type: object style_codes: type: array items: type: string style_reference_images: type: array items: type: string format: binary IdeogramV3ReplaceBackgroundRequest: type: object required: - prompt properties: image: type: string format: binary prompt: type: string magic_prompt: type: string enum: [AUTO, ON, OFF] num_images: type: integer minimum: 1 maximum: 8 seed: type: integer minimum: 0 maximum: 2147483647 rendering_speed: $ref: "#/components/schemas/RenderingSpeed" color_palette: type: object style_codes: type: array items: type: string style_reference_images: type: array items: type: string format: binary KlingTaskStatus: type: string enum: [submitted, processing, succeed, failed] description: Task Status # Kling Video Generation Request Properties KlingTextToVideoModelName: type: string enum: [kling-v1, kling-v1-5, kling-v1-6, kling-v2-master, kling-v2-1-master, kling-v2-5-turbo, kling-v2-6, kling-v3] default: kling-v1 description: Model Name KlingVideoGenModelName: type: string enum: [ kling-v1, kling-v1-5, kling-v1-6, kling-v2-master, kling-v2-1, kling-v2-1-master, kling-v2-5-turbo, kling-v2-6, kling-v3 ] default: kling-v2-master description: Model Name KlingVideoGenMode: type: string enum: [std, pro] default: std description: "Video generation mode. std: Standard Mode, which is cost-effective. pro: Professional Mode, generates videos with longer duration but higher quality output." KlingVideoGenAspectRatio: type: string enum: ["16:9", "9:16", "1:1"] default: "16:9" description: Video aspect ratio KlingVideoGenDuration: type: string enum: ["3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15"] default: "5" description: Video length in seconds KlingVideoGenCfgScale: type: number format: float default: 0.5 description: Flexibility in video generation. The higher the value, the lower the model's degree of flexibility, and the stronger the relevance to the user's prompt. minimum: 0 maximum: 1 KlingCameraControl: type: object properties: type: $ref: "#/components/schemas/KlingCameraControlType" config: $ref: "#/components/schemas/KlingCameraConfig" KlingCameraControlType: type: string enum: [simple, down_back, forward_up, right_turn_forward, left_turn_forward] description: "Predefined camera movements type. simple: Customizable camera movement. down_back: Camera descends and moves backward. forward_up: Camera moves forward and tilts up. right_turn_forward: Rotate right and move forward. left_turn_forward: Rotate left and move forward." KlingCameraConfig: type: object properties: horizontal: type: number minimum: -10 maximum: 10 description: Controls camera's movement along horizontal axis (x-axis). Negative indicates left, positive indicates right. vertical: type: number minimum: -10 maximum: 10 description: Controls camera's movement along vertical axis (y-axis). Negative indicates downward, positive indicates upward. pan: type: number minimum: -10 maximum: 10 description: Controls camera's rotation in vertical plane (x-axis). Negative indicates downward rotation, positive indicates upward rotation. tilt: type: number minimum: -10 maximum: 10 description: Controls camera's rotation in horizontal plane (y-axis). Negative indicates left rotation, positive indicates right rotation. roll: type: number minimum: -10 maximum: 10 description: Controls camera's rolling amount (z-axis). Negative indicates counterclockwise, positive indicates clockwise. zoom: type: number minimum: -10 maximum: 10 description: Controls change in camera's focal length. Negative indicates narrower field of view, positive indicates wider field of view. # Kling Video Generation Response Properties KlingVideoResult: type: object properties: id: type: string description: Generated video ID url: type: string format: uri description: URL for generated video watermark_url: type: string format: uri description: URL for generated video with watermark, hotlink protection format duration: type: string description: Total video duration in seconds # Kling Lip Sync Request Properties KlingAudioUploadType: type: string enum: [file, url] description: "Method of Transmitting Audio Files for Lip-Sync. Required when mode is audio2video." KlingLipSyncMode: type: string enum: [text2video, audio2video] description: "Video Generation Mode. text2video: Text-to-video generation mode; audio2video: Audio-to-video generation mode" KlingLipSyncVoiceLanguage: type: string enum: [zh, en] default: en description: "The voice language corresponds to the Voice ID." # Kling Video Effects Request Properties KlingDualCharacterEffectsScene: type: string enum: [hug, kiss, heart_gesture] description: Scene Name. Dual-character Effects (hug, kiss, heart_gesture). KlingSingleImageEffectsScene: type: string enum: [bloombloom, dizzydizzy, fuzzyfuzzy, squish, expansion] description: Scene Name. Single Image Effects (bloombloom, dizzydizzy, fuzzyfuzzy, squish, expansion). KlingCharacterEffectModelName: type: string enum: [kling-v1, kling-v1-5, kling-v1-6] default: kling-v1 description: Model Name. Can be kling-v1, kling-v1-5, or kling-v1-6. KlingSingleImageEffectModelName: type: string enum: [kling-v1-6] description: Model Name. Only kling-v1-6 is supported for single image effects. KlingSingleImageEffectDuration: type: string enum: ["5"] description: Video Length in seconds. Only 5-second videos are supported. KlingDualCharacterImages: type: array minItems: 2 maxItems: 2 items: type: string description: Reference Image Group. Must contain exactly 2 images. First image will be positioned on left side, second on right side of the composite. Each image follows the same requirements as single image effects. # Kling Image Generation Request Properties KlingImageGenAspectRatio: type: string enum: ["16:9", "9:16", "1:1", "4:3", "3:4", "3:2", "2:3", "21:9"] default: "16:9" description: Aspect ratio of the generated images KlingImageGenImageReferenceType: type: string enum: [subject, face] description: Image reference type KlingImageGenModelName: type: string enum: [kling-v1, kling-v1-5, kling-v2, kling-v3] default: kling-v1 description: Model Name # Kling Image Generation Response Properties KlingImageResult: type: object properties: index: type: integer description: Image Number (0-9) url: type: string format: uri description: URL for generated image # Kling Virtual Try On Request Properties KlingVirtualTryOnModelName: type: string enum: [kolors-virtual-try-on-v1, kolors-virtual-try-on-v1-5] default: kolors-virtual-try-on-v1 description: Model Name # Kling Requests and Responses KlingText2VideoRequest: type: object properties: model_name: $ref: "#/components/schemas/KlingTextToVideoModelName" multi_shot: type: boolean default: false description: Whether to generate multi-shot video. When true, the prompt parameter is invalid. When false, the shot_type and multi_prompt parameters are invalid. shot_type: type: string enum: [customize] description: Storyboard method. Required when the multi_shot parameter is set to true. prompt: type: string maxLength: 2500 description: Positive text prompt. Use <<>> to specify a voice matching the voice_list parameter order. A task can reference up to 2 tones. When specifying a tone, the sound parameter value must be on. multi_prompt: type: array description: Information about each storyboard, such as prompts and duration. Supports up to 6 storyboards, with a minimum of 1. Required when multi_shot is true and shot_type is customize. items: type: object properties: index: type: integer description: Shot sequence number prompt: type: string maxLength: 512 description: Prompt word for this storyboard. Maximum length 512 characters. duration: type: string description: Duration of this storyboard in seconds. Must not exceed total task duration and must not be less than 1. Sum of all storyboard durations equals total task duration. negative_prompt: type: string maxLength: 2500 description: Negative text prompt. It is recommended to supplement negative prompt information through negative sentences directly within positive prompts. cfg_scale: $ref: "#/components/schemas/KlingVideoGenCfgScale" mode: $ref: "#/components/schemas/KlingVideoGenMode" camera_control: $ref: "#/components/schemas/KlingCameraControl" aspect_ratio: $ref: "#/components/schemas/KlingVideoGenAspectRatio" duration: $ref: "#/components/schemas/KlingVideoGenDuration" sound: type: string enum: [on, off] default: off description: Whether to generate sound simultaneously when generating videos. Only V2.6 and subsequent versions of the model support this parameter. watermark_info: type: object description: Whether to generate watermarked results simultaneously. Custom watermark is not supported at this time. properties: enabled: type: boolean description: true means generate watermark, false means do not generate. callback_url: type: string format: uri description: The callback notification address external_task_id: type: string description: Customized Task ID KlingText2VideoResponse: type: object properties: code: type: integer description: Error code message: type: string description: Error message request_id: type: string description: Request ID data: type: object properties: task_id: type: string description: Task ID task_status: $ref: "#/components/schemas/KlingTaskStatus" task_status_msg: type: string description: Task status information, displaying the failure reason when the task fails task_info: type: object properties: external_task_id: type: string watermark_info: type: object properties: enabled: type: boolean final_unit_deduction: type: string description: The deduction units of task created_at: type: integer description: Task creation time, Unix timestamp in milliseconds updated_at: type: integer description: Task update time, Unix timestamp in milliseconds task_result: type: object properties: videos: type: array items: $ref: "#/components/schemas/KlingVideoResult" KlingImage2VideoRequest: type: object properties: model_name: $ref: "#/components/schemas/KlingVideoGenModelName" image: type: string description: Reference Image - URL or Base64 encoded string, cannot exceed 10MB, resolution not less than 300*300px, aspect ratio between 1:2.5 ~ 2.5:1. Base64 should not include data:image prefix. image_tail: type: string description: Reference Image - End frame control. URL or Base64 encoded string, cannot exceed 10MB, resolution not less than 300*300px. Base64 should not include data:image prefix. Cannot be used simultaneously with dynamic_masks/static_mask or camera_control. multi_shot: type: boolean default: false description: Whether to generate multi-shot video. When true, the prompt parameter is invalid. When false, the shot_type and multi_prompt parameters are invalid. shot_type: type: string enum: [customize] description: Storyboard method. Required when the multi_shot parameter is set to true. prompt: type: string maxLength: 2500 description: Positive text prompt. Use <<>> to specify a voice matching the voice_list parameter order. A task can reference up to 2 tones. When specifying a tone, the sound parameter value must be on. multi_prompt: type: array description: Information about each storyboard, such as prompts and duration. Supports up to 6 storyboards, with a minimum of 1. Required when multi_shot is true and shot_type is customize. items: type: object properties: index: type: integer description: Shot sequence number prompt: type: string maxLength: 512 description: Prompt word for this storyboard. Maximum length 512 characters. duration: type: string description: Duration of this storyboard in seconds. Must not exceed total task duration and must not be less than 1. Sum of all storyboard durations equals total task duration. negative_prompt: type: string maxLength: 2500 description: Negative text prompt. It is recommended to supplement negative prompt information through negative sentences directly within positive prompts. element_list: type: array description: Reference Element List based on element ID configuration. Supports up to 3 reference elements. The element_list and voice_list parameters are mutually exclusive. items: type: object properties: element_id: type: integer format: int64 description: Element ID cfg_scale: $ref: "#/components/schemas/KlingVideoGenCfgScale" mode: $ref: "#/components/schemas/KlingVideoGenMode" static_mask: type: string description: Static Brush Application Area (Mask image created by users using the motion brush). The aspect ratio must match the input image. dynamic_masks: type: array items: type: object properties: mask: type: string format: uri description: Dynamic Brush Application Area (Mask image created by users using the motion brush). The aspect ratio must match the input image. trajectories: type: array items: type: object properties: x: type: integer description: The horizontal coordinate of trajectory point. Based on bottom-left corner of image as origin (0,0). y: type: integer description: The vertical coordinate of trajectory point. Based on bottom-left corner of image as origin (0,0). description: Dynamic Brush Configuration List (up to 6 groups). For 5-second videos, trajectory length must not exceed 77 coordinates. camera_control: $ref: "#/components/schemas/KlingCameraControl" aspect_ratio: $ref: "#/components/schemas/KlingVideoGenAspectRatio" duration: $ref: "#/components/schemas/KlingVideoGenDuration" sound: type: string enum: [on, off] default: off description: Whether to generate sound simultaneously when generating videos. Only V2.6 and subsequent versions of the model support this parameter. watermark_info: type: object description: Whether to generate watermarked results simultaneously. Custom watermark is not supported at this time. properties: enabled: type: boolean description: true means generate watermark, false means do not generate. callback_url: type: string format: uri description: The callback notification address. Server will notify when the task status changes. external_task_id: type: string description: Customized Task ID. Must be unique within a single user account. KlingImage2VideoResponse: type: object properties: code: type: integer description: Error code message: type: string description: Error message request_id: type: string description: Request ID data: type: object properties: task_id: type: string description: Task ID task_status: $ref: "#/components/schemas/KlingTaskStatus" task_status_msg: type: string description: Task status information, displaying the failure reason when the task fails task_info: type: object properties: external_task_id: type: string watermark_info: type: object properties: enabled: type: boolean final_unit_deduction: type: string description: The deduction units of task created_at: type: integer description: Task creation time, Unix timestamp in milliseconds updated_at: type: integer description: Task update time, Unix timestamp in milliseconds task_result: type: object properties: videos: type: array items: $ref: "#/components/schemas/KlingVideoResult" KlingVideoExtendRequest: type: object properties: video_id: type: string description: The ID of the video to be extended. Supports videos generated by text-to-video, image-to-video, and previous video extension operations. Cannot exceed 3 minutes total duration after extension. prompt: type: string maxLength: 2500 description: Positive text prompt for guiding the video extension negative_prompt: type: string maxLength: 2500 description: Negative text prompt for elements to avoid in the extended video cfg_scale: $ref: "#/components/schemas/KlingVideoGenCfgScale" callback_url: type: string format: uri description: The callback notification address. Server will notify when the task status changes. KlingVideoExtendResponse: type: object properties: code: type: integer description: Error code message: type: string description: Error message request_id: type: string description: Request ID data: type: object properties: task_id: type: string description: Task ID task_status: $ref: "#/components/schemas/KlingTaskStatus" task_info: type: object properties: external_task_id: type: string created_at: type: integer description: Task creation time updated_at: type: integer description: Task update time task_result: type: object properties: videos: type: array items: $ref: "#/components/schemas/KlingVideoResult" KlingOmniVideoRequest: type: object properties: model_name: type: string enum: [kling-video-o1, kling-v3-omni] default: kling-video-o1 description: Model Name multi_shot: type: boolean default: false description: Whether to generate multi-shot video. When true, the prompt parameter is invalid. When false, the shot_type and multi_prompt parameters are invalid. shot_type: type: string enum: [customize] description: Storyboard method. Required when the multi_shot parameter is set to true. prompt: type: string maxLength: 2500 description: Text prompt words, which can include positive and negative descriptions. Must not exceed 2,500 characters. Can specify elements, images, or videos in the format <<<>>> such as <>, <<>>, <<>>. multi_prompt: type: array description: Information about each storyboard, such as prompts and duration. Supports up to 6 storyboards, with a minimum of 1. Required when multi_shot is true and shot_type is customize. items: type: object properties: index: type: integer description: Shot sequence number prompt: type: string maxLength: 512 description: Prompt word for this storyboard. Maximum length 512 characters. duration: type: string description: Duration of this storyboard in seconds. Must not exceed total task duration and must not be less than 1. Sum of all storyboard durations equals total task duration. image_list: type: array description: Reference Image List. Can include reference images of the element, scene, style, etc., or be used as the first or last frame to generate videos. items: type: object properties: image_url: type: string description: Image Base64 encoding or image URL (ensure accessibility). Supported formats include .jpg/.jpeg/.png. File size cannot exceed 10MB. Width and height dimensions shall not be less than 300px, aspect ratio between 1:2.5 ~ 2.5:1. type: type: string enum: [first_frame, end_frame] description: Whether the image is in the first or last frame. first_frame is the first frame, end_frame is the last frame. Currently does not support only the end frame. element_list: type: array description: Reference Element List based on element ID configuration. items: type: object properties: element_id: type: integer format: int64 description: Element ID video_list: type: array description: Reference Video list. Can be used as a reference video for feature or as a video to be edited, with the default being the video to be edited. items: type: object properties: video_url: type: string description: URL of uploaded video. Only .mp4/.mov formats are supported. Duration between 3-10 seconds. Resolution must be between 720px and 2160px. Frame rates of 24-60 fps supported. Only 1 video can be uploaded, with size not exceeding 200MB. refer_type: type: string enum: [feature, base] description: Reference video type. feature is the feature reference video, base is the video to be edited. keep_original_sound: type: string enum: [yes, no] description: Whether to keep the video original sound. yes indicates retention, no indicates non retention. sound: type: string enum: [on, off] default: "off" description: Whether sound is generated simultaneously when generating videos. mode: type: string enum: [pro, std] default: std description: "Video generation mode. std: Standard Mode, generating 720P videos, cost-effective. pro: Professional Mode, generating 1080P videos, higher quality video output." aspect_ratio: type: string enum: [16:9, 9:16, 1:1] description: The aspect ratio of the generated video frame (width:height). Required when first-frame reference or video editing features are not used. duration: type: string enum: ["3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15"] default: "5" description: "Video Length in seconds. When using video editing function (refer_type: base), output duration is the same as input video and this parameter is invalid." watermark_info: type: object description: Whether to generate watermarked results simultaneously. Custom watermark is not supported at this time. properties: enabled: type: boolean description: true means generate watermark, false means do not generate. callback_url: type: string format: uri description: The callback notification address for the result of this task. If configured, the server will actively notify when the task status changes. external_task_id: type: string description: Customized Task ID. Must be unique within a single user account. KlingOmniVideoResponse: type: object properties: code: type: integer description: Error code message: type: string description: Error message request_id: type: string description: Request ID data: type: object properties: task_id: type: string description: Task ID task_status: $ref: "#/components/schemas/KlingTaskStatus" task_status_msg: type: string description: Task status information, displaying the failure reason when the task fails task_info: type: object properties: external_task_id: type: string watermark_info: type: object properties: enabled: type: boolean final_unit_deduction: type: string description: The deduction units of task created_at: type: integer description: Task creation time, Unix timestamp in milliseconds updated_at: type: integer description: Task update time, Unix timestamp in milliseconds task_result: type: object properties: videos: type: array items: $ref: "#/components/schemas/KlingVideoResult" KlingOmniImageRequest: type: object required: [prompt] properties: model_name: type: string enum: [kling-image-o1, kling-v3-omni] default: kling-image-o1 description: Model Name prompt: type: string maxLength: 2500 description: Text prompt words, which can include positive and negative descriptions. Must not exceed 2,500 characters. The Omni model can achieve various capabilities through Prompt with elements and images. Specify an image in the format of <<<>>>, such as <<>>. image_list: type: array description: Reference Image List. Supports inputting image Base64 encoding or image URL (ensure accessibility). Supported formats include .jpg/.jpeg/.png. File size cannot exceed 10MB. Width and height dimensions shall not be less than 300px, aspect ratio between 1:2.5 ~ 2.5:1. The sum of reference elements and reference images shall not exceed 10. items: type: object properties: image: type: string description: Image Base64 encoding or image URL (ensure accessibility) element_list: type: array description: Reference Element List based on element ID configuration. The sum of reference elements and reference images shall not exceed 10. items: type: object properties: element_id: type: integer format: int64 description: Element ID resolution: type: string enum: ["1k", "2k", "4k"] default: "1k" description: Image generation resolution. 1k is 1K standard, 2k is 2K high-res, 4k is 4K high-res. result_type: type: string enum: [single, series] default: single description: Control whether to generate a single image or a series of images. n: type: integer minimum: 1 maximum: 9 default: 1 description: Number of generated images. Value range [1,9]. series_amount: type: integer minimum: 2 maximum: 9 default: 4 description: Number of images in a series. Value range [2,9]. aspect_ratio: type: string enum: ["16:9", "9:16", "1:1", "4:3", "3:4", "3:2", "2:3", "21:9", "auto"] default: auto description: Aspect ratio of the generated images (width:height). auto is to intelligently generate images based on incoming content. callback_url: type: string format: uri description: The callback notification address for the result of this task. If configured, the server will actively notify when the task status changes. external_task_id: type: string description: Customized Task ID. Must be unique within a single user account. KlingOmniImageResponse: type: object properties: code: type: integer description: Error code message: type: string description: Error message request_id: type: string description: Request ID data: type: object properties: task_id: type: string description: Task ID task_status: $ref: "#/components/schemas/KlingTaskStatus" task_status_msg: type: string description: Task status information, displaying the failure reason when the task fails (such as triggering the content risk control of the platform, etc.) task_info: type: object properties: external_task_id: type: string description: Customer-defined task ID final_unit_deduction: type: string description: The deduction units of task created_at: type: integer description: Task creation time, Unix timestamp in milliseconds updated_at: type: integer description: Task update time, Unix timestamp in milliseconds task_result: type: object properties: result_type: type: string enum: [single, series] description: Whether the result is a single image or a series of images images: type: array items: $ref: "#/components/schemas/KlingImageResult" series_images: type: array description: Series images result list items: type: object properties: index: type: integer description: Series-image sequence number url: type: string format: uri description: URL for generated image KlingLipSyncInputObject: type: object required: [mode] properties: video_id: type: string description: "The ID of the video generated by Kling AI. Only supports 5-second and 10-second videos generated within the last 30 days." video_url: type: string description: "Get link for uploaded video. Video files support .mp4/.mov, file size does not exceed 100MB, video length between 2-10s." mode: $ref: "#/components/schemas/KlingLipSyncMode" text: type: string description: "Text Content for Lip-Sync Video Generation. Required when mode is text2video. Maximum length is 120 characters." voice_id: type: string description: "Voice ID. Required when mode is text2video. The system offers a variety of voice options to choose from." voice_language: $ref: "#/components/schemas/KlingLipSyncVoiceLanguage" voice_speed: type: number minimum: 0.8 maximum: 2.0 default: 1.0 description: "Speech Rate. Valid range: 0.8~2.0, accurate to one decimal place." audio_type: $ref: "#/components/schemas/KlingAudioUploadType" audio_file: type: string description: "Local Path of Audio File. Supported formats: .mp3/.wav/.m4a/.aac, maximum file size of 5MB. Base64 code." audio_url: type: string description: "Audio File Download URL. Supported formats: .mp3/.wav/.m4a/.aac, maximum file size of 5MB." KlingLipSyncRequest: type: object required: [input] properties: input: $ref: "#/components/schemas/KlingLipSyncInputObject" callback_url: type: string format: uri description: "The callback notification address. Server will notify when the task status changes." KlingLipSyncResponse: type: object properties: code: type: integer description: Error code message: type: string description: Error message request_id: type: string description: Request ID data: type: object properties: task_id: type: string description: Task ID task_status: $ref: "#/components/schemas/KlingTaskStatus" task_info: type: object properties: external_task_id: type: string created_at: type: integer description: Task creation time updated_at: type: integer description: Task update time task_result: type: object properties: videos: type: array items: $ref: "#/components/schemas/KlingVideoResult" KlingAvatarRequest: type: object required: [image] properties: image: type: string description: "Avatar Reference Image. Supports Base64 encoding or image URL. Supported formats: .jpg/.jpeg/.png. Max 10MB, min 300px width/height, aspect ratio between 1:2.5 and 2.5:1." audio_id: type: string description: "Audio ID Generated via TTS API. Only supports 2-300 second audio generated within the last 30 days. Either audio_id or sound_file must be provided (mutually exclusive)." sound_file: type: string description: "Sound File. Supports Base64-encoded audio or accessible audio URL. Accepted formats: .mp3/.wav/.m4a/.aac (max 5MB), 2-300 seconds. Either audio_id or sound_file must be provided (mutually exclusive)." prompt: type: string maxLength: 2500 description: "Positive text prompt. Can define avatar actions, emotions, and camera movements." mode: $ref: "#/components/schemas/KlingAvatarMode" watermark_info: type: object properties: enabled: type: boolean description: "Whether to generate watermarked results simultaneously." callback_url: type: string format: uri description: "The callback notification address for the result of this task." external_task_id: type: string description: "Customized Task ID. Must be unique within a single user account." KlingAvatarMode: type: string enum: [std, pro] default: std description: "Video generation mode. std: Standard Mode (cost-effective), pro: Professional Mode (longer duration, higher quality)." KlingAvatarResponse: type: object properties: code: type: integer description: Error code message: type: string description: Error message request_id: type: string description: Request ID data: type: object properties: task_id: type: string description: Task ID task_status: $ref: "#/components/schemas/KlingTaskStatus" task_status_msg: type: string description: Task status information task_info: type: object properties: external_task_id: type: string watermark_info: type: object properties: enabled: type: boolean final_unit_deduction: type: string description: The deduction units of task created_at: type: integer description: Task creation time updated_at: type: integer description: Task update time task_result: type: object properties: videos: type: array items: $ref: "#/components/schemas/KlingVideoResult" KlingVideoEffectsRequest: type: object required: [effect_scene, input] properties: effect_scene: oneOf: - $ref: "#/components/schemas/KlingDualCharacterEffectsScene" - $ref: "#/components/schemas/KlingSingleImageEffectsScene" input: $ref: "#/components/schemas/KlingVideoEffectsInput" callback_url: type: string format: uri description: The callback notification address for the result of this task. external_task_id: type: string description: Customized Task ID. Must be unique within a single user account. KlingVideoEffectsInput: oneOf: - $ref: "#/components/schemas/KlingSingleImageEffectInput" - $ref: "#/components/schemas/KlingDualCharacterEffectInput" KlingSingleImageEffectInput: type: object required: [model_name, image, duration] properties: model_name: $ref: "#/components/schemas/KlingSingleImageEffectModelName" image: type: string description: Reference Image. URL or Base64 encoded string (without data:image prefix). File size cannot exceed 10MB, resolution not less than 300*300px, aspect ratio between 1:2.5 ~ 2.5:1. duration: $ref: "#/components/schemas/KlingSingleImageEffectDuration" KlingDualCharacterEffectInput: type: object required: [images, duration] properties: model_name: $ref: "#/components/schemas/KlingCharacterEffectModelName" mode: $ref: "#/components/schemas/KlingVideoGenMode" images: $ref: "#/components/schemas/KlingDualCharacterImages" duration: $ref: "#/components/schemas/KlingVideoGenDuration" KlingVideoEffectsResponse: type: object properties: code: type: integer description: Error code message: type: string description: Error message request_id: type: string description: Request ID data: type: object properties: task_id: type: string description: Task ID task_status: $ref: "#/components/schemas/KlingTaskStatus" task_info: type: object properties: external_task_id: type: string created_at: type: integer description: Task creation time updated_at: type: integer description: Task update time task_result: type: object properties: videos: type: array items: $ref: "#/components/schemas/KlingVideoResult" KlingMotionControlRequest: type: object required: [image_url, video_url, character_orientation, mode] properties: model_name: type: string enum: [kling-v2-6, kling-v3] default: "kling-v2-6" description: Model name for motion control. Enum values - kling-v2-6, kling-v3. prompt: type: string maxLength: 2500 description: Text prompt words, which can include positive and negative descriptions. Cannot exceed 2500 characters. image_url: type: string description: Reference Image. The characters, backgrounds, and other elements in the generated video are based on the reference image. Supports inputting image Base64 encoding or image URL (ensure accessibility). Supported image formats include .jpg / .jpeg / .png. The image file size cannot exceed 10MB, and the width and height dimensions of the image range from 300px to 65536px, and the aspect ratio of the image should be between 1:2.5 ~ 2.5:1. video_url: type: string description: The URL of the reference video. The character actions in the generated video are consistent with the reference video. The video file supports .mp4/.mov, with a file size not exceeding 100MB, and only supports side lengths between 340px and 3850px. The lower limit of video duration should not be less than 3 seconds, and the upper limit depends on character_orientation. element_list: type: array description: Reference Element List based on element ID configuration. Currently only one element can be introduced. items: type: object properties: element_id: type: integer format: int64 description: Element ID keep_original_sound: type: string enum: [yes, no] default: "yes" description: Whether to keep the original sound of the video. Enumeration values - yes (Keep the original sound), no (do not retain the original video sound). character_orientation: type: string enum: [image, video] description: Generate the orientation of the characters in the video. image - same orientation as the person in the picture (reference video duration should not exceed 10 seconds). video - consistent with the orientation of the characters in the video (reference video duration should not exceed 30 seconds). mode: type: string enum: [std, pro] description: Video generation mode. std - Standard Mode (cost-effective). pro - Professional Mode (longer duration but higher quality video output). watermark_info: type: object description: Whether to generate watermarked results simultaneously. Custom watermark is not supported at this time. properties: enabled: type: boolean description: true means generate watermark, false means do not generate. callback_url: type: string format: uri description: The callback notification address for the result of this task. If configured, the server will actively notify when the task status changes. external_task_id: type: string description: Customized Task ID. Users can provide a customized task ID, which will not overwrite the system-generated task ID but can be used for task queries. Must be unique within a single user account. KlingMotionControlResponse: type: object properties: code: type: integer description: Error code message: type: string description: Error message request_id: type: string description: Request ID data: type: object properties: task_id: type: string description: Task ID task_status: $ref: "#/components/schemas/KlingTaskStatus" task_status_msg: type: string description: Task status information, displaying the failure reason when the task fails task_info: type: object properties: external_task_id: type: string description: Customer-defined task ID watermark_info: type: object properties: enabled: type: boolean final_unit_deduction: type: string description: The deduction units of task created_at: type: integer description: Task creation time, Unix timestamp, unit ms updated_at: type: integer description: Task update time, Unix timestamp, unit ms task_result: type: object properties: videos: type: array items: $ref: "#/components/schemas/KlingMotionControlVideoResult" KlingMotionControlVideoResult: type: object properties: id: type: string description: Generated video ID; globally unique url: type: string description: URL for generating videos watermark_url: type: string description: URL for generating videos with watermark, hotlink protection format duration: type: string description: Total video duration, unit - s (seconds) KlingImageGenerationsRequest: type: object properties: model_name: $ref: "#/components/schemas/KlingImageGenModelName" prompt: type: string maxLength: 2500 description: Positive text prompt. Must not exceed 2,500 characters. negative_prompt: type: string maxLength: 2500 description: Negative text prompt. Cannot exceed 2500 characters. It is recommended to supplement negative prompt information through negative sentences directly within positive prompts. Not supported in Image-to-Image scenario (when image field is not empty). image: type: string description: Reference Image - Base64 encoded string or image URL. Supported formats include .jpg/.jpeg/.png. File size cannot exceed 10MB. Width and height dimensions shall not be less than 300px, aspect ratio between 1:2.5 ~ 2.5:1. Required when image_reference is not empty. image_reference: $ref: "#/components/schemas/KlingImageGenImageReferenceType" image_fidelity: type: number minimum: 0 maximum: 1 default: 0.5 description: Reference intensity for user-uploaded images human_fidelity: type: number minimum: 0 maximum: 1 default: 0.45 description: Subject reference similarity element_list: type: array description: Reference Element List based on element ID configuration. The sum of reference elements and reference images shall not exceed 10. items: type: object properties: element_id: type: integer format: int64 description: Element ID resolution: type: string enum: ["1k", "2k"] default: "1k" description: Image generation resolution. 1k is 1K standard, 2k is 2K high-res. n: type: integer minimum: 1 maximum: 9 default: 1 description: Number of generated images. Value range [1,9]. aspect_ratio: $ref: "#/components/schemas/KlingImageGenAspectRatio" callback_url: type: string format: uri description: The callback notification address external_task_id: type: string description: Customized Task ID. Must be unique within a single user account. required: - prompt KlingImageGenerationsResponse: type: object properties: code: type: integer description: Error code message: type: string description: Error message request_id: type: string description: Request ID data: type: object properties: task_id: type: string description: Task ID task_status: $ref: "#/components/schemas/KlingTaskStatus" task_status_msg: type: string description: Task status information, displaying the failure reason when the task fails final_unit_deduction: type: string description: The deduction units of task created_at: type: integer description: Task creation time, Unix timestamp in milliseconds updated_at: type: integer description: Task update time, Unix timestamp in milliseconds task_result: type: object properties: images: type: array items: $ref: "#/components/schemas/KlingImageResult" task_info: type: object properties: external_task_id: type: string description: Customer-defined task ID KlingVirtualTryOnRequest: type: object properties: model_name: $ref: "#/components/schemas/KlingVirtualTryOnModelName" human_image: type: string description: Reference human image - Base64 encoded string or image URL cloth_image: type: string description: Reference clothing image - Base64 encoded string or image URL callback_url: type: string format: uri description: The callback notification address required: - human_image KlingVirtualTryOnResponse: type: object properties: code: type: integer description: Error code message: type: string description: Error message request_id: type: string description: Request ID data: type: object properties: task_id: type: string description: Task ID task_status: $ref: "#/components/schemas/KlingTaskStatus" task_status_msg: type: string description: Task status information created_at: type: integer description: Task creation time updated_at: type: integer description: Task update time task_result: type: object properties: images: type: array items: $ref: "#/components/schemas/KlingImageResult" KlingResourcePackageResponse: type: object properties: code: type: integer description: Error code; 0 indicates success message: type: string description: Error information request_id: type: string description: Request ID, generated by the system, used to track requests and troubleshoot problems data: type: object properties: code: type: integer description: Error code; 0 indicates success msg: type: string description: Error information resource_pack_subscribe_infos: type: array description: Resource package list items: type: object properties: resource_pack_name: type: string description: Resource package name resource_pack_id: type: string description: Resource package ID resource_pack_type: type: string description: Resource package type (decreasing_total=decreasing total, constant_period=constant periodicity) enum: [decreasing_total, constant_period] total_quantity: type: number format: float description: Total quantity remaining_quantity: type: number format: float description: Remaining quantity (updated with a 12-hour delay) purchase_time: type: integer format: int64 description: Purchase time, Unix timestamp in ms effective_time: type: integer format: int64 description: Effective time, Unix timestamp in ms invalid_time: type: integer format: int64 description: Expiration time, Unix timestamp in ms status: type: string description: Resource Package Status enum: [toBeOnline, online, expired, runOut] LTXText2VideoRequest: type: object properties: prompt: type: string maxLength: 10000 description: Text prompt describing the desired video content model: type: string enum: [ltx-2-fast, ltx-2-pro] description: Model to use for generation duration: type: integer description: Video duration in seconds enum: [6, 8, 10] resolution: type: string enum: [1920x1080, 2560x1440, 3840x2160] description: Output video resolution fps: type: integer description: Frame rate in frames per second default: 25 enum: [25, 50] generate_audio: type: boolean description: Generate audio for the video default: true required: - prompt - model - duration - resolution LTXImage2VideoRequest: type: object properties: image_uri: type: string description: Image to be used as the first frame of the video (HTTPS URL or base64 data URI) prompt: type: string maxLength: 10000 description: Text description of how the image should be animated model: type: string enum: [ltx-2-fast, ltx-2-pro] description: Model to use for generation duration: type: integer description: Video duration in seconds enum: [6, 8, 10] resolution: type: string enum: [1920x1080, 2560x1440, 3840x2160] description: Output video resolution fps: type: integer description: Frame rate in frames per second default: 25 enum: [25, 50] generate_audio: type: boolean description: Generate audio for the video default: true required: - image_uri - prompt - model - duration - resolution StripeEvent: type: object required: [id, object, type, data] properties: id: type: string object: type: string enum: ["event"] data: type: object properties: object: type: object type: type: string enum: [invoice.paid] MinimaxVideoGenerationRequest: type: object description: Parameters for the Minimax video generation proxy request. properties: model: type: string description: "Required. ID of model. Options: MiniMax-Hailuo-02, T2V-01-Director, I2V-01-Director, S2V-01, I2V-01, I2V-01-live, T2V-01" enum: - MiniMax-Hailuo-02 - T2V-01-Director - I2V-01-Director - S2V-01 - I2V-01 - I2V-01-live - T2V-01 prompt: type: string description: "Description of the video. Should be less than 2000 characters. Supports camera movement instructions in [brackets]." maxLength: 2000 prompt_optimizer: type: boolean description: "If true (default), the model will automatically optimize the prompt. Set to false for more precise control." default: true first_frame_image: type: string description: "URL or base64 encoding of the first frame image. Required when model is I2V-01, I2V-01-Director, or I2V-01-live." subject_reference: type: array description: "Only available when model is S2V-01. The model will generate a video based on the subject uploaded through this parameter." items: type: object properties: image: type: string description: "URL or base64 encoding of the subject reference image." mask: type: string description: "URL or base64 encoding of the mask for the subject reference image." callback_url: type: string description: "Optional. URL to receive real-time status updates about the video generation task." duration: type: integer description: "Video length in seconds. Only available for MiniMax-Hailuo-02" enum: [6, 10] default: 6 resolution: type: string description: "Video resolution. Only available for MiniMax-Hailuo-02." enum: ["768P", "1080P"] default: "768P" required: - model MinimaxBaseResponse: type: object description: Common response structure used by Minimax APIs properties: status_code: type: integer description: "Status code. 0 indicates success, other values indicate errors." status_msg: type: string description: "Specific error details or success message." required: - status_code - status_msg MinimaxVideoGenerationResponse: type: object description: Response from the Minimax video generation API. properties: task_id: type: string description: "The task ID for the asynchronous video generation task." base_resp: $ref: "#/components/schemas/MinimaxBaseResponse" required: - task_id - base_resp MinimaxFileRetrieveResponse: type: object description: Response from retrieving a Minimax file download URL. properties: file: type: object properties: file_id: type: integer description: Unique identifier for the file bytes: type: integer description: File size in bytes created_at: type: integer description: Unix timestamp when the file was created, in seconds filename: type: string description: The name of the file purpose: type: string description: The purpose of using the file download_url: type: string description: The URL to download the video base_resp: $ref: "#/components/schemas/MinimaxBaseResponse" required: - file - base_resp MinimaxTaskResultResponse: type: object description: Response from querying a Minimax video generation task status. properties: task_id: type: string description: "The task ID being queried." status: type: string description: "Task status: 'Queueing' (in queue), 'Preparing' (task is preparing), 'Processing' (generating), 'Success' (task completed successfully), or 'Fail' (task failed)." enum: - Queueing - Preparing - Processing - Success - Fail file_id: type: string description: "After the task status changes to Success, this field returns the file ID corresponding to the generated video." base_resp: $ref: "#/components/schemas/MinimaxBaseResponse" required: - task_id - status - base_resp BFLFluxKontextProGenerateRequest: type: object required: - prompt - input_image properties: prompt: type: string description: The text prompt describing what to edit on the image input_image: type: string description: Base64 encoded image to be edited steps: type: integer description: Number of inference steps minimum: 1 maximum: 50 default: 50 guidance: type: number description: The guidance scale for generation minimum: 1.0 maximum: 20.0 default: 3.0 BFLFluxKontextProGenerateResponse: type: object required: - id - polling_url properties: id: type: string description: Job ID for tracking polling_url: type: string description: URL to poll for results BFLFluxKontextMaxGenerateRequest: type: object required: - prompt - input_image properties: prompt: type: string description: The text prompt describing what to edit on the image input_image: type: string description: Base64 encoded image to be edited steps: type: integer description: Number of inference steps minimum: 1 maximum: 50 default: 50 guidance: type: number description: The guidance scale for generation minimum: 1.0 maximum: 20.0 default: 3.0 BFLFluxKontextMaxGenerateResponse: type: object required: - id - polling_url properties: id: type: string description: Job ID for tracking polling_url: type: string description: URL to poll for results BFLFluxPro1_1GenerateRequest: type: object required: - prompt - width - height properties: prompt: type: string description: The main text prompt for image generation image_prompt: type: string description: Optional image prompt width: type: integer description: Width of the generated image height: type: integer description: Height of the generated image prompt_upsampling: type: boolean description: Whether to use prompt upsampling seed: type: integer description: Random seed for reproducibility safety_tolerance: type: integer description: Safety tolerance level output_format: type: string enum: [jpeg, png] description: Output image format webhook_url: type: string description: Optional webhook URL for async processing webhook_secret: type: string description: Optional webhook secret for async processing BFLFluxPro1_1GenerateResponse: type: object required: - id - polling_url properties: id: type: string description: Job ID for tracking polling_url: type: string description: URL to poll for results BFLFluxProGenerateRequest: type: object description: Request body for the BFL Flux Pro 1.1 Ultra image generation API. properties: prompt: type: string description: The text prompt for image generation. negative_prompt: type: string description: The negative prompt for image generation. width: type: integer description: The width of the image to generate. minimum: 64 maximum: 2048 height: type: integer description: The height of the image to generate. minimum: 64 maximum: 2048 num_inference_steps: type: integer description: The number of inference steps. minimum: 1 maximum: 100 guidance_scale: type: number description: The guidance scale for generation. minimum: 1.0 maximum: 20.0 seed: type: integer description: The seed value for reproducibility. num_images: type: integer description: The number of images to generate. minimum: 1 maximum: 4 required: - prompt - width - height BFLFluxProGenerateResponse: type: object description: Response from the BFL Flux Pro 1.1 Ultra image generation API. properties: id: type: string description: The unique identifier for the generation task. polling_url: type: string description: URL to poll for the generation result. cost: type: number format: float description: The cost of the generation task. input_mp: type: number format: float description: Input megapixels. output_mp: type: number format: float description: Output megapixels. required: - id - polling_url BFLFluxProStatusResponse: type: object description: Response from the BFL Flux Pro 1.1 Ultra status check API. properties: id: type: string description: The unique identifier for the generation task. status: $ref: "#/components/schemas/BFLStatus" description: The status of the task. result: type: object description: The result of the task (null if not completed). nullable: true progress: type: number format: float description: The progress of the task (0.0 to 1.0). minimum: 0.0 maximum: 1.0 details: type: object description: Additional details about the task (null if not available). nullable: true required: - id - status - progress BFLStatus: type: string description: Possible statuses for a BFL Flux Pro generation task. enum: - Task not found - Pending - Request Moderated - Content Moderated - Ready - Error example: Ready BFLFlux2ProGenerateRequest: type: object description: Request body for the BFL Flux 2 Pro image generation API. properties: prompt: type: string description: Text description of the image to generate. input_image: type: string description: Base64 encoded image for image-to-image generation. input_image_2: type: string description: Base64 encoded image for image-to-image generation. input_image_3: type: string description: Base64 encoded image for image-to-image generation. input_image_4: type: string description: Base64 encoded image for image-to-image generation. input_image_5: type: string description: Base64 encoded image for image-to-image generation. input_image_6: type: string description: Base64 encoded image for image-to-image generation. input_image_7: type: string description: Base64 encoded image for image-to-image generation. input_image_8: type: string description: Base64 encoded image for image-to-image generation. input_image_9: type: string description: Base64 encoded image for image-to-image generation. width: type: integer description: Width of the image. default: 1024 minimum: 256 maximum: 2048 height: type: integer description: Height of the image. default: 1024 minimum: 256 maximum: 2048 seed: type: integer description: Seed for reproducibility. prompt_upsampling: type: boolean description: Automatically modify prompt for generation. default: true output_format: type: string description: Output format for the generated image. default: jpeg enum: - jpeg - png safety_tolerance: type: integer description: Moderation tolerance level (Flux 2 Max only). default: 2 minimum: 0 maximum: 5 required: - prompt BFLFluxProFillInputs: properties: image: type: string title: Image description: >- A Base64-encoded string representing the image you wish to modify. Can contain alpha mask if desired. mask: anyOf: - type: string title: Mask description: >- A Base64-encoded string representing a mask for the areas you want to modify in the image. The mask should be the same dimensions as the image and in black and white. Black areas (0%) indicate no modification, while white areas (100%) specify areas for inpainting. Optional if you provide an alpha mask in the original image. Validation: The endpoint verifies that the dimensions of the mask match the original image. prompt: anyOf: - type: string title: Prompt description: >- The description of the changes you want to make. This text guides the inpainting process, allowing you to specify features, styles, or modifications for the masked area. default: "" example: ein fantastisches bild steps: anyOf: - type: integer maximum: 50 minimum: 15 title: Steps description: Number of steps for the image generation process default: 50 example: 50 prompt_upsampling: anyOf: - type: boolean title: Prompt Upsampling description: >- Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation default: false seed: anyOf: - type: integer title: Seed description: Optional seed for reproducibility guidance: anyOf: - type: number maximum: 100 minimum: 1.5 title: Guidance description: Guidance strength for the image generation process default: 60 output_format: anyOf: - $ref: "#/components/schemas/BFLOutputFormat" description: Output format for the generated image. Can be 'jpeg' or 'png'. default: jpeg safety_tolerance: type: integer maximum: 6 minimum: 0 title: Safety Tolerance description: >- Tolerance level for input and output moderation. Between 0 and 6, 0 being most strict, 6 being least strict. default: 2 example: 2 webhook_url: anyOf: - type: string maxLength: 2083 minLength: 1 format: uri title: Webhook Url description: URL to receive webhook notifications webhook_secret: anyOf: - type: string title: Webhook Secret description: Optional secret for webhook signature verification type: object required: - image title: FluxProFillInputs BFLAsyncResponse: properties: id: type: string title: Id polling_url: type: string title: Polling Url type: object required: - id - polling_url title: AsyncResponse BFLAsyncWebhookResponse: properties: id: type: string title: Id status: type: string title: Status webhook_url: type: string title: Webhook Url type: object required: - id - status - webhook_url title: AsyncWebhookResponse BFLHTTPValidationError: properties: detail: items: $ref: "#/components/schemas/BFLValidationError" type: array title: Detail type: object title: HTTPValidationError BFLFluxProExpandInputs: properties: image: type: string title: Image description: A Base64-encoded string representing the image you wish to expand. top: anyOf: - type: integer maximum: 2048 minimum: 0 title: Top description: Number of pixels to expand at the top of the image default: 0 bottom: anyOf: - type: integer maximum: 2048 minimum: 0 title: Bottom description: Number of pixels to expand at the bottom of the image default: 0 left: anyOf: - type: integer maximum: 2048 minimum: 0 title: Left description: Number of pixels to expand on the left side of the image default: 0 right: anyOf: - type: integer maximum: 2048 minimum: 0 title: Right description: Number of pixels to expand on the right side of the image default: 0 prompt: anyOf: - type: string title: Prompt description: >- The description of the changes you want to make. This text guides the expansion process, allowing you to specify features, styles, or modifications for the expanded areas. default: "" example: ein fantastisches bild steps: anyOf: - type: integer maximum: 50 minimum: 15 title: Steps description: Number of steps for the image generation process default: 50 example: 50 prompt_upsampling: anyOf: - type: boolean title: Prompt Upsampling description: >- Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation default: false seed: anyOf: - type: integer title: Seed description: Optional seed for reproducibility guidance: anyOf: - type: number maximum: 100 minimum: 1.5 title: Guidance description: Guidance strength for the image generation process default: 60 output_format: anyOf: - $ref: "#/components/schemas/BFLOutputFormat" description: Output format for the generated image. Can be 'jpeg' or 'png'. default: jpeg safety_tolerance: type: integer maximum: 6 minimum: 0 title: Safety Tolerance description: >- Tolerance level for input and output moderation. Between 0 and 6, 0 being most strict, 6 being least strict. default: 2 example: 2 webhook_url: anyOf: - type: string maxLength: 2083 minLength: 1 format: uri title: Webhook Url description: URL to receive webhook notifications webhook_secret: anyOf: - type: string title: Webhook Secret description: Optional secret for webhook signature verification type: object required: - image title: FluxProExpandInputs BFLCannyInputs: properties: prompt: type: string title: Prompt description: Text prompt for image generation example: ein fantastisches bild control_image: anyOf: - type: string title: Control Image description: >- Base64 encoded image to use as control input if no preprocessed image is provided preprocessed_image: anyOf: - type: string title: Preprocessed Image description: >- Optional pre-processed image that will bypass the control preprocessing step canny_low_threshold: anyOf: - type: integer maximum: 500 minimum: 0 title: Canny Low Threshold description: Low threshold for Canny edge detection default: 50 canny_high_threshold: anyOf: - type: integer maximum: 500 minimum: 0 title: Canny High Threshold description: High threshold for Canny edge detection default: 200 prompt_upsampling: anyOf: - type: boolean title: Prompt Upsampling description: Whether to perform upsampling on the prompt default: false seed: anyOf: - type: integer title: Seed description: Optional seed for reproducibility example: 42 steps: anyOf: - type: integer maximum: 50 minimum: 15 title: Steps description: Number of steps for the image generation process default: 50 output_format: anyOf: - $ref: "#/components/schemas/BFLOutputFormat" description: Output format for the generated image. Can be 'jpeg' or 'png'. default: jpeg guidance: anyOf: - type: number maximum: 100 minimum: 1 title: Guidance description: Guidance strength for the image generation process default: 30 safety_tolerance: type: integer maximum: 6 minimum: 0 title: Safety Tolerance description: >- Tolerance level for input and output moderation. Between 0 and 6, 0 being most strict, 6 being least strict. default: 2 webhook_url: anyOf: - type: string maxLength: 2083 minLength: 1 format: uri title: Webhook Url description: URL to receive webhook notifications webhook_secret: anyOf: - type: string title: Webhook Secret description: Optional secret for webhook signature verification type: object required: - prompt title: CannyInputs BFLDepthInputs: properties: prompt: type: string title: Prompt description: Text prompt for image generation example: ein fantastisches bild control_image: anyOf: - type: string title: Control Image description: Base64 encoded image to use as control input preprocessed_image: anyOf: - type: string title: Preprocessed Image description: >- Optional pre-processed image that will bypass the control preprocessing step prompt_upsampling: anyOf: - type: boolean title: Prompt Upsampling description: Whether to perform upsampling on the prompt default: false seed: anyOf: - type: integer title: Seed description: Optional seed for reproducibility example: 42 steps: anyOf: - type: integer maximum: 50 minimum: 15 title: Steps description: Number of steps for the image generation process default: 50 output_format: anyOf: - $ref: "#/components/schemas/BFLOutputFormat" description: Output format for the generated image. Can be 'jpeg' or 'png'. default: jpeg guidance: anyOf: - type: number maximum: 100 minimum: 1 title: Guidance description: Guidance strength for the image generation process default: 15 safety_tolerance: type: integer maximum: 6 minimum: 0 title: Safety Tolerance description: >- Tolerance level for input and output moderation. Between 0 and 6, 0 being most strict, 6 being least strict. default: 2 webhook_url: anyOf: - type: string maxLength: 2083 minLength: 1 format: uri title: Webhook Url description: URL to receive webhook notifications webhook_secret: anyOf: - type: string title: Webhook Secret description: Optional secret for webhook signature verification type: object required: - prompt title: DepthInputs BFLOutputFormat: type: string enum: - jpeg - png title: OutputFormat BFLValidationError: properties: loc: items: anyOf: - type: string - type: integer type: array title: Location msg: type: string title: Message type: type: string title: Error Type type: object required: - loc - msg - type title: ValidationError RecraftImageGenerationRequest: type: object description: Parameters for the Recraft image generation proxy request. properties: prompt: type: string description: The text prompt describing the image to generate model: type: string description: The model to use for generation (e.g., "recraftv3") style: type: string description: The style to apply to the generated image (e.g., "digital_illustration") style_id: type: string description: The style ID to apply to the generated image (e.g., "123e4567-e89b-12d3-a456-426614174000"). If style_id is provided, style should not be provided. size: type: string description: The size of the generated image (e.g., "1024x1024") controls: type: object description: The controls for the generated image properties: artistic_level: type: integer nullable: true description: Defines artistic tone of your image. At a simple level, the person looks straight at the camera in a static and clean style. Dynamic and eccentric levels introduce movement and creativity. minimum: 0 maximum: 5 colors: type: array description: An array of preferable colors items: $ref: "#/components/schemas/RGBColor" background_color: $ref: "#/components/schemas/RGBColor" description: Use given color as a desired background color no_text: type: boolean description: Do not embed text layouts n: type: integer description: The number of images to generate minimum: 1 maximum: 4 required: - prompt - model - size - n RecraftImageGenerationResponse: type: object description: Response from the Recraft image generation API. properties: created: type: integer description: Unix timestamp when the generation was created credits: type: integer description: Number of credits used for the generation data: type: array description: Array of generated image information items: type: object properties: image_id: type: string description: Unique identifier for the generated image url: type: string description: URL to access the generated image required: - created - credits - data RecraftImageFeatures: properties: nsfw_score: type: number type: object RecraftTextLayoutItem: properties: bbox: items: items: type: number x-go-type: float32 type: array type: array text: type: string required: - text - bbox type: object RecraftImageColor: properties: rgb: items: type: integer type: array std: items: type: number type: array weight: type: number type: object RecraftImageStyle: enum: - digital_illustration - icon - realistic_image - vector_illustration type: string RecraftImageSubStyle: enum: - 2d_art_poster - 3d - 80s - glow - grain - hand_drawn - infantile_sketch - kawaii - pixel_art - psychedelic - seamless - voxel - watercolor - broken_line - colored_outline - colored_shapes - colored_shapes_gradient - doodle_fill - doodle_offset_fill - offset_fill - outline - outline_gradient - uneven_fill - 70s - cartoon - doodle_line_art - engraving - flat_2 - kawaii - line_art - linocut - seamless - b_and_w - enterprise - hard_flash - hdr - motion_blur - natural_light - studio_portrait - line_circuit - 2d_art_poster_2 - engraving_color - flat_air_art - hand_drawn_outline - handmade_3d - stickers_drawings - plastic - pictogram type: string RecraftTransformModel: enum: - refm1 - recraft20b - recraftv2 - recraftv3 - recraftv4 - recraftv4_pro - flux1_1pro - flux1dev - imagen3 - hidream_i1_dev type: string RecraftImageFormat: enum: - webp - png type: string RecraftResponseFormat: enum: - url - b64_json type: string RecraftImage: properties: b64_json: type: string features: $ref: "#/components/schemas/RecraftImageFeatures" image_id: format: uuid type: string revised_prompt: type: string url: type: string required: - image_id type: object RecraftUserControls: properties: artistic_level: type: integer background_color: $ref: "#/components/schemas/RecraftImageColor" colors: items: $ref: "#/components/schemas/RecraftImageColor" type: array no_text: type: boolean type: object RecraftTextLayout: items: $ref: "#/components/schemas/RecraftTextLayoutItem" type: array RecraftProcessImageRequest: properties: image: format: binary type: string image_format: $ref: "#/components/schemas/RecraftImageFormat" response_format: $ref: "#/components/schemas/RecraftResponseFormat" required: - image type: object RecraftProcessImageResponse: properties: created: type: integer credits: type: integer image: $ref: "#/components/schemas/RecraftImage" required: - created - image - credits type: object RecraftImageToImageRequest: properties: block_nsfw: type: boolean calculate_features: type: boolean controls: $ref: "#/components/schemas/RecraftUserControls" image: format: binary type: string image_format: $ref: "#/components/schemas/RecraftImageFormat" model: $ref: "#/components/schemas/RecraftTransformModel" "n": type: integer negative_prompt: type: string prompt: type: string response_format: $ref: "#/components/schemas/RecraftResponseFormat" strength: type: number style: $ref: "#/components/schemas/RecraftImageStyle" style_id: format: uuid type: string substyle: $ref: "#/components/schemas/RecraftImageSubStyle" text_layout: $ref: "#/components/schemas/RecraftTextLayout" required: - prompt - image - strength type: object RecraftGenerateImageResponse: properties: created: type: integer credits: type: integer data: items: $ref: "#/components/schemas/RecraftImage" type: array required: - created - data - credits type: object RecraftTransformImageWithMaskRequest: properties: block_nsfw: type: boolean calculate_features: type: boolean image: format: binary type: string image_format: $ref: "#/components/schemas/RecraftImageFormat" mask: format: binary type: string model: $ref: "#/components/schemas/RecraftTransformModel" "n": type: integer negative_prompt: type: string prompt: type: string response_format: $ref: "#/components/schemas/RecraftResponseFormat" style: $ref: "#/components/schemas/RecraftImageStyle" style_id: format: uuid type: string substyle: $ref: "#/components/schemas/RecraftImageSubStyle" text_layout: $ref: "#/components/schemas/RecraftTextLayout" required: - image - mask - prompt type: object RecraftCreateStyleRequest: type: object description: Request body for creating a Recraft style reference properties: style: type: string description: The base style of the generated images enum: - realistic_image - digital_illustration - vector_illustration - icon file1: type: string format: binary description: First image file (PNG, JPG, or WEBP) file2: type: string format: binary description: Second image file (PNG, JPG, or WEBP) file3: type: string format: binary description: Third image file (PNG, JPG, or WEBP) file4: type: string format: binary description: Fourth image file (PNG, JPG, or WEBP) file5: type: string format: binary description: Fifth image file (PNG, JPG, or WEBP) required: - style - file1 RecraftCreateStyleResponse: type: object description: Response containing the created style ID properties: id: type: string format: uuid description: The unique identifier of the created style required: - id TencentHunyuan3DProRequest: type: object description: Request body for Tencent Hunyuan 3D Pro generation properties: Model: type: string description: | Tencent HY 3D Global model version. Defaults to 3.0, with optional choices: 3.0, 3.1. When selecting version 3.1, the LowPoly parameter is unavailable. enum: ["3.0", "3.1"] default: "3.0" example: "3.0" Prompt: type: string description: | Text description for 3D content generation. Supports up to 1024 utf-8 characters. Either Prompt or ImageBase64/ImageUrl is required, but not both. maxLength: 1024 example: "A cat" ImageBase64: type: string description: | Base64 encoded image for image-to-3D generation. Resolution: min 128px, max 5000px per side. Max size: 8MB (recommend 6MB before encoding). Supported formats: jpg, png, jpeg, webp. Either ImageBase64/ImageUrl or Prompt is required. ImageUrl: type: string format: uri description: | URL of input image for image-to-3D generation. Resolution: min 128px, max 5000px per side. Max size: 8MB. Supported formats: jpg, png, jpeg, webp. Either ImageBase64/ImageUrl or Prompt is required. EnablePBR: type: boolean description: Whether to enable PBR material generation. default: false FaceCount: type: integer description: Face count for 3D model generation. minimum: 40000 maximum: 1500000 default: 500000 GenerateType: type: string description: | Generation task type: - Normal: generates a geometric model with textures (default) - LowPoly: model generated after intelligent polygon reduction - Geometry: generate model without textures (white model) - Sketch: generative model from sketch or line drawing enum: ["Normal", "LowPoly", "Geometry", "Sketch"] default: "Normal" PolygonType: type: string description: | Polygon type (only effective when GenerateType is LowPoly). - triangle: triangular faces (default) - quadrilateral: mix of quadrangle and triangle faces enum: ["triangle", "quadrilateral"] default: "triangle" MultiViewImages: type: array description: | Multi-perspective model images for 3D generation. Each perspective is limited to one image. Image size limit: max 8MB after encoding. Image resolution: min 128px, max 5000px per side. Supported formats: JPG, PNG. items: $ref: "#/components/schemas/TencentViewImage" TencentViewImage: type: object description: A view image for multi-perspective 3D generation properties: ViewType: type: string description: | The viewing angle type for this image. - left: Left view - right: Right view - back: Rear view - top: Top view (only supported in Model 3.1) - bottom: Bottom view (only supported in Model 3.1) - left_front: Left front 45 degree view (only supported in Model 3.1) - right_front: Right front 45 degree view (only supported in Model 3.1) enum: ["left", "right", "back", "top", "bottom", "left_front", "right_front"] ViewImageBase64: type: string description: | Base64 encoded image for this view. Resolution: min 128px, max 5000px per side. Max size: 8MB. Supported formats: JPG, PNG. ViewImageUrl: type: string format: uri description: | URL of the image for this view. Resolution: min 128px, max 5000px per side. Max size: 8MB. Supported formats: JPG, PNG. TencentHunyuan3DProResponse: type: object description: Response from Tencent Hunyuan 3D Pro submit endpoint properties: Response: type: object properties: JobId: type: string description: Task ID (valid for 24 hours) example: "1375367755519696896" RequestId: type: string description: Unique request ID for troubleshooting example: "13f47dd0-1af9-4383-b401-dae18d6e99fc" Error: type: object description: Error object (present when request fails) properties: Code: type: string description: Error code Message: type: string description: Error message TencentHunyuan3DQueryRequest: type: object required: - JobId properties: JobId: type: string description: The JobId returned from the submit endpoint example: "1375367755519696896" TencentHunyuan3DQueryResponse: type: object description: Response from Tencent Hunyuan 3D query endpoint properties: Response: type: object properties: Status: type: string description: | Task status: - WAIT: waiting - RUN: running - FAIL: failed - DONE: successful enum: ["WAIT", "RUN", "FAIL", "DONE"] ErrorCode: type: string description: Error code (empty string if no error) ErrorMessage: type: string description: Error message if task failed (empty string if no error) ResultFile3Ds: type: array description: Array of generated 3D files items: $ref: "#/components/schemas/TencentFile3D" RequestId: type: string description: Unique request ID for troubleshooting TencentFile3D: type: object description: 3D file information properties: Type: type: string description: 3D file format enum: ["GLB", "OBJ"] Url: type: string format: uri description: File URL (valid for 24 hours) PreviewImageUrl: type: string format: uri description: Preview image URL TencentErrorResponse: type: object description: Error response from Tencent API properties: Response: type: object properties: Error: type: object properties: Code: type: string description: Error code Message: type: string description: Error message RequestId: type: string description: Unique request ID for troubleshooting TencentHunyuan3DUVRequest: type: object description: Request body for Tencent Hunyuan 3D UV unfolding properties: File: $ref: "#/components/schemas/TencentInputFile3D" TencentInputFile3D: type: object description: 3D file input for UV unwrapping properties: Type: type: string description: 3D file format type enum: ["FBX", "OBJ", "GLB"] example: "GLB" Url: type: string format: uri description: URL of the 3D file that needs UV unwrapping example: "https://example.com/model.glb" required: - Type - Url TencentHunyuan3DUVResponse: type: object description: Response from Tencent Hunyuan 3D UV submit endpoint properties: Response: type: object properties: JobId: type: string description: Task ID for the UV unwrapping job example: "1384898587778465792" RequestId: type: string description: Unique request ID for troubleshooting example: "5265eb4a-0f4f-4cb1-9b3d-d9f1fb9347d2" Error: type: object description: Error object (present when request fails) properties: Code: type: string description: Error code Message: type: string description: Error message TencentHunyuan3DTextureEditRequest: type: object description: Request body for Tencent Hunyuan 3D texture edit required: - File3D properties: File3D: $ref: "#/components/schemas/TencentInputFile3D" description: File URL of the 3D model that requires texture edit. Supported format FBX, less than 100000 faces. Image: $ref: "#/components/schemas/TencentImageInfo" description: Reference image for 3D model texture editing. Either Base64 or Url must be provided. If both provided, Url prevails. Incompatible with Prompt. Prompt: type: string maxLength: 1024 description: Describes texture editing. Either Image or Prompt is required; they cannot coexist. example: "a kitten" EnablePBR: type: boolean description: Whether to enable the PBR texture parameter; only supported when using Prompt. example: true TencentImageInfo: type: object description: Reference image - Base64 data or image URL properties: ImageBase64: type: string description: Base64 encoded image. Resolution 128-4096 per side, converted Base64 less than 10MB. Formats jpg, jpeg, png. ImageUrl: type: string format: uri description: Image URL. If both Base64 and Url provided, Url prevails. TencentHunyuan3DSmartTopologyRequest: type: object description: Request body for Tencent Hunyuan 3D Smart Topology (retopology/polygon reduction) required: - File3D properties: File3D: $ref: "#/components/schemas/TencentInputFile3D" description: Source 3D file model link. Supported formats GLB, OBJ. File size max 200MB. PolygonType: type: string description: Polygon type for the output mesh. Defaults to triangle. enum: ["triangle", "quadrilateral"] example: "triangle" FaceLevel: type: string description: Polygon reduction level. enum: ["high", "medium", "low"] example: "medium" HitPawPhotoEnhancerRequest: type: object description: Request body for HitPaw Photo Enhancement API required: - model_name - img_url - extension properties: model_name: type: string description: | The model name to use for enhancement. **Available Models:** - face_2x, face_4x: Face Clear Model (2x/4x upscaling) - face_v2_2x, face_v2_4x: Face Natural Model (2x/4x upscaling) - general_2x, general_4x: General Enhance Model (2x/4x upscaling) - high_fidelity_2x, high_fidelity_4x: High Fidelity Model (2x/4x upscaling) - sharpen_denoise: Sharp Denoise Model - detail_denoise: Detail Denoise Model - generative_portrait: Generative Portrait Model - generative: Generative Enhance Model enum: - face_2x - face_4x - face_v2_2x - face_v2_4x - general_2x - general_4x - high_fidelity_2x - high_fidelity_4x - sharpen_denoise - detail_denoise - generative_portrait - generative example: "generative_portrait" img_url: type: string format: uri description: URL of the image to be enhanced. Must be publicly accessible. example: "https://example.com/image.jpg" extension: type: string description: File extension of the image (e.g., ".jpg", ".png") example: ".jpg" exif: type: boolean description: Whether to preserve EXIF data (default false) example: true DPI: type: integer format: int64 description: Target DPI for the output image example: 300 HitPawJobResponse: type: object description: Response from HitPaw Enhancement APIs (photo and video) properties: code: type: integer description: Status code, 200 indicates success example: 200 message: type: string description: Response message example: "OK" data: type: object properties: job_id: type: string description: Unique identifier for the enhancement job example: "f5007c0b-e902-4070-8c75-f337d896168f" consume_coins: type: integer description: Number of coins consumed for this task example: 75 HitPawTaskStatusRequest: type: object description: Request body for HitPaw Task Status Query API required: - job_id properties: job_id: type: string description: Task ID obtained from Enhancement API response example: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" HitPawTaskStatusResponse: type: object description: Response from HitPaw Task Status Query API properties: code: type: integer description: Status code, 200 indicates success example: 200 message: type: string description: Response message example: "OK" data: type: object properties: job_id: type: string description: Task ID example: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" status: type: string description: | Task status: - WAITING: The job is queued and waiting to be processed - CONVERTING: Processing task in progress - COMPLETED: Task completed successfully - ERROR: Task failed enum: ["WAITING", "CONVERTING", "COMPLETED", "ERROR"] res_url: type: string format: uri description: Result URL, only valid when status is COMPLETED example: "https://example.com/result.jpg" original_url: type: string format: uri description: Original Image URL (photo enhancement only) example: "https://example.com/original.jpg" HitPawErrorResponse: type: object description: Error response from HitPaw API properties: error_code: type: integer description: Error code message: type: string description: Error message HitPawVideoEnhancerRequest: type: object description: Request body for HitPaw Video Enhancement API required: - video_url - model_name - resolution properties: video_url: type: string format: uri description: URL of the video to be enhanced example: "https://example.com/video.mp4" model_name: type: string description: | Model name to use for enhancement. **Available Models:** - face_soft: Face Soft Model - portrait_restore_1x: Portrait Restore Model 1x - portrait_restore_2x: Portrait Restore Model 2x - general_restore_1x: General Restore Model 1x - general_restore_2x: General Restore Model 2x - general_restore_4x: General Restore Model 4x - ultrahd_restore: Ultra HD Model - generative: Generative Model (SD) enum: - face_soft - portrait_restore_1x - portrait_restore_2x - general_restore_1x - general_restore_2x - general_restore_4x - ultrahd_restore - generative example: "general_restore_2x" resolution: type: array description: Target resolution [width, height] items: type: integer minItems: 2 maxItems: 2 example: [1920, 1080] extension: type: string description: File extension for the output video (default ".mp4") default: ".mp4" example: ".mp4" original_resolution: type: array description: Original video resolution [width, height] items: type: integer minItems: 2 maxItems: 2 example: [1280, 720] # ElevenLabs Schemas ElevenLabsVoiceSettings: type: object nullable: true description: Voice settings configuration properties: stability: type: number format: double nullable: true minimum: 0 maximum: 1 default: 0.5 description: Stability of the voice. Lower values introduce broader emotional range. similarity_boost: type: number format: double nullable: true minimum: 0 maximum: 1 default: 0.75 description: How closely the AI adheres to the original voice when replicating it. style: type: number format: double nullable: true minimum: 0 maximum: 1 default: 0 description: Style exaggeration. Amplifies the style of the original speaker. use_speaker_boost: type: boolean nullable: true default: true description: Boosts similarity to the original speaker. Requires higher computational load. speed: type: number format: double nullable: true minimum: 0.7 maximum: 1.2 default: 1.0 description: Speed adjustment. 1.0 is default, values below slow down, values above speed up. ElevenLabsTTSRequest: type: object description: Request body for ElevenLabs Text to Speech properties: text: type: string description: The text that will be converted into speech. model_id: type: string description: Identifier of the model to use. Query /v1/models to list available models. default: eleven_multilingual_v2 language_code: type: string nullable: true description: Language code (ISO 639-1) to enforce for the model. If unsupported, an error is returned. voice_settings: $ref: "#/components/schemas/ElevenLabsVoiceSettings" pronunciation_dictionary_locators: type: array nullable: true items: $ref: "#/components/schemas/ElevenLabsPronunciationDictionaryLocator" description: List of pronunciation dictionary locators (id, version_id). Maximum 3 per request. maxItems: 3 seed: type: integer nullable: true description: Seed for deterministic generation. Must be between 0 and 4294967295. minimum: 0 maximum: 4294967295 previous_text: type: string nullable: true description: Text that came before this request, used to improve speech continuity. next_text: type: string nullable: true description: Text that comes after this request, used to improve speech continuity. previous_request_ids: type: array nullable: true items: type: string description: Request IDs of previous generations for continuity. Maximum 3. maxItems: 3 next_request_ids: type: array nullable: true items: type: string description: Request IDs of next generations for continuity. Maximum 3. maxItems: 3 apply_text_normalization: type: string enum: - auto - "on" - "off" default: auto description: | Controls text normalization. 'auto' lets the system decide, 'on' always applies normalization, 'off' skips normalization. apply_language_text_normalization: type: boolean default: false description: Controls language-specific text normalization. Can heavily increase latency. Currently only supported for Japanese. use_pvc_as_ivc: type: boolean default: false description: Deprecated. If true, uses IVC version of voice instead of PVC. required: - text ElevenLabsPronunciationDictionaryLocator: type: object description: Locator for a pronunciation dictionary properties: pronunciation_dictionary_id: type: string description: The ID of the pronunciation dictionary version_id: type: string description: The version ID of the pronunciation dictionary required: - pronunciation_dictionary_id - version_id ElevenLabsValidationError: type: object description: Validation error response from ElevenLabs properties: detail: type: object description: Details about the validation error properties: status: type: string description: Error status message: type: string description: Error message # ElevenLabs Speech-to-Text Schemas ElevenLabsSTTRequest: type: object description: Request body for ElevenLabs Speech-to-Text required: - model_id properties: model_id: type: string enum: - scribe_v1 - scribe_v2 description: The ID of the model to use for transcription. file: type: string format: binary description: | The file to transcribe. All major audio and video formats are supported. Exactly one of file or cloud_storage_url parameters must be provided. The file size must be less than 3.0GB. language_code: type: string nullable: true description: | An ISO-639-1 or ISO-639-3 language_code corresponding to the language of the audio file. Can sometimes improve transcription performance if known beforehand. Defaults to null, in this case the language is predicted automatically. tag_audio_events: type: boolean default: true description: Whether to tag audio events like (laughter), (footsteps), etc. in the transcription. num_speakers: type: integer nullable: true description: | The maximum amount of speakers talking in the uploaded file. Can help with predicting who speaks when. The maximum amount of speakers that can be predicted is 32. Defaults to null, in this case the amount of speakers is set to the maximum value the model supports. timestamps_granularity: type: string enum: - none - word - character default: word description: | The granularity of the timestamps in the transcription. 'word' provides word-level timestamps and 'character' provides character-level timestamps per word. diarize: type: boolean default: false description: Whether to annotate which speaker is currently talking in the uploaded file. diarization_threshold: type: number format: double nullable: true description: | Diarization threshold to apply during speaker diarization. A higher value means there will be a lower chance of one speaker being diarized as two different speakers. Can only be set when diarize=True and num_speakers=None. Defaults to None. additional_formats: type: array nullable: true items: $ref: "#/components/schemas/ElevenLabsSTTExportOptions" description: A list of additional formats to export the transcript to. file_format: type: string enum: - pcm_s16le_16 - other default: other description: | The format of input audio. Options are 'pcm_s16le_16' or 'other'. For pcm_s16le_16, the input audio must be 16-bit PCM at a 16kHz sample rate, single channel (mono). cloud_storage_url: type: string nullable: true description: | The HTTPS URL of the file to transcribe. Exactly one of file or cloud_storage_url parameters must be provided. The file must be accessible via HTTPS and the file size must be less than 2GB. webhook: type: boolean default: false description: | Whether to send the transcription result to configured speech-to-text webhooks. If set the request will return early without the transcription, which will be delivered later via webhook. webhook_id: type: string nullable: true description: | Optional specific webhook ID to send the transcription result to. Only valid when webhook is set to true. temperature: type: number format: double nullable: true description: | Controls the randomness of the transcription output. Accepts values between 0.0 and 2.0. Higher values result in more diverse and less deterministic results. seed: type: integer nullable: true description: | If specified, our system will make a best effort to sample deterministically. Must be an integer between 0 and 2147483647. minimum: 0 maximum: 2147483647 use_multi_channel: type: boolean default: false description: | Whether the audio file contains multiple channels where each channel contains a single speaker. When enabled, each channel will be transcribed independently and the results will be combined. A maximum of 5 channels is supported. webhook_metadata: type: string nullable: true description: | Optional metadata to be included in the webhook response. This should be a JSON string representing an object with a maximum depth of 2 levels and maximum size of 16KB. entity_detection: nullable: true oneOf: - type: string - type: array items: type: string description: | Detect entities in the transcript. Can be 'all' to detect all entities, a single entity type or category string, or a list of entity types/categories. Categories include 'pii', 'phi', 'pci', 'other', 'offensive_language'. When enabled, detected entities will be returned in the 'entities' field with their text, type, and character positions. Usage of this parameter will incur additional costs. keyterms: type: array nullable: true items: type: string description: | A list of keyterms to bias the transcription towards. The number of keyterms cannot exceed 100 and each keyterm must be less than 50 characters. ElevenLabsSTTExportOptions: type: object description: Export format options for speech-to-text transcripts required: - format properties: format: type: string enum: - segmented_json - docx - pdf - txt - html - srt description: The output format for the transcript export. include_speakers: type: boolean default: true description: Whether to include speaker labels in the export. include_timestamps: type: boolean default: true description: Whether to include timestamps in the export. segment_on_silence_longer_than_s: type: number format: double nullable: true description: Segment the transcript when silence is longer than this value in seconds. max_segment_duration_s: type: number format: double nullable: true description: Maximum duration of each segment in seconds. max_segment_chars: type: integer nullable: true description: Maximum number of characters per segment. max_characters_per_line: type: integer nullable: true description: Maximum characters per line (for txt and srt formats). ElevenLabsSTTResponse: type: object description: Response from ElevenLabs Speech-to-Text properties: language_code: type: string description: The detected language code (e.g. 'eng' for English). language_probability: type: number format: double description: The confidence score of the language detection (0 to 1). text: type: string description: The raw text of the transcription. words: type: array items: $ref: "#/components/schemas/ElevenLabsSTTWord" description: List of words with their timing information. channel_index: type: integer nullable: true description: The channel index this transcript belongs to (for multichannel audio). additional_formats: type: array nullable: true items: $ref: "#/components/schemas/ElevenLabsSTTAdditionalFormat" description: Requested additional formats of the transcript. transcription_id: type: string nullable: true description: The transcription ID of the response. entities: type: array nullable: true items: $ref: "#/components/schemas/ElevenLabsSTTDetectedEntity" description: List of detected entities with their text, type, and character positions. transcripts: type: array nullable: true items: $ref: "#/components/schemas/ElevenLabsSTTTranscript" description: List of transcripts for multichannel audio (when use_multi_channel is true). message: type: string nullable: true description: Message for webhook responses. request_id: type: string nullable: true description: Request ID for webhook responses. ElevenLabsSTTWord: type: object description: Word information from speech-to-text transcription required: - text - type - logprob properties: text: type: string description: The word or sound that was transcribed. start: type: number format: double nullable: true description: The start time of the word or sound in seconds. end: type: number format: double nullable: true description: The end time of the word or sound in seconds. type: type: string enum: - word - spacing - audio_event description: | The type of the word or sound. 'audio_event' is used for non-word sounds like laughter or footsteps. speaker_id: type: string nullable: true description: Unique identifier for the speaker of this word. logprob: type: number format: double description: | The log of the probability with which this word was predicted. Logprobs are in range [-infinity, 0], higher logprobs indicate higher confidence. characters: type: array nullable: true items: $ref: "#/components/schemas/ElevenLabsSTTCharacter" description: The characters that make up the word and their timing information. ElevenLabsSTTCharacter: type: object description: Character information with timing required: - text properties: text: type: string description: The character that was transcribed. start: type: number format: double nullable: true description: The start time of the character in seconds. end: type: number format: double nullable: true description: The end time of the character in seconds. ElevenLabsSTTAdditionalFormat: type: object description: Additional format response for transcript export required: - requested_format - file_extension - content_type - is_base64_encoded - content properties: requested_format: type: string description: The requested format. file_extension: type: string description: The file extension of the additional format. content_type: type: string description: The content type of the additional format. is_base64_encoded: type: boolean description: Whether the content is base64 encoded. content: type: string description: The content of the additional format. ElevenLabsSTTDetectedEntity: type: object description: Detected entity in transcript required: - text - entity_type - start_char - end_char properties: text: type: string description: The text that was identified as an entity. entity_type: type: string description: The type of entity detected (e.g., 'credit_card', 'email_address', 'person_name'). start_char: type: integer description: Start character position in the transcript text. end_char: type: integer description: End character position in the transcript text. ElevenLabsSTTTranscript: type: object description: Individual transcript for multichannel audio properties: language_code: type: string description: The detected language code. language_probability: type: number format: double description: The confidence score of the language detection. text: type: string description: The raw text of the transcription. words: type: array items: $ref: "#/components/schemas/ElevenLabsSTTWord" description: List of words with their timing information. channel_index: type: integer nullable: true description: The channel index this transcript belongs to. additional_formats: type: array nullable: true items: $ref: "#/components/schemas/ElevenLabsSTTAdditionalFormat" description: Requested additional formats. entities: type: array nullable: true items: $ref: "#/components/schemas/ElevenLabsSTTDetectedEntity" description: List of detected entities. # ElevenLabs Speech-to-Speech (Voice Changer) Schemas ElevenLabsSpeechToSpeechRequest: type: object description: Request body for ElevenLabs Speech-to-Speech (Voice Changer) required: - audio properties: audio: type: string format: binary description: The audio file which holds the content and emotion that will control the generated speech. model_id: type: string default: eleven_english_sts_v2 description: | Identifier of the model that will be used. Query GET /v1/models to list available models. The model needs to have support for speech to speech (can_do_voice_conversion property). voice_settings: type: string nullable: true description: | Voice settings overriding stored settings for the given voice. They are applied only on the given request. Needs to be sent as a JSON encoded string. seed: type: integer nullable: true description: | If specified, our system will make a best effort to sample deterministically. Repeated requests with the same seed and parameters should return the same result. Must be integer between 0 and 4294967295. minimum: 0 maximum: 4294967295 remove_background_noise: type: boolean default: false description: | If set, will remove the background noise from your audio input using our audio isolation model. Only applies to Voice Changer. file_format: type: string nullable: true enum: - pcm_s16le_16 - other default: other description: | The format of input audio. Options are 'pcm_s16le_16' or 'other'. For pcm_s16le_16, the input audio must be 16-bit PCM at a 16kHz sample rate, single channel (mono). # ElevenLabs Text-to-Dialogue Schemas ElevenLabsTextToDialogueRequest: type: object description: Request body for ElevenLabs Text-to-Dialogue (multi-voice TTS) required: - inputs properties: inputs: type: array items: $ref: "#/components/schemas/ElevenLabsDialogueInput" description: | A list of dialogue inputs, each containing text and a voice ID which will be converted into speech. The maximum number of unique voice IDs is 10. model_id: type: string default: eleven_v3 description: | Identifier of the model that will be used. Query GET /v1/models to list available models. The model needs to have support for text to speech (can_do_text_to_speech property). language_code: type: string nullable: true description: | Language code (ISO 639-1) used to enforce a language for the model and text normalization. If the model does not support provided language code, an error will be returned. settings: $ref: "#/components/schemas/ElevenLabsDialogueSettings" pronunciation_dictionary_locators: type: array nullable: true items: $ref: "#/components/schemas/ElevenLabsPronunciationDictionaryLocator" description: | A list of pronunciation dictionary locators (id, version_id) to be applied to the text. They will be applied in order. You may have up to 3 locators per request. maxItems: 3 seed: type: integer nullable: true description: | If specified, our system will make a best effort to sample deterministically. Repeated requests with the same seed and parameters should return the same result. Must be integer between 0 and 4294967295. minimum: 0 maximum: 4294967295 apply_text_normalization: type: string enum: - auto - "on" - "off" default: auto description: | Controls text normalization with three modes: 'auto' - system automatically decides whether to apply text normalization 'on' - text normalization will always be applied 'off' - text normalization will be skipped ElevenLabsDialogueInput: type: object description: A single dialogue input containing text and voice ID required: - text - voice_id properties: text: type: string description: The text to be converted into speech. voice_id: type: string description: The ID of the voice to be used for the generation. ElevenLabsDialogueSettings: type: object nullable: true description: Settings controlling the dialogue generation properties: stability: type: number format: double nullable: true default: 0.5 description: | Determines how stable the voice is and the randomness between each generation. Lower values introduce broader emotional range for the voice. Higher values can result in a monotonous voice with limited emotion. # ElevenLabs Audio Isolation Schemas ElevenLabsAudioIsolationRequest: type: object description: Request body for audio isolation (removing background noise) required: - audio properties: audio: type: string format: binary description: The audio file from which vocals/speech will be isolated. file_format: type: string nullable: true enum: - pcm_s16le_16 - other default: other description: | The format of input audio. Options are 'pcm_s16le_16' or 'other'. For pcm_s16le_16, the input audio must be 16-bit PCM at a 16kHz sample rate, single channel (mono). Latency will be lower than with passing an encoded waveform. preview_b64: type: string nullable: true description: Optional preview image base64 for tracking this generation. ElevenLabsCreateVoiceRequest: type: object description: Request body for creating an instant voice clone required: - name - files properties: name: type: string description: The name that identifies this voice. files: type: array items: type: string format: binary description: Audio recordings for voice cloning. remove_background_noise: type: boolean default: false description: If set, removes background noise from voice samples using audio isolation. description: type: string nullable: true description: A description of the voice. labels: type: string nullable: true description: JSON string of labels for the voice (language, accent, gender, age). ElevenLabsSoundGenerationRequest: type: object description: Request body for generating sound effects from text required: - text properties: text: type: string description: The text that will get converted into a sound effect. loop: type: boolean default: false description: | Whether to create a sound effect that loops smoothly. Only available for the 'eleven_text_to_sound_v2' model. duration_seconds: type: number format: double nullable: true description: | The duration of the sound which will be generated in seconds. Must be at least 0.5 and at most 30. If set to null, the optimal duration will be guessed using the prompt. Defaults to null. prompt_influence: type: number format: double description: | A higher prompt influence makes your generation follow the prompt more closely while also making generations less variable. Must be a value between 0 and 1. Defaults to 0.3. model_id: type: string default: eleven_text_to_sound_v2 description: The model ID to use for the sound generation. KlingErrorResponse: type: object properties: code: type: integer description: | - 1000: Authentication failed - 1001: Authorization is empty - 1002: Authorization is invalid - 1003: Authorization is not yet valid - 1004: Authorization has expired - 1100: Account exception - 1101: Account in arrears (postpaid scenario) - 1102: Resource pack depleted or expired (prepaid scenario) - 1103: Unauthorized access to requested resource - 1200: Invalid request parameters - 1201: Invalid parameters - 1202: Invalid request method - 1203: Requested resource does not exist - 1300: Trigger platform strategy - 1301: Trigger content security policy - 1302: API request too frequent - 1303: Concurrency/QPS exceeds limit - 1304: Trigger IP whitelist policy - 5000: Internal server error - 5001: Service temporarily unavailable - 5002: Server internal timeout message: type: string description: Human-readable error message request_id: type: string description: Request ID for tracking and troubleshooting required: - code - message - request_id TripoTask: type: object properties: task_id: type: string type: type: string status: type: string enum: - queued - running - success - failed - cancelled - unknown - banned - expired input: type: object output: type: object properties: model: type: string base_model: type: string pbr_model: type: string rendered_image: type: string riggable: type: boolean topology: type: string enum: - "bip" - "quad" progress: type: integer minimum: 0 maximum: 100 create_time: type: integer required: - task_id - type - status - input - output - progress - create_time TripoSuccessTask: type: object properties: code: type: integer enum: - 0 data: type: object properties: task_id: description: used for getTask type: string required: - task_id required: - code - data TripoBalance: type: object properties: balance: type: number frozen: type: number required: ["balance", "frozen"] TripoErrorResponse: type: object properties: code: type: integer enum: - 1001 - 2000 - 2001 - 2002 - 2003 - 2004 - 2006 - 2007 - 2008 - 2010 message: type: string suggestion: type: string required: - code - message - suggestion TripoResponseSuccessCode: type: integer description: "Standard success code for Tripo API responses. Typically 0 for success." example: 0 TripoTextToModel: type: string description: "The type of the Tripo task, specifically for text-to-model operations." enum: - text_to_model example: text_to_model TripoModelVersion: type: string description: "Version of the Tripo model." enum: - "v2.5-20250123" - "v2.0-20240919" - "v1.4-20240625" example: "v2.5-20250123" TripoModelStyle: type: string description: "Style for the Tripo model generation." enum: - "person:person2cartoon" - "animal:venom" - "object:clay" - "object:steampunk" - "object:christmas" - "object:barbie" - "gold" - "ancient_bronze" example: "object:clay" TripoImageToModel: type: string description: "Task type for Tripo image-to-model generation." enum: - "image_to_model" example: "image_to_model" TripoMultiviewToModel: type: string description: "Task type for Tripo multiview-to-model generation." enum: - "multiview_to_model" example: "multiview_to_model" TripoMultiviewMode: type: string description: "Mode for multiview generation, specifying view orientation." enum: - LEFT - RIGHT example: LEFT TripoTextureQuality: type: string enum: - standard - detailed TripoTextureAlignment: type: string enum: - original_image - geometry TripoOrientation: type: string enum: - align_image - default default: default TripoTypeTextureModel: type: string enum: - texture_model TripoTypeRefineModel: type: string enum: - refine_model TripoTypeAnimatePrerigcheck: type: string enum: - animate_prerigcheck TripoTypeAnimateRig: type: string enum: - animate_rig TripoStandardFormat: type: string enum: - glb - fbx TripoTopology: type: string enum: - "bip" - "quad" TripoSpec: type: string enum: - "mixamo" - "tripo" TripoTypeAnimateRetarget: type: string enum: - animate_retarget TripoAnimation: type: string enum: - preset:idle - preset:walk - preset:climb - preset:jump - preset:run - preset:slash - preset:shoot - preset:hurt - preset:fall - preset:turn TripoTypeStylizeModel: type: string enum: - stylize_model TripoStylizeOptions: type: string enum: - lego - voxel - voronoi - minecraft TripoTypeConvertModel: type: string enum: - convert_model TripoConvertFormat: type: string enum: - GLTF - USDZ - FBX - OBJ - STL - 3MF TripoTextureFormat: type: string enum: - BMP - DPX - HDR - JPEG - OPEN_EXR - PNG - TARGA - TIFF - WEBP TripoGeometryQuality: type: string enum: - standard - detailed LumaAspectRatio: type: string enum: - "1:1" - "16:9" - "9:16" - "4:3" - "3:4" - "21:9" - "9:21" description: The aspect ratio of the generation example: "16:9" default: "16:9" LumaKeyframes: type: object description: The keyframes of the generation properties: frame0: $ref: "#/components/schemas/LumaKeyframe" frame1: $ref: "#/components/schemas/LumaKeyframe" example: frame0: type: image url: "https://example.com/image.jpg" frame1: type: generation id: "123e4567-e89b-12d3-a456-426614174000" LumaVideoModel: type: string enum: - ray-2 - ray-flash-2 - ray-1-6 default: ray-2 example: ray-2 description: The video model used for the generation LumaVideoModelOutputResolution: anyOf: - type: string enum: - 540p - 720p - 1080p - 4k - type: string LumaVideoModelOutputDuration: anyOf: - type: string enum: - 5s - 9s - type: string LumaImageModel: type: string enum: - photon-1 - photon-flash-1 default: photon-1 description: The image model used for the generation LumaImageRef: type: object description: The image reference object properties: url: type: string format: uri description: The URL of the image reference weight: type: number description: The weight of the image reference LumaImageIdentity: type: object description: The image identity object properties: images: type: array items: type: string format: uri description: The URLs of the image identity LumaModifyImageRef: type: object description: The modify image reference object properties: url: type: string format: uri description: The URL of the image reference weight: type: number description: The weight of the modify image reference LumaGenerationReference: type: object description: The generation reference object properties: type: type: string enum: - generation default: generation id: type: string format: uuid description: The ID of the generation required: - type - id example: type: generation id: "123e4567-e89b-12d3-a456-426614174003" LumaImageReference: type: object description: The image object properties: type: type: string enum: - image default: image url: type: string format: uri description: The URL of the image required: - type - url example: type: image url: "https://example.com/image.jpg" LumaKeyframe: oneOf: - $ref: "#/components/schemas/LumaGenerationReference" - $ref: "#/components/schemas/LumaImageReference" discriminator: propertyName: type mapping: generation: "#/components/schemas/LumaGenerationReference" image: "#/components/schemas/LumaImageReference" description: A keyframe can be either a Generation reference, an Image, or a Video LumaGenerationType: type: string enum: - video - image LumaState: type: string description: The state of the generation enum: - queued - dreaming - completed - failed example: completed LumaAssets: type: object description: The assets of the generation properties: video: type: string format: uri description: The URL of the video image: type: string format: uri description: The URL of the image progress_video: type: string format: uri description: The URL of the progress video LumaGenerationRequest: type: object description: The generation request object properties: generation_type: type: string enum: - video default: video prompt: type: string description: The prompt of the generation aspect_ratio: $ref: "#/components/schemas/LumaAspectRatio" loop: type: boolean description: Whether to loop the video keyframes: $ref: "#/components/schemas/LumaKeyframes" callback_url: type: string format: uri description: The callback URL of the generation, a POST request with Generation object will be sent to the callback URL when the generation is dreaming, completed, or failed model: $ref: "#/components/schemas/LumaVideoModel" resolution: $ref: "#/components/schemas/LumaVideoModelOutputResolution" duration: $ref: "#/components/schemas/LumaVideoModelOutputDuration" required: - duration - resolution - prompt - aspect_ratio - model LumaImageGenerationRequest: type: object description: The image generation request object properties: generation_type: type: string enum: - image default: image model: $ref: "#/components/schemas/LumaImageModel" prompt: type: string description: The prompt of the generation aspect_ratio: $ref: "#/components/schemas/LumaAspectRatio" callback_url: type: string format: uri description: The callback URL for the generation image_ref: type: array items: $ref: "#/components/schemas/LumaImageRef" style_ref: type: array items: $ref: "#/components/schemas/LumaImageRef" character_ref: type: object properties: identity0: $ref: "#/components/schemas/LumaImageIdentity" modify_image_ref: $ref: "#/components/schemas/LumaModifyImageRef" LumaUpscaleVideoGenerationRequest: type: object description: The upscale generation request object properties: generation_type: type: string enum: - upscale_video default: upscale_video resolution: $ref: "#/components/schemas/LumaVideoModelOutputResolution" callback_url: type: string format: uri description: The callback URL for the upscale LumaAudioGenerationRequest: type: object description: The audio generation request object properties: generation_type: type: string enum: - add_audio default: add_audio prompt: type: string description: The prompt of the audio negative_prompt: type: string description: The negative prompt of the audio callback_url: type: string format: uri description: The callback URL for the audio LumaError: type: object description: The error object properties: detail: type: string description: The error message example: detail: "Invalid API key is provided" LumaGeneration: type: object description: The generation response object properties: id: type: string format: uuid description: The ID of the generation generation_type: $ref: "#/components/schemas/LumaGenerationType" state: $ref: "#/components/schemas/LumaState" failure_reason: type: string description: The reason for the state of the generation created_at: type: string format: date-time description: The date and time when the generation was created assets: $ref: "#/components/schemas/LumaAssets" model: type: string description: The model used for the generation request: oneOf: - $ref: "#/components/schemas/LumaGenerationRequest" - $ref: "#/components/schemas/LumaImageGenerationRequest" - $ref: "#/components/schemas/LumaUpscaleVideoGenerationRequest" - $ref: "#/components/schemas/LumaAudioGenerationRequest" description: The request of the generation example: id: "123e4567-e89b-12d3-a456-426614174000" state: "completed" failure_reason: null created_at: "2023-06-01T12:00:00Z" assets: video: "https://example.com/video.mp4" model: "ray-2" request: prompt: "A serene lake surrounded by mountains at sunset" aspect_ratio: "16:9" loop: true keyframes: frame0: type: image url: "https://example.com/image.jpg" frame1: type: generation id: "123e4567-e89b-12d3-a456-426614174000" PixverseTextVideoRequest: type: object required: - aspect_ratio - duration - model - prompt - quality properties: aspect_ratio: type: string enum: ["16:9", "4:3", "1:1", "3:4", "9:16"] duration: type: integer enum: [5, 8] model: type: string enum: [v3.5] motion_mode: type: string enum: [normal, fast] negative_prompt: type: string prompt: type: string quality: type: string enum: [360p, 540p, 720p, 1080p] seed: type: integer style: type: string enum: [anime, 3d_animation, clay, comic, cyberpunk] template_id: type: integer water_mark: type: boolean PixverseVideoResponse: type: object properties: ErrCode: type: integer ErrMsg: type: string Resp: type: object properties: video_id: type: integer PixverseImageUploadResponse: type: object properties: ErrCode: type: integer ErrMsg: type: string Resp: type: object properties: img_id: type: integer PixverseImageVideoRequest: type: object required: - img_id - model - duration - quality - prompt properties: img_id: type: integer model: type: string enum: [v3.5] prompt: type: string duration: type: integer enum: [5, 8] quality: type: string enum: [360p, 540p, 720p, 1080p] motion_mode: type: string enum: [normal, fast] seed: type: integer style: type: string enum: [anime, 3d_animation, clay, comic, cyberpunk] template_id: type: integer water_mark: type: boolean PixverseTransitionVideoRequest: type: object required: - first_frame_img - last_frame_img - model - duration - quality - prompt - motion_mode - seed properties: first_frame_img: type: integer last_frame_img: type: integer model: type: string enum: [v3.5] duration: type: integer enum: [5, 8] quality: type: string enum: [360p, 540p, 720p, 1080p] motion_mode: type: string enum: [normal, fast] seed: type: integer prompt: type: string style: type: string enum: [anime, 3d_animation, clay, comic, cyberpunk] template_id: type: integer water_mark: type: boolean PixverseVideoResultResponse: type: object properties: ErrCode: type: integer ErrMsg: type: string Resp: type: object properties: create_time: type: string id: type: integer modify_time: type: string negative_prompt: type: string outputHeight: type: integer outputWidth: type: integer prompt: type: string resolution_ratio: type: integer seed: type: integer size: type: integer status: type: integer enum: [1, 5, 6, 7, 8] description: | Video generation status codes: * 1 - Generation successful * 5 - Generating * 6 - Deleted * 7 - Contents moderation failed * 8 - Generation failed style: type: string url: type: string Veo2GenVidRequest: type: object properties: instances: type: array items: type: object properties: prompt: type: string description: Text description of the video image: type: object description: Optional image to guide video generation properties: bytesBase64Encoded: type: string format: byte gcsUri: type: string mimeType: type: string oneOf: - required: [bytesBase64Encoded] - required: [gcsUri] required: - prompt parameters: type: object properties: aspectRatio: type: string example: "16:9" negativePrompt: type: string personGeneration: type: string enum: ["ALLOW", "BLOCK"] sampleCount: type: integer seed: type: integer format: uint32 storageUri: type: string description: Optional Cloud Storage URI to upload the video durationSeconds: type: integer enhancePrompt: type: boolean Veo2GenVidResponse: type: object properties: name: type: string description: Operation resource name example: projects/PROJECT_ID/locations/us-central1/publishers/google/models/MODEL_ID/operations/a1b07c8e-7b5a-4aba-bb34-3e1ccb8afcc8 required: - name Veo2GenVidPollRequest: type: object properties: operationName: type: string description: Full operation name (from predict response) example: projects/PROJECT_ID/locations/us-central1/publishers/google/models/MODEL_ID/operations/OPERATION_ID required: - operationName Veo2GenVidPollResponse: type: object properties: name: type: string done: type: boolean response: type: object properties: "@type": type: string example: type.googleapis.com/cloud.ai.large_models.vision.GenerateVideoResponse raiMediaFilteredCount: type: integer description: Count of media filtered by responsible AI policies raiMediaFilteredReasons: type: array items: type: string description: Reasons why media was filtered by responsible AI policies videos: type: array items: type: object properties: gcsUri: type: string description: Cloud Storage URI of the video bytesBase64Encoded: type: string description: Base64-encoded video content mimeType: type: string description: Video MIME type description: The actual prediction response if done is true error: type: object description: Error details if operation failed properties: code: type: integer description: Error code message: type: string description: Error message VeoGenVidRequest: type: object properties: instances: type: array items: type: object properties: prompt: type: string description: Text description of the video image: type: object description: Optional image to guide video generation properties: bytesBase64Encoded: type: string format: byte gcsUri: type: string mimeType: type: string oneOf: - required: [bytesBase64Encoded] - required: [gcsUri] lastFrame: type: object description: Optional last frame image to guide video generation properties: bytesBase64Encoded: type: string format: byte gcsUri: type: string mimeType: type: string oneOf: - required: [bytesBase64Encoded] - required: [gcsUri] required: - prompt parameters: type: object properties: aspectRatio: type: string example: "16:9" negativePrompt: type: string personGeneration: type: string enum: ["ALLOW", "BLOCK"] sampleCount: type: integer seed: type: integer format: uint32 storageUri: type: string description: Optional Cloud Storage URI to upload the video durationSeconds: type: integer enhancePrompt: type: boolean generateAudio: type: boolean description: Generate audio for the video. Only supported by veo 3 models. VeoGenVidResponse: type: object properties: name: type: string description: Operation resource name example: projects/PROJECT_ID/locations/us-central1/publishers/google/models/MODEL_ID/operations/a1b07c8e-7b5a-4aba-bb34-3e1ccb8afcc8 required: - name VeoGenVidPollRequest: type: object properties: operationName: type: string description: Full operation name (from predict response) example: projects/PROJECT_ID/locations/us-central1/publishers/google/models/MODEL_ID/operations/OPERATION_ID required: - operationName VeoGenVidPollResponse: type: object properties: name: type: string done: type: boolean response: type: object properties: "@type": type: string example: type.googleapis.com/cloud.ai.large_models.vision.GenerateVideoResponse raiMediaFilteredCount: type: integer description: Count of media filtered by responsible AI policies raiMediaFilteredReasons: type: array items: type: string description: Reasons why media was filtered by responsible AI policies videos: type: array items: type: object properties: gcsUri: type: string description: Cloud Storage URI of the video bytesBase64Encoded: type: string description: Base64-encoded video content mimeType: type: string description: Video MIME type description: The actual prediction response if done is true error: type: object description: Error details if operation failed properties: code: type: integer description: Error code message: type: string description: Error message RunwayImageToVideoRequest: type: object properties: promptImage: $ref: "#/components/schemas/RunwayPromptImageObject" seed: type: integer format: int64 minimum: 0 maximum: 4294967295 description: Random seed for generation model: $ref: "#/components/schemas/RunwayModelEnum" description: Model to use for generation promptText: type: string maxLength: 1000 description: Text prompt for the generation duration: $ref: "#/components/schemas/RunwayDurationEnum" description: The number of seconds of duration for the output video. ratio: $ref: "#/components/schemas/RunwayAspectRatioEnum" description: The resolution (aspect ratio) of the output video. Allowable values depend on the selected model. 1280:768 and 768:1280 are only supported for gen3a_turbo. required: - promptImage - seed - model - duration - ratio RunwayImageToVideoResponse: type: object properties: id: type: string description: Task ID RunwayTextToImageResponse: type: object properties: id: type: string description: Task ID RunwayTaskStatusResponse: type: object properties: id: type: string description: Task ID status: $ref: "#/components/schemas/RunwayTaskStatusEnum" description: Task status createdAt: type: string format: date-time description: Task creation timestamp output: type: array items: type: string description: Array of output video URLs progress: type: number format: float minimum: 0 maximum: 1 description: Float value between 0 and 1 representing the progress of the task. Only available if status is RUNNING. required: - id - status - createdAt RunwayTaskStatusEnum: type: string description: Possible statuses for a Runway task. enum: - SUCCEEDED - RUNNING - FAILED - PENDING - CANCELLED - THROTTLED RunwayModelEnum: type: string description: Available Runway models for generation. enum: - gen4_turbo - gen3a_turbo RunwayPromptImageDetailedObject: type: object description: Represents an image with its position in the video sequence. properties: uri: type: string description: A HTTPS URL or data URI containing an encoded image. position: type: string description: The position of the image in the output video. 'last' is currently supported for gen3a_turbo only. enum: [first, last] required: - uri - position RunwayDurationEnum: type: integer enum: - 5 - 10 RunwayAspectRatioEnum: type: string enum: - "1280:720" - "720:1280" - "1104:832" - "832:1104" - "960:960" - "1584:672" - "1280:768" # gen3a_turbo only - "768:1280" # gen3a_turbo only RunwayTextToImageAspectRatioEnum: type: string enum: - "1920:1080" - "1080:1920" - "1024:1024" - "1360:768" - "1080:1080" - "1168:880" - "1440:1080" - "1080:1440" - "1808:768" - "2112:912" RunwayPromptImageObject: oneOf: - type: string description: A single HTTPS URL or data URI for the first frame image. - type: array description: An array of image objects with positions. No two images can have the same position. items: $ref: "#/components/schemas/RunwayPromptImageDetailedObject" description: Image(s) to use for the video generation. Can be a single URI or an array of image objects with positions. OpenAIImageGenerationResponse: type: object properties: data: type: array items: type: object properties: b64_json: type: string description: Base64 encoded image data url: type: string description: URL of the image revised_prompt: type: string description: Revised prompt usage: type: object properties: input_tokens: type: integer input_tokens_details: type: object properties: text_tokens: type: integer image_tokens: type: integer output_tokens: type: integer output_tokens_details: type: object properties: text_tokens: type: integer image_tokens: type: integer total_tokens: type: integer OpenAIImageGenerationRequest: type: object required: - prompt properties: model: type: string description: The model to use for image generation example: "dall-e-3" prompt: type: string description: A text description of the desired image example: "Draw a rocket in front of a blackhole in deep space" n: type: integer description: The number of images to generate (1-10). Only 1 supported for dall-e-3. example: 1 quality: type: string description: The quality of the generated image enum: [low, medium, high, standard, hd] example: "high" size: type: string description: Size of the image (e.g., 1024x1024, 1536x1024, auto) example: "1024x1536" output_format: type: string description: Format of the output image enum: [png, webp, jpeg] example: "png" output_compression: type: integer description: Compression level for JPEG or WebP (0-100) example: 100 moderation: type: string description: Content moderation setting enum: [low, auto] example: "auto" background: type: string description: Background transparency enum: [transparent, opaque] example: "opaque" response_format: type: string description: Response format of image data enum: [url, b64_json] example: "b64_json" style: type: string description: Style of the image (only for dall-e-3) enum: [vivid, natural] example: "vivid" user: type: string description: A unique identifier for end-user monitoring example: "user-1234" OpenAIImageEditRequest: type: object required: - model - prompt properties: model: type: string description: The model to use for image editing example: "gpt-image-1" prompt: type: string description: A text description of the desired edit example: "Give the rocketship rainbow coloring" n: type: integer description: The number of images to generate example: 1 quality: type: string description: The quality of the edited image example: "low" size: type: string description: Size of the output image example: "1024x1024" output_format: type: string description: Format of the output image enum: [png, webp, jpeg] example: "png" output_compression: type: integer description: Compression level for JPEG or WebP (0-100) example: 100 moderation: type: string description: Content moderation setting enum: [low, auto] example: "auto" background: type: string description: Background transparency example: "opaque" user: type: string description: A unique identifier for end-user monitoring example: "user-1234" OpenAIVideoCreateRequest: type: object required: - prompt properties: prompt: type: string description: Text prompt that describes the video to generate example: "A calico cat playing a piano on stage" input_reference: type: string format: binary description: Optional image or video reference that guides generation model: type: string description: The video generation model to use enum: ["sora-2", "sora-2-pro"] default: "sora-2" seconds: type: string description: Clip duration in seconds enum: ["4", "8", "12"] default: "4" size: type: string description: Output resolution formatted as width x height enum: ["720x1280", "1280x720", "1024x1792", "1792x1024"] default: "720x1280" OpenAIVideoJob: type: object properties: id: type: string description: Unique identifier for the video job example: "video_123" object: type: string description: The object type, which is always video enum: [video] example: "video" model: type: string description: The video generation model that produced the job example: "sora-2" status: type: string description: Current lifecycle status of the video job enum: [queued, in_progress, completed, failed] example: "queued" progress: type: integer description: Approximate completion percentage for the generation task example: 0 created_at: type: integer description: Unix timestamp (seconds) for when the job was created example: 1712697600 completed_at: type: integer description: Unix timestamp (seconds) for when the job completed, if finished example: 1712698600 expires_at: type: integer description: Unix timestamp (seconds) for when the downloadable assets expire, if set example: 1712784000 size: type: string description: The resolution of the generated video example: "1024x1808" seconds: type: string description: Duration of the generated clip in seconds example: "8" quality: type: string description: Quality of the generated video example: "standard" remixed_from_video_id: type: string description: Identifier of the source video if this video is a remix example: "video_456" error: type: object description: Error payload that explains why generation failed, if applicable properties: code: type: string description: Error code message: type: string description: Human-readable error message CustomerStorageResourceResponse: type: object properties: download_url: type: string description: The signed URL to use for downloading the file from the specified path upload_url: type: string description: The signed URL to use for uploading the file to the specified path expires_at: type: string format: date-time description: When the signed URL will expire existing_file: type: boolean description: Whether an existing file with the same hash was found Pikaffect: type: string enum: - Cake-ify - Crumble - Crush - Decapitate - Deflate - Dissolve - Explode - Eye-pop - Inflate - Levitate - Melt - Peel - Poke - Squish - Ta-da - Tear PikaBody_generate_pikaffects_generate_pikaffects_post: properties: image: type: string format: binary title: Image pikaffect: $ref: "#/components/schemas/Pikaffect" title: Pikaffect promptText: anyOf: - type: string title: Prompttext negativePrompt: anyOf: - type: string title: Negativeprompt seed: anyOf: - type: integer title: Seed type: object # required: TODO: this should be required, but need to make optional to pass validation # - image title: Body_generate_pikaffects_generate_pikaffects_post PikaGenerateResponse: properties: video_id: type: string title: Video Id type: object required: - video_id title: GenerateResponse PikaHTTPValidationError: properties: detail: items: $ref: "#/components/schemas/PikaValidationError" type: array title: Detail type: object title: HTTPValidationError PikaBody_generate_pikadditions_generate_pikadditions_post: properties: video: type: string format: binary title: Video image: type: string format: binary title: Image promptText: anyOf: - type: string title: Prompttext negativePrompt: anyOf: - type: string title: Negativeprompt seed: anyOf: - type: integer title: Seed type: object # required: # TODO: this should be required, but need to make optional to pass validation # - video # - image title: Body_generate_pikadditions_generate_pikadditions_post PikaBody_generate_pikaswaps_generate_pikaswaps_post: properties: video: type: string format: binary title: Video image: anyOf: - type: string format: binary title: Image promptText: anyOf: - type: string title: Prompttext modifyRegionMask: anyOf: - type: string format: binary title: Modifyregionmask description: >- A mask image that specifies the region to modify, where the mask is white and the background is black modifyRegionRoi: anyOf: - type: string title: Modifyregionroi description: Plaintext description of the object / region to modify negativePrompt: anyOf: - type: string title: Negativeprompt seed: anyOf: - type: integer title: Seed type: object # required: # TODO: this should be required, but need to make optional to pass validation # - video title: Body_generate_pikaswaps_generate_pikaswaps_post PikaBody_generate_2_2_t2v_generate_2_2_t2v_post: properties: promptText: type: string title: Prompttext negativePrompt: type: string nullable: true title: Negativeprompt seed: type: integer nullable: true title: Seed resolution: $ref: "#/components/schemas/PikaResolutionEnum" title: Resolution duration: $ref: "#/components/schemas/PikaDurationEnum" title: Duration aspectRatio: type: number maximum: 2.5 minimum: 0.4 default: 1.7777777777777777 format: float title: Aspectratio description: Aspect ratio (width / height) type: object required: - promptText title: Body_generate_2_2_t2v_generate_2_2_t2v_post PikaBody_generate_2_2_i2v_generate_2_2_i2v_post: properties: image: type: string format: binary nullable: true # TODO: fix, this is not actually nullable, but needed to pass validation as it is not included in request body title: Image promptText: type: string nullable: true title: Prompttext negativePrompt: type: string nullable: true title: Negativeprompt seed: type: integer nullable: true title: Seed resolution: title: Resolution $ref: "#/components/schemas/PikaResolutionEnum" duration: $ref: "#/components/schemas/PikaDurationEnum" title: Duration type: object # required: TODO: this should be required, but need to make optional to pass validation # - image title: Body_generate_2_2_i2v_generate_2_2_i2v_post PikaBody_generate_2_2_c2v_generate_2_2_pikascenes_post: properties: images: items: type: string format: binary type: array title: Images ingredientsMode: type: string enum: - creative - precise title: Ingredientsmode promptText: anyOf: - type: string title: Prompttext negativePrompt: anyOf: - type: string title: Negativeprompt seed: anyOf: - type: integer title: Seed resolution: type: string title: Resolution default: 1080p duration: type: integer title: Duration default: 5 aspectRatio: anyOf: - type: number maximum: 2.5 minimum: 0.4 title: Aspectratio description: Aspect ratio (width / height) type: object required: # - images # TODO: this should be required, but need to make optional to pass validation - ingredientsMode title: Body_generate_2_2_c2v_generate_2_2_pikascenes_post PikaBody_generate_2_2_keyframe_generate_2_2_pikaframes_post: properties: keyFrames: items: type: string format: binary type: array title: Keyframes description: Array of keyframe images promptText: type: string title: Prompttext negativePrompt: anyOf: - type: string title: Negativeprompt seed: anyOf: - type: integer title: Seed resolution: $ref: "#/components/schemas/PikaResolutionEnum" title: Resolution duration: type: integer minimum: 5 maximum: 10 title: Duration type: object required: # - keyFrames # TODO: this should be required, but need to make optional to pass validation - promptText title: Body_generate_2_2_keyframe_generate_2_2_pikaframes_post PikaVideoResponse: properties: id: type: string title: Id status: title: Status description: The status of the video $ref: "#/components/schemas/PikaStatusEnum" url: type: string nullable: true title: Url default: null progress: type: integer nullable: true title: Progress default: null type: object required: - id - status title: VideoResponse PikaStatusEnum: type: string enum: - queued - started - finished PikaValidationError: properties: loc: items: anyOf: - type: string - type: integer type: array title: Location msg: type: string title: Message type: type: string title: Error Type type: object required: - loc - msg - type title: ValidationError PikaResolutionEnum: type: string enum: - 1080p - 720p default: 1080p PikaDurationEnum: type: integer enum: - 5 - 10 default: 5 RGBColor: type: object description: RGB color values properties: rgb: type: array items: type: integer minimum: 0 maximum: 255 minItems: 3 maxItems: 3 required: - rgb example: rgb: [255, 0, 0] StabilityError: type: object properties: id: type: string minLength: 1 description: > A unique identifier associated with this error. Please include this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new) you file, as it will greatly assist us in diagnosing the root cause of the problem. example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 name: type: string minLength: 1 description: Short-hand name for an error, useful for discriminating between errors with the same status code. example: bad_request errors: type: array items: type: string minItems: 1 description: One or more error messages indicating what went wrong. example: - some-field: is required required: - id - name - errors example: id: 2a1b2d4eafe2bc6ab4cd4d5c6133f513 name: internal_error errors: - An unexpected server error has occurred, please try again later. StabilityStabilityClientID: type: string maxLength: 256 description: >- The name of your application, used to help us communicate app-specific debugging or moderation issues to you. example: my-awesome-app StabilityStabilityClientUserID: type: string maxLength: 256 description: >- A unique identifier for your end user. Used to help us communicate user-specific debugging or moderation issues to you. Feel free to obfuscate this value to protect user privacy. example: "DiscordUser#9999" StabilityStabilityClientVersion: type: string maxLength: 256 description: >- The version of your application, used to help us communicate version-specific debugging or moderation issues to you. example: 1.2.1 StabilityContentModerationResponse: type: object properties: id: type: string minLength: 1 description: >- A unique identifier associated with this error. Please include this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new) you file, as it will greatly assist us in diagnosing the root cause of the problem. example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 name: type: string minLength: 1 description: >- Our content moderation system has flagged some part of your request and subsequently denied it. You were not charged for this request. While this may at times be frustrating, it is necessary to maintain the integrity of our platform and ensure a safe experience for all users. If you would like to provide feedback, please use the [Support Form](https://kb.stability.ai/knowledge-base/kb-tickets/new). enum: - content_moderation errors: type: array items: type: string minItems: 1 description: One or more error messages indicating what went wrong. example: - "some-field: is required" required: - id - name - errors description: Your request was flagged by our content moderation system. example: id: ed14db44362126aab3cbd25cca51ffe3 name: content_moderation errors: - >- Your request was flagged by our content moderation system, as a result your request was denied and you were not charged. ImagenGenerateImageRequest: type: object properties: instances: type: array items: $ref: "#/components/schemas/ImagenImageGenerationInstance" parameters: $ref: "#/components/schemas/ImagenImageGenerationParameters" required: - instances - parameters ImagenGenerateImageResponse: type: object properties: predictions: type: array items: $ref: "#/components/schemas/ImagenImagePrediction" ImagenImageGenerationInstance: type: object properties: prompt: type: string description: Text prompt for image generation required: - prompt ImagenImageGenerationParameters: type: object properties: sampleCount: type: integer minimum: 1 maximum: 4 seed: type: integer format: uint32 addWatermark: type: boolean aspectRatio: type: string enum: ["1:1", "9:16", "16:9", "3:4", "4:3"] enhancePrompt: type: boolean includeRaiReason: type: boolean includeSafetyAttributes: type: boolean outputOptions: $ref: "#/components/schemas/ImagenOutputOptions" personGeneration: type: string enum: ["dont_allow", "allow_adult", "allow_all"] safetySetting: type: string enum: ["block_most", "block_some", "block_few", "block_fewest"] storageUri: type: string format: uri ImagenImagePrediction: type: object properties: mimeType: type: string description: MIME type of the generated image prompt: type: string description: Enhanced or rewritten prompt used to generate this image bytesBase64Encoded: type: string format: byte description: Base64-encoded image content ImagenOutputOptions: type: object properties: mimeType: type: string enum: ["image/png", "image/jpeg"] compressionQuality: type: integer minimum: 0 maximum: 100 RenderingSpeed: type: string description: The rendering speed setting that controls the trade-off between generation speed and quality enum: - DEFAULT - TURBO - QUALITY default: DEFAULT IdeogramStyleType: type: string enum: ["AUTO", "GENERAL", "REALISTIC", "DESIGN", "FICTION"] default: "GENERAL" StabilityCreativity: type: number minimum: 0.2 maximum: 0.5 default: 0.35 description: Controls the likelihood of creating additional details not heavily conditioned by the init image. StabilityGenerationID: type: string minLength: 64 maxLength: 64 description: The `id` of a generation, typically used for async generations, that can be used to check the status of the generation or retrieve the result. example: a6dc6c6e20acda010fe14d71f180658f2896ed9b4ec25aa99a6ff06c796987c4 StabilityImageGenerationSD3_Request: type: object properties: prompt: type: string minLength: 1 maxLength: 10000 description: "What you wish to see in the output image. A strong, descriptive prompt that clearly defines elements, colors, and subjects will lead to better results." mode: type: string enum: - text-to-image - image-to-image default: text-to-image description: "Controls whether this is a text-to-image or image-to-image generation, which affects which parameters are required: - **text-to-image** requires only the `prompt` parameter - **image-to-image** requires the `prompt`, `image`, and `strength` parameters" title: GenerationMode image: type: string description: "The image to use as the starting point for the generation.\n\ \nSupported formats:\n\n\n\n - jpeg\n - png\n - webp\n\nSupported dimensions:\n\ \n\n\n - Every side must be at least 64 pixels\n\n> **Important:** This\ \ parameter is only valid for **image-to-image** requests." format: binary strength: type: number minimum: 0 maximum: 1 description: "Sometimes referred to as _denoising_, this parameter controls how much influence the `image` parameter has on the generated image. A value of 0 would yield an image that is identical to the input. A value of 1 would be as if you passed in no image at all. > **Important:** This parameter is only valid for **image-to-image** requests." aspect_ratio: type: string enum: - "21:9" - "16:9" - "3:2" - "5:4" - "1:1" - "4:5" - "2:3" - "9:16" - "9:21" default: "1:1" description: "Controls the aspect ratio of the generated image. Defaults to 1:1. > **Important:** This parameter is only valid for **text-to-image** requests." model: type: string enum: - sd3.5-large - sd3.5-large-turbo - sd3.5-medium default: sd3.5-large description: "The model to use for generation.\n\n- `sd3.5-large` requires\ \ 6.5 credits per generation\n- `sd3.5-large-turbo` requires 4 credits\ \ per generation\n- `sd3.5-medium` requires 3.5 credits per generation\n\ - As of the April 17, 2025, `sd3-large`, `sd3-large-turbo` and `sd3-medium`\n\ \n\n\n are re-routed to their `sd3.5-[model version]` equivalent, at\ \ the same price." seed: type: number minimum: 0 maximum: 4294967294 default: 0 description: A specific value that is used to guide the 'randomness' of the generation. (Omit this parameter or pass `0` to use a random seed.) output_format: type: string enum: - png - jpeg default: png description: Dictates the `content-type` of the generated image. style_preset: type: string enum: - enhance - anime - photographic - digital-art - comic-book - fantasy-art - line-art - analog-film - neon-punk - isometric - low-poly - origami - modeling-compound - cinematic - 3d-model - pixel-art - tile-texture description: Guides the image model towards a particular style. negative_prompt: type: string maxLength: 10000 description: "Keywords of what you **do not** wish to see in the output image. This is an advanced feature." cfg_scale: type: number minimum: 1 maximum: 10 description: How strictly the diffusion process adheres to the prompt text (higher values keep your image closer to your prompt). The _Large_ and _Medium_ models use a default of `4`. The _Turbo_ model uses a default of `1`. required: - prompt StabilityImageGenrationSD3_Response_200: type: object properties: image: type: string description: The generated image, encoded to base64. example: AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1... seed: type: number minimum: 0 maximum: 4294967294 default: 0 description: The seed used as random noise for this generation. example: 343940597 finish_reason: type: string enum: - SUCCESS - CONTENT_FILTERED description: "The reason the generation finished. - `SUCCESS` = successful generation. - `CONTENT_FILTERED` = successful generation, however the output violated our content moderation policy and has been blurred as a result." example: SUCCESS required: - image - finish_reason StabilityImageGenrationSD3_Response_400: type: object properties: id: type: string minLength: 1 description: "A unique identifier associated with this error. Please include this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new) you file, as it will greatly assist us in diagnosing the root cause of the problem." example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 name: type: string minLength: 1 description: Short-hand name for an error, useful for discriminating between errors with the same status code. example: bad_request errors: type: array items: type: string minItems: 1 description: One or more error messages indicating what went wrong. example: - "some-field: is required" required: - id - name - errors StabilityImageGenrationSD3_Response_413: type: object properties: id: type: string minLength: 1 description: "A unique identifier associated with this error. Please include this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new) you file, as it will greatly assist us in diagnosing the root cause of the problem." example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 name: type: string minLength: 1 description: Short-hand name for an error, useful for discriminating between errors with the same status code. example: bad_request errors: type: array items: type: string minItems: 1 description: One or more error messages indicating what went wrong. example: - "some-field: is required" required: - id - name - errors example: id: 4212a4b66fbe1cedca4bf2133d35dca5 name: payload_too_large errors: - "body: payloads cannot be larger than 10MiB in size" StabilityImageGenrationSD3_Response_422: type: object properties: id: type: string minLength: 1 description: "A unique identifier associated with this error. Please include this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new) you file, as it will greatly assist us in diagnosing the root cause of the problem." example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 name: type: string minLength: 1 description: Short-hand name for an error, useful for discriminating between errors with the same status code. example: bad_request errors: type: array items: type: string minItems: 1 description: One or more error messages indicating what went wrong. example: - "some-field: is required" required: - id - name - errors StabilityImageGenrationSD3_Response_429: type: object properties: id: type: string minLength: 1 description: "A unique identifier associated with this error. Please include this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new) you file, as it will greatly assist us in diagnosing the root cause of the problem." example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 name: type: string minLength: 1 description: Short-hand name for an error, useful for discriminating between errors with the same status code. example: bad_request errors: type: array items: type: string minItems: 1 description: One or more error messages indicating what went wrong. example: - "some-field: is required" required: - id - name - errors example: id: rate_limit_exceeded name: rate_limit_exceeded errors: - You have exceeded the rate limit of 150 requests within a 10 second period, and have been timed out for 60 seconds. StabilityImageGenrationSD3_Response_500: type: object properties: id: type: string minLength: 1 description: "A unique identifier associated with this error. Please include this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new) you file, as it will greatly assist us in diagnosing the root cause of the problem." example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 name: type: string minLength: 1 description: Short-hand name for an error, useful for discriminating between errors with the same status code. example: bad_request errors: type: array items: type: string minItems: 1 description: One or more error messages indicating what went wrong. example: - "some-field: is required" required: - id - name - errors example: id: 2a1b2d4eafe2bc6ab4cd4d5c6133f513 name: internal_error errors: - An unexpected server error has occurred, please try again later. StabilityImageGenrationUpscaleConservative_Request: type: object properties: image: type: string description: "The image you wish to upscale. Supported Formats: - jpeg - png - webp Validation Rules: - Every side must be at least 64 pixels - Total pixel count must be between 4,096 and 9,437,184 pixels - The aspect ratio must be between 1:2.5 and 2.5:1" format: binary example: ./some/image.png prompt: type: string minLength: 1 maxLength: 10000 description: "What you wish to see in the output image. A strong, descriptive prompt that clearly defines elements, colors, and subjects will lead to better results. To control the weight of a given word use the format `(word:weight)`, where `word` is the word you'd like to control the weight of and `weight` is a value between 0 and 1. For example: `The sky was a crisp (blue:0.3) and (green:0.8)` would convey a sky that was blue and green, but more green than blue." negative_prompt: type: string maxLength: 10000 description: "A blurb of text describing what you **do not** wish to see in the output image. This is an advanced feature." seed: type: number minimum: 0 maximum: 4294967294 default: 0 description: A specific value that is used to guide the 'randomness' of the generation. (Omit this parameter or pass `0` to use a random seed.) output_format: type: string enum: - jpeg - png - webp default: png description: Dictates the `content-type` of the generated image. creativity: $ref: "#/components/schemas/StabilityCreativity" required: - image - prompt StabilityImageGenrationUpscaleConservative_Response_200: type: object properties: image: type: string description: The generated image, encoded to base64. example: AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1... seed: type: number minimum: 0 maximum: 4294967294 default: 0 description: The seed used as random noise for this generation. example: 343940597 finish_reason: type: string enum: - SUCCESS - CONTENT_FILTERED description: "The reason the generation finished. - `SUCCESS` = successful generation. - `CONTENT_FILTERED` = successful generation, however the output violated our content moderation policy and has been blurred as a result." example: SUCCESS required: - image - finish_reason StabilityImageGenrationUpscaleConservative_Response_400: type: object properties: id: type: string minLength: 1 description: "A unique identifier associated with this error. Please include this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new) you file, as it will greatly assist us in diagnosing the root cause of the problem." example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 name: type: string minLength: 1 description: Short-hand name for an error, useful for discriminating between errors with the same status code. example: bad_request errors: type: array items: type: string minItems: 1 description: One or more error messages indicating what went wrong. example: - "some-field: is required" required: - id - name - errors StabilityImageGenrationUpscaleConservative_Response_413: type: object properties: id: type: string minLength: 1 description: "A unique identifier associated with this error. Please include this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new) you file, as it will greatly assist us in diagnosing the root cause of the problem." example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 name: type: string minLength: 1 description: Short-hand name for an error, useful for discriminating between errors with the same status code. example: bad_request errors: type: array items: type: string minItems: 1 description: One or more error messages indicating what went wrong. example: - "some-field: is required" required: - id - name - errors example: id: 4212a4b66fbe1cedca4bf2133d35dca5 name: payload_too_large errors: - "body: payloads cannot be larger than 10MiB in size" StabilityImageGenrationUpscaleConservative_Response_422: type: object properties: id: type: string minLength: 1 description: "A unique identifier associated with this error. Please include this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new) you file, as it will greatly assist us in diagnosing the root cause of the problem." example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 name: type: string minLength: 1 description: Short-hand name for an error, useful for discriminating between errors with the same status code. example: bad_request errors: type: array items: type: string minItems: 1 description: One or more error messages indicating what went wrong. example: - "some-field: is required" required: - id - name - errors StabilityImageGenrationUpscaleConservative_Response_429: type: object properties: id: type: string minLength: 1 description: "A unique identifier associated with this error. Please include this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new) you file, as it will greatly assist us in diagnosing the root cause of the problem." example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 name: type: string minLength: 1 description: Short-hand name for an error, useful for discriminating between errors with the same status code. example: bad_request errors: type: array items: type: string minItems: 1 description: One or more error messages indicating what went wrong. example: - "some-field: is required" required: - id - name - errors example: id: rate_limit_exceeded name: rate_limit_exceeded errors: - You have exceeded the rate limit of 150 requests within a 10 second period, and have been timed out for 60 seconds. StabilityImageGenrationUpscaleConservative_Response_500: type: object properties: id: type: string minLength: 1 description: "A unique identifier associated with this error. Please include this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new) you file, as it will greatly assist us in diagnosing the root cause of the problem." example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 name: type: string minLength: 1 description: Short-hand name for an error, useful for discriminating between errors with the same status code. example: bad_request errors: type: array items: type: string minItems: 1 description: One or more error messages indicating what went wrong. example: - "some-field: is required" required: - id - name - errors example: id: 2a1b2d4eafe2bc6ab4cd4d5c6133f513 name: internal_error errors: - An unexpected server error has occurred, please try again later. StabilityImageGenrationUpscaleCreative_Request: type: object properties: image: type: string description: "The image you wish to upscale. Supported Formats: - jpeg - png - webp Validation Rules: - Every side must be at least 64 pixels - Total pixel count must be between 4,096 and 1,048,576 pixels" format: binary example: ./some/image.png prompt: type: string minLength: 1 maxLength: 10000 description: "What you wish to see in the output image. A strong, descriptive prompt that clearly defines elements, colors, and subjects will lead to better results. To control the weight of a given word use the format `(word:weight)`, where `word` is the word you'd like to control the weight of and `weight` is a value between 0 and 1. For example: `The sky was a crisp (blue:0.3) and (green:0.8)` would convey a sky that was blue and green, but more green than blue." negative_prompt: type: string maxLength: 10000 description: "A blurb of text describing what you **do not** wish to see in the output image. This is an advanced feature." output_format: type: string enum: - jpeg - png - webp default: png description: Dictates the `content-type` of the generated image. seed: type: number minimum: 0 maximum: 4294967294 default: 0 description: A specific value that is used to guide the 'randomness' of the generation. (Omit this parameter or pass `0` to use a random seed.) creativity: type: number minimum: 0.1 maximum: 0.5 default: 0.3 description: "Indicates how creative the model should be when upscaling an image. Higher values will result in more details being added to the image during upscaling." style_preset: type: string enum: - enhance - anime - photographic - digital-art - comic-book - fantasy-art - line-art - analog-film - neon-punk - isometric - low-poly - origami - modeling-compound - cinematic - 3d-model - pixel-art - tile-texture description: Guides the image model towards a particular style. required: - image - prompt StabilityImageGenrationUpscaleCreative_Response_200: type: object properties: id: $ref: "#/components/schemas/StabilityGenerationID" required: - id StabilityImageGenrationUpscaleCreative_Response_400: type: object properties: id: type: string minLength: 1 description: "A unique identifier associated with this error. Please include this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new) you file, as it will greatly assist us in diagnosing the root cause of the problem." example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 name: type: string minLength: 1 description: Short-hand name for an error, useful for discriminating between errors with the same status code. example: bad_request errors: type: array items: type: string minItems: 1 description: One or more error messages indicating what went wrong. example: - "some-field: is required" required: - id - name - errors StabilityImageGenrationUpscaleCreative_Response_413: type: object properties: id: type: string minLength: 1 description: "A unique identifier associated with this error. Please include this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new) you file, as it will greatly assist us in diagnosing the root cause of the problem." example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 name: type: string minLength: 1 description: Short-hand name for an error, useful for discriminating between errors with the same status code. example: bad_request errors: type: array items: type: string minItems: 1 description: One or more error messages indicating what went wrong. example: - "some-field: is required" required: - id - name - errors example: id: 4212a4b66fbe1cedca4bf2133d35dca5 name: payload_too_large errors: - "body: payloads cannot be larger than 10MiB in size" StabilityImageGenrationUpscaleCreative_Response_422: type: object properties: id: type: string minLength: 1 description: "A unique identifier associated with this error. Please include this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new) you file, as it will greatly assist us in diagnosing the root cause of the problem." example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 name: type: string minLength: 1 description: Short-hand name for an error, useful for discriminating between errors with the same status code. example: bad_request errors: type: array items: type: string minItems: 1 description: One or more error messages indicating what went wrong. example: - "some-field: is required" required: - id - name - errors StabilityImageGenrationUpscaleCreative_Response_429: type: object properties: id: type: string minLength: 1 description: "A unique identifier associated with this error. Please include this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new) you file, as it will greatly assist us in diagnosing the root cause of the problem." example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 name: type: string minLength: 1 description: Short-hand name for an error, useful for discriminating between errors with the same status code. example: bad_request errors: type: array items: type: string minItems: 1 description: One or more error messages indicating what went wrong. example: - "some-field: is required" required: - id - name - errors example: id: rate_limit_exceeded name: rate_limit_exceeded errors: - You have exceeded the rate limit of 150 requests within a 10 second period, and have been timed out for 60 seconds. StabilityImageGenrationUpscaleCreative_Response_500: type: object properties: id: type: string minLength: 1 description: "A unique identifier associated with this error. Please include this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new) you file, as it will greatly assist us in diagnosing the root cause of the problem." example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 name: type: string minLength: 1 description: Short-hand name for an error, useful for discriminating between errors with the same status code. example: bad_request errors: type: array items: type: string minItems: 1 description: One or more error messages indicating what went wrong. example: - "some-field: is required" required: - id - name - errors example: id: 2a1b2d4eafe2bc6ab4cd4d5c6133f513 name: internal_error errors: - An unexpected server error has occurred, please try again later. StabilityImageGenrationUpscaleFast_Request: type: object properties: image: type: string description: "The image you wish to upscale. Supported Formats: - jpeg - png - webp Validation Rules: - Width must be between 32 and 1,536 pixels - Height must be between 32 and 1,536 pixels - Total pixel count must be between 1,024 and 1,048,576 pixels" format: binary example: ./some/image.png output_format: type: string enum: - jpeg - png - webp default: png description: Dictates the `content-type` of the generated image. required: - image StabilityImageGenrationUpscaleFast_Response_200: type: object properties: image: type: string description: The generated image, encoded to base64. example: AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1... seed: type: number minimum: 0 maximum: 4294967294 default: 0 description: The seed used as random noise for this generation. example: 343940597 finish_reason: type: string enum: - SUCCESS - CONTENT_FILTERED description: "The reason the generation finished. - `SUCCESS` = successful generation. - `CONTENT_FILTERED` = successful generation, however the output violated our content moderation policy and has been blurred as a result." example: SUCCESS required: - image - finish_reason StabilityImageGenrationUpscaleFast_Response_400: type: object properties: id: type: string minLength: 1 description: "A unique identifier associated with this error. Please include this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new) you file, as it will greatly assist us in diagnosing the root cause of the problem." example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 name: type: string minLength: 1 description: Short-hand name for an error, useful for discriminating between errors with the same status code. example: bad_request errors: type: array items: type: string minItems: 1 description: One or more error messages indicating what went wrong. example: - "some-field: is required" required: - id - name - errors StabilityImageGenrationUpscaleFast_Response_413: type: object properties: id: type: string minLength: 1 description: "A unique identifier associated with this error. Please include this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new) you file, as it will greatly assist us in diagnosing the root cause of the problem." example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 name: type: string minLength: 1 description: Short-hand name for an error, useful for discriminating between errors with the same status code. example: bad_request errors: type: array items: type: string minItems: 1 description: One or more error messages indicating what went wrong. example: - "some-field: is required" required: - id - name - errors example: id: 4212a4b66fbe1cedca4bf2133d35dca5 name: payload_too_large errors: - "body: payloads cannot be larger than 10MiB in size" StabilityImageGenrationUpscaleFast_Response_422: type: object properties: id: type: string minLength: 1 description: "A unique identifier associated with this error. Please include this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new) you file, as it will greatly assist us in diagnosing the root cause of the problem." example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 name: type: string minLength: 1 description: Short-hand name for an error, useful for discriminating between errors with the same status code. example: bad_request errors: type: array items: type: string minItems: 1 description: One or more error messages indicating what went wrong. example: - "some-field: is required" required: - id - name - errors StabilityImageGenrationUpscaleFast_Response_429: type: object properties: id: type: string minLength: 1 description: "A unique identifier associated with this error. Please include this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new) you file, as it will greatly assist us in diagnosing the root cause of the problem." example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 name: type: string minLength: 1 description: Short-hand name for an error, useful for discriminating between errors with the same status code. example: bad_request errors: type: array items: type: string minItems: 1 description: One or more error messages indicating what went wrong. example: - "some-field: is required" required: - id - name - errors example: id: rate_limit_exceeded name: rate_limit_exceeded errors: - You have exceeded the rate limit of 150 requests within a 10 second period, and have been timed out for 60 seconds. StabilityGetResultResponse_202: type: object properties: status: type: string enum: - in-progress id: type: string description: The ID of the generation result. example: 1234567890 APIKey: type: object properties: id: type: string name: type: string description: type: string key_prefix: type: string created_at: type: string format: date-time APIKeyWithPlaintext: allOf: - $ref: "#/components/schemas/APIKey" - type: object properties: plaintext_key: type: string description: The full API key (only returned at creation) GeminiGenerateContentRequest: type: object required: [contents] properties: contents: type: array items: $ref: "#/components/schemas/GeminiContent" tools: type: array items: $ref: "#/components/schemas/GeminiTool" safetySettings: type: array items: $ref: "#/components/schemas/GeminiSafetySetting" generationConfig: $ref: "#/components/schemas/GeminiGenerationConfig" systemInstruction: $ref: "#/components/schemas/GeminiSystemInstructionContent" videoMetadata: $ref: "#/components/schemas/GeminiVideoMetadata" uploadImagesToStorage: type: boolean description: If true, generated images will be uploaded to cloud storage and returned as signed URLs instead of inline base64 data. The URLs expire after 24 hours. GeminiGenerateContentResponse: type: object properties: candidates: type: array items: $ref: "#/components/schemas/GeminiCandidate" promptFeedback: $ref: "#/components/schemas/GeminiPromptFeedback" usageMetadata: $ref: "#/components/schemas/GeminiUsageMetadata" modelVersion: type: string description: The model version used to generate the response. createTime: type: string description: Timestamp when the response was created. responseId: type: string description: Unique identifier for the response. GeminiUsageMetadata: type: object properties: promptTokenCount: type: integer description: Number of tokens in the request. When cachedContent is set, this is still the total effective prompt size meaning this includes the number of tokens in the cached content. candidatesTokenCount: type: integer description: Number of tokens in the response(s). toolUsePromptTokenCount: type: integer description: Number of tokens present in tool-use prompt(s). thoughtsTokenCount: type: integer description: Number of tokens present in thoughts output. cachedContentTokenCount: type: integer description: Output only. Number of tokens in the cached part in the input (the cached content). promptTokensDetails: type: array items: $ref: "#/components/schemas/ModalityTokenCount" description: Breakdown of prompt tokens by modality. candidatesTokensDetails: type: array items: $ref: "#/components/schemas/ModalityTokenCount" description: Breakdown of candidate tokens by modality. totalTokenCount: type: integer description: Total number of tokens (prompt + candidates). trafficType: type: string description: Traffic type used for the request (e.g., PROVISIONED_THROUGHPUT). ModalityTokenCount: type: object properties: modality: $ref: "#/components/schemas/Modality" tokenCount: type: integer description: Number of tokens for the given modality. Modality: type: string enum: - MODALITY_UNSPECIFIED - TEXT - IMAGE - VIDEO - AUDIO - DOCUMENT description: Type of input or output content modality. GeminiSystemInstructionContent: type: object required: [role, parts] description: | Available for gemini-2.0-flash and gemini-2.0-flash-lite. Instructions for the model to steer it toward better performance. For example, "Answer as concisely as possible" or "Don't use technical terms in your response". The text strings count toward the token limit. The role field of systemInstruction is ignored and doesn't affect the performance of the model. Note: Only text should be used in parts and content in each part should be in a separate paragraph. properties: role: type: string description: | The identity of the entity that creates the message. The following values are supported: user: This indicates that the message is sent by a real person, typically a user-generated message. model: This indicates that the message is generated by the model. The model value is used to insert messages from the model into the conversation during multi-turn conversations. For non-multi-turn conversations, this field can be left blank or unset. enum: - user - model example: "user" parts: type: array description: | A list of ordered parts that make up a single message. Different parts may have different IANA MIME types. For limits on the inputs, such as the maximum number of tokens or the number of images, see the model specifications on the Google models page. items: $ref: "#/components/schemas/GeminiTextPart" GeminiContent: type: object required: [role, parts] description: | The content of the current conversation with the model. For single-turn queries, this is a single instance. For multi-turn queries, this is a repeated field that contains conversation history and the latest request. properties: role: type: string enum: - user - model example: "user" parts: type: array items: $ref: "#/components/schemas/GeminiPart" GeminiTool: type: object description: | A piece of code that enables the system to interact with external systems to perform an action, or set of actions, outside of knowledge and scope of the model. See Function calling. properties: functionDeclarations: type: array items: $ref: "#/components/schemas/GeminiFunctionDeclaration" GeminiSafetySetting: type: object description: | Per request settings for blocking unsafe content. Enforced on GenerateContentResponse.candidates. required: [category, threshold] properties: category: $ref: "#/components/schemas/GeminiSafetyCategory" threshold: $ref: "#/components/schemas/GeminiSafetyThreshold" GeminiSafetyCategory: type: string enum: - HARM_CATEGORY_SEXUALLY_EXPLICIT - HARM_CATEGORY_HATE_SPEECH - HARM_CATEGORY_HARASSMENT - HARM_CATEGORY_DANGEROUS_CONTENT GeminiSafetyThreshold: type: string enum: - OFF - BLOCK_NONE - BLOCK_LOW_AND_ABOVE - BLOCK_MEDIUM_AND_ABOVE - BLOCK_ONLY_HIGH GeminiGenerationConfig: type: object properties: temperature: type: number format: float description: | The temperature is used for sampling during response generation, which occurs when topP and topK are applied. Temperature controls the degree of randomness in token selection. Lower temperatures are good for prompts that require a less open-ended or creative response, while higher temperatures can lead to more diverse or creative results. A temperature of 0 means that the highest probability tokens are always selected. In this case, responses for a given prompt are mostly deterministic, but a small amount of variation is still possible. If the model returns a response that's too generic, too short, or the model gives a fallback response, try increasing the temperature default: 1 minimum: 0 maximum: 2 topP: type: number format: float description: | If specified, nucleus sampling is used. Top-P changes how the model selects tokens for output. Tokens are selected from the most (see top-K) to least probable until the sum of their probabilities equals the top-P value. For example, if tokens A, B, and C have a probability of 0.3, 0.2, and 0.1 and the top-P value is 0.5, then the model will select either A or B as the next token by using temperature and excludes C as a candidate. Specify a lower value for less random responses and a higher value for more random responses. default: 0.95 minimum: 0 maximum: 1 topK: type: integer description: | Top-K changes how the model selects tokens for output. A top-K of 1 means the next selected token is the most probable among all tokens in the model's vocabulary. A top-K of 3 means that the next token is selected from among the 3 most probable tokens by using temperature. default: 40 minimum: 1 example: 40 maxOutputTokens: type: integer description: | Maximum number of tokens that can be generated in the response. A token is approximately 4 characters. 100 tokens correspond to roughly 60-80 words. minimum: 16 maximum: 8192 example: 2048 seed: type: integer description: | When seed is fixed to a specific value, the model makes a best effort to provide the same response for repeated requests. Deterministic output isn't guaranteed. Also, changing the model or parameter settings, such as the temperature, can cause variations in the response even when you use the same seed value. By default, a random seed value is used. Available for the following models:, gemini-2.5-flash, gemini-2.5-pro, gemini-2.5-flash-preview-04-1, gemini-2.5-pro-preview-05-0, gemini-2.0-flash-lite-00, gemini-2.0-flash-001 example: 343940597 stopSequences: type: array items: type: string responseModalities: type: array items: type: string enum: - TEXT - IMAGE imageConfig: type: object description: Configuration for image generation properties: imageOutputOptions: type: object description: Optional. The image output format for generated images. properties: mimeType: type: string description: Optional. The image format that the output should be saved as. compressionQuality: type: integer description: Optional. The compression quality of the output image. aspectRatio: type: string description: Aspect ratio for generated images imageSize: type: string description: Optional. Specifies the size of generated images. Supported values are 1K, 2K, 4K. If not specified, the model will use default value 1K. thinkingConfig: type: object description: Optional. Configuration for thinking features. Thinking is a process where the model breaks down a complex task into smaller steps to generate a higher-quality response. properties: includeThoughts: type: boolean description: Optional. If true, the model will include its thoughts in the response. thinkingBudget: type: integer description: Optional. The token budget for the model's thinking process. The model will make a best effort to stay within this budget. thinkingLevel: type: string description: Optional. The thinking level for the model. enum: - THINKING_LEVEL_UNSPECIFIED - LOW - MEDIUM - HIGH - MINIMAL GeminiVideoMetadata: type: object description: | For video input, the start and end offset of the video in Duration format. For example, to specify a 10 second clip starting at 1:00, set "startOffset": { "seconds": 60 } and "endOffset": { "seconds": 70 }. The metadata should only be specified while the video data is presented in inlineData or fileData. properties: startOffset: $ref: "#/components/schemas/GeminiOffset" endOffset: $ref: "#/components/schemas/GeminiOffset" GeminiOffset: type: object description: | Represents a duration offset for video timeline positions. properties: seconds: type: integer description: | Signed seconds of the span of time. Must be from -315,576,000,000 to +315,576,000,000 inclusive. minimum: -315576000000 maximum: 315576000000 example: 60 nanos: type: integer description: | Signed fractions of a second at nanosecond resolution. Negative second values with fractions must still have non-negative nanos values. minimum: 0 maximum: 999999999 example: 0 GeminiCandidate: type: object properties: content: $ref: "#/components/schemas/GeminiContent" finishReason: type: string safetyRatings: type: array items: $ref: "#/components/schemas/GeminiSafetyRating" citationMetadata: $ref: "#/components/schemas/GeminiCitationMetadata" GeminiMimeType: type: string description: The media type of the file specified in the data or fileUri fields. Acceptable values include the following. For gemini-2.0-flash-lite and gemini-2.0-flash, the maximum length of an audio file is 8.4 hours and the maximum length of a video file (without audio) is one hour. For more information, see Gemini audio and video requirements. Text files must be UTF-8 encoded. The contents of the text file count toward the token limit. There is no limit on image resolution. enum: - application/pdf - audio/mpeg - audio/mp3 - audio/wav - image/png - image/jpeg - image/webp - text/plain - video/mov - video/mpeg - video/mp4 - video/mpg - video/avi - video/wmv - video/mpegps - video/flv GeminiPromptFeedback: type: object properties: safetyRatings: type: array items: $ref: "#/components/schemas/GeminiSafetyRating" blockReason: type: string blockReasonMessage: type: string GeminiTextPart: type: object properties: text: type: string description: A text prompt or code snippet. example: "Answer as concisely as possible" GeminiPart: type: object properties: text: type: string description: A text prompt or code snippet. example: "Write a story about a robot learning to paint" inlineData: $ref: "#/components/schemas/GeminiInlineData" fileData: $ref: "#/components/schemas/GeminiFileData" GeminiFunctionDeclaration: type: object required: [name, parameters] properties: name: type: string description: type: string parameters: type: object description: JSON schema for the function parameters GeminiSafetyRating: type: object properties: category: $ref: "#/components/schemas/GeminiSafetyCategory" probability: type: string enum: - NEGLIGIBLE - LOW - MEDIUM - HIGH - UNKNOWN description: The probability that the content violates the specified safety category GeminiCitationMetadata: type: object properties: citations: type: array items: $ref: "#/components/schemas/GeminiCitation" GeminiInlineData: type: object description: | Inline data in raw bytes. For gemini-2.0-flash-lite and gemini-2.0-flash, you can specify up to 3000 images by using inlineData. properties: mimeType: $ref: "#/components/schemas/GeminiMimeType" data: type: string description: | The base64 encoding of the image, PDF, or video to include inline in the prompt. When including media inline, you must also specify the media type (mimeType) of the data. Size limit: 20MB format: byte GeminiFileData: type: object description: URI based data. properties: mimeType: $ref: "#/components/schemas/GeminiMimeType" fileUri: type: string description: URI GeminiCitation: type: object properties: startIndex: type: integer endIndex: type: integer uri: type: string title: type: string license: type: string publicationDate: type: string format: date authors: type: array items: type: string Rodin3DGenerateRequest: type: object required: - images properties: images: type: string description: The reference images to generate 3D Assets. seed: type: integer description: Seed. tier: $ref: "#/components/schemas/RodinTierType" material: $ref: "#/components/schemas/RodinMaterialType" quality: $ref: "#/components/schemas/RodinQualityType" mesh_mode: $ref: "#/components/schemas/RodinMeshModeType" RodinTierType: type: string description: Rodin Tier para options enum: [Regular, Sketch, Detail, Smooth] RodinMaterialType: type: string description: Rodin Material para options enum: [PBR, Shaded] RodinQualityType: type: string description: Rodin Quality para options enum: [extra-low, low, medium, high] RodinMeshModeType: type: string description: Rodin Mesh_Mode para options enum: [Quad, Raw] Rodin3DCheckStatusRequest: type: object required: - subscription_key properties: subscription_key: type: string description: subscription from generate endpoint Rodin3DDownloadRequest: type: object required: - task_uuid properties: task_uuid: type: string description: Task UUID Rodin3DGenerateResponse: type: object properties: message: type: string description: message prompt: type: string description: prompt submit_time: type: string description: Time uuid: type: string description: Task UUID jobs: $ref: "#/components/schemas/RodinGenerateJobsData" RodinGenerateJobsData: type: object properties: uuids: type: array description: subjobs uuid. items: type: string subscription_key: type: string description: Subscription Key. Rodin3DCheckStatusResponse: type: object properties: jobs: type: array description: Details for the generation status. items: $ref: "#/components/schemas/RodinCheckStatusJobItem" RodinCheckStatusJobItem: type: object properties: uuid: type: string description: sub uuid status: $ref: "#/components/schemas/RodinStatusOptions" RodinStatusOptions: type: string enum: [Done, Failed, Generating, Waiting] Rodin3DDownloadResponse: type: object properties: list: type: array items: $ref: "#/components/schemas/RodinResourceItem" RodinResourceItem: type: object properties: url: type: string description: Download url name: type: string description: File name CreateAPIKeyRequest: type: object required: - name properties: name: type: string description: type: string StabilityImageGenrationUpscaleFast_Response_500: type: object properties: id: type: string minLength: 1 description: "A unique identifier associated with this error. Please include this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new) you file, as it will greatly assist us in diagnosing the root cause of the problem." example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 name: type: string minLength: 1 description: Short-hand name for an error, useful for discriminating between errors with the same status code. example: bad_request errors: type: array items: type: string minItems: 1 description: One or more error messages indicating what went wrong. example: - "some-field: is required" required: - id - name - errors example: id: 2a1b2d4eafe2bc6ab4cd4d5c6133f513 name: internal_error errors: - An unexpected server error has occurred, please try again later. StableAudio25TextToAudioRequest: type: object description: Request parameters for Stable Audio 2.5 text-to-audio generation properties: prompt: type: string description: What you wish the output audio to be. A strong, descriptive prompt that clearly defines instruments, moods, styles, and genre will lead to better results. maxLength: 10000 duration: type: number description: Controls the duration in seconds of the generated audio. minimum: 1 maximum: 190 default: 190 seed: type: number description: A specific value that is used to guide the 'randomness' of the generation. (Omit this parameter or pass 0 to use a random seed.) minimum: 0 maximum: 4294967294 default: 0 steps: type: integer description: Controls the number of sampling steps. For stable-audio-2.5 accepts steps between 4 and 8 (defaults to 8). minimum: 4 maximum: 8 default: 8 cfg_scale: type: number description: How strictly the diffusion process adheres to the prompt text (higher values make your audio closer to your prompt). Defaults to 1 for stable-audio-2.5. minimum: 1 maximum: 25 default: 1 model: $ref: "#/components/schemas/StableAudio25Model" output_format: $ref: "#/components/schemas/StableAudio25OutputFormat" required: - prompt - model StableAudio25AudioToAudioRequest: type: object description: Request parameters for Stable Audio audio-to-audio transformation properties: prompt: type: string description: What you wish the output audio to be. A strong, descriptive prompt that clearly defines instruments, moods, styles, and genre will lead to better results. maxLength: 10000 audio: type: string format: binary description: The audio to be used as the starting point for the generation. Supported formats - mp3, wav. Audio must be between 6 and 190 seconds long. duration: type: number description: Controls the duration in seconds of the generated audio. minimum: 1 maximum: 190 default: 190 seed: type: number description: A specific value that is used to guide the 'randomness' of the generation. (Omit this parameter or pass 0 to use a random seed.) minimum: 0 maximum: 4294967294 default: 0 steps: type: integer description: Controls the number of sampling steps. For stable-audio-2.5 accepts steps between 4 and 8 (defaults to 8). minimum: 4 maximum: 8 default: 8 cfg_scale: type: number description: How strictly the diffusion process adheres to the prompt text (higher values make your audio closer to your prompt). Defaults to 7 for stable-audio-2 and 1 for stable-audio-2.5. minimum: 1 maximum: 25 model: $ref: "#/components/schemas/StableAudio25Model" output_format: $ref: "#/components/schemas/StableAudio25OutputFormat" strength: type: number description: Controls how much influence the audio parameter has on the generated audio. A value of 0 would yield audio that is identical to the input. A value of 1 would be as if you passed in no audio at all. Minimum value for stable-audio-2.5 is 0.01. minimum: 0 maximum: 1 default: 1 required: - prompt - audio - model StableAudio25InpaintRequest: type: object description: Request parameters for Stable Audio 2.5 audio inpainting properties: prompt: type: string description: What you wish the output audio to be. A strong, descriptive prompt that clearly defines instruments, moods, styles, and genre will lead to better results. maxLength: 10000 audio: type: string format: binary description: The audio to be used as the starting point for the generation. Supported formats - mp3, wav. Audio must be between 6 and 190 seconds long. duration: type: number description: Controls the duration in seconds of the generated audio. minimum: 1 maximum: 190 default: 190 seed: type: number description: A specific value that is used to guide the 'randomness' of the generation. (Omit this parameter or pass 0 to use a random seed.) minimum: 0 maximum: 4294967294 default: 0 steps: type: integer description: Controls the number of sampling steps. minimum: 4 maximum: 8 default: 8 output_format: $ref: "#/components/schemas/StableAudio25OutputFormat" mask_start: type: number description: Start time in seconds for the audio segment to be inpainted. minimum: 0 maximum: 190 default: 30 mask_end: type: number description: End time in seconds for the audio segment to be inpainted. minimum: 0 maximum: 190 default: 190 required: - prompt - audio StableAudio25AudioResponse: type: object description: Response from Stable Audio 2.5 audio generation properties: id: type: string description: Unique identifier for the generation request audio: type: string format: byte description: Base64-encoded audio data finish_reason: type: string description: Reason for completion enum: ["SUCCESS", "ERROR", "CONTENT_FILTERED"] StableAudio25Model: type: string description: The model to use for generation enum: - stable-audio-2.5 StableAudio25OutputFormat: type: string description: Dictates the content-type of the generated audio enum: - mp3 - wav ModelResponseProperties: type: object description: Common properties for model responses properties: model: type: string description: The model used to generate the response instructions: type: string description: Instructions for the model on how to generate the response max_output_tokens: type: integer description: Maximum number of tokens to generate temperature: type: number minimum: 0 maximum: 2 default: 1 description: Controls randomness in the response top_p: type: number minimum: 0 maximum: 1 default: 1 description: Controls diversity of the response via nucleus sampling truncation: type: string enum: [disabled, auto] default: disabled description: How to handle truncation of the response InputFileContent: properties: type: type: string enum: - input_file description: The type of the input item. Always `input_file`. default: input_file x-stainless-const: true file_id: type: string description: The ID of the file to be sent to the model. filename: type: string description: The name of the file to be sent to the model. file_data: type: string description: | The content of the file to be sent to the model. type: object required: &a1 - type title: Input file description: A file input to the model. ResponseProperties: type: object properties: previous_response_id: type: string description: | The unique ID of the previous response to the model. Use this to create multi-turn conversations. Learn more about [conversation state](/docs/guides/conversation-state). model: description: > Model ID used to generate the response, like `gpt-4o` or `o3`. OpenAI offers a wide range of models with different capabilities, performance characteristics, and price points. Refer to the [model guide](/docs/models) to browse and compare available models. $ref: "#/components/schemas/OpenAIModels" reasoning: $ref: "#/components/schemas/Reasoning" max_output_tokens: description: > An upper bound for the number of tokens that can be generated for a response, including visible output tokens and [reasoning tokens](/docs/guides/reasoning). type: integer instructions: type: string description: > Inserts a system (or developer) message as the first item in the model's context. When using along with `previous_response_id`, the instructions from a previous response will not be carried over to the next response. This makes it simple to swap out system (or developer) messages in new responses. text: type: object properties: format: $ref: "#/components/schemas/TextResponseFormatConfiguration" tools: type: array items: $ref: "#/components/schemas/Tool" tool_choice: description: > How the model should select which tool (or tools) to use when generating a response. See the `tools` parameter to see how to specify which tools the model can call. oneOf: - $ref: "#/components/schemas/ToolChoiceOptions" - $ref: "#/components/schemas/ToolChoiceTypes" - $ref: "#/components/schemas/ToolChoiceFunction" truncation: type: string description: > The truncation strategy to use for the model response. - `auto`: If the context of this response and previous ones exceeds the model's context window size, the model will truncate the response to fit the context window by dropping input items in the middle of the conversation. - `disabled` (default): If a model response will exceed the context window size for a model, the request will fail with a 400 error. enum: - auto - disabled default: disabled TextResponseFormatConfiguration: description: > An object specifying the format that the model must output. Configuring `{ "type": "json_schema" }` enables Structured Outputs, which ensures the model will match your supplied JSON schema. Learn more in the [Structured Outputs guide](/docs/guides/structured-outputs). The default format is `{ "type": "text" }` with no additional options. **Not recommended for gpt-4o and newer models:** Setting to `{ "type": "json_object" }` enables the older JSON mode, which ensures the message the model generates is valid JSON. Using `json_schema` is preferred for models that support it. oneOf: - $ref: "#/components/schemas/ResponseFormatText" - $ref: "#/components/schemas/TextResponseFormatJsonSchema" - $ref: "#/components/schemas/ResponseFormatJsonObject" ResponseFormatJsonObject: type: object title: JSON object description: > JSON object response format. An older method of generating JSON responses. Using `json_schema` is recommended for models that support it. Note that the model will not generate JSON without a system or user message instructing it to do so. properties: type: type: string description: The type of response format being defined. Always `json_object`. enum: - json_object x-stainless-const: true required: - type ResponseFormatJsonSchema: type: object title: JSON schema description: | JSON Schema response format. Used to generate structured JSON responses. Learn more about [Structured Outputs](/docs/guides/structured-outputs). properties: type: type: string default: json_schema x-stainless-const: true json_schema: type: object title: JSON schema description: | Structured Outputs configuration options, including a JSON Schema. properties: description: type: string description: > A description of what the response format is for, used by the model to determine how to respond in the format. name: type: string description: > The name of the response format. Must be a-z, A-Z, 0-9, or contain underscores and dashes, with a maximum length of 64. schema: $ref: "#/components/schemas/ResponseFormatJsonSchemaSchema" strict: type: boolean default: false description: > Whether to enable strict schema adherence when generating the output. If set to true, the model will always follow the exact schema defined in the `schema` field. Only a subset of JSON Schema is supported when `strict` is `true`. To learn more, read the [Structured Outputs guide](/docs/guides/structured-outputs). required: - name required: - type - json_schema ResponseFormatJsonSchemaSchema: type: object title: JSON schema description: | The schema for the response format, described as a JSON Schema object. Learn how to build JSON schemas [here](https://json-schema.org/). additionalProperties: true ResponseFormatText: type: object title: Text description: | Default response format. Used to generate text responses. properties: type: type: string description: The type of response format being defined. Always `text`. enum: - text x-stainless-const: true required: - type TextResponseFormatJsonSchema: type: object title: JSON schema description: | JSON Schema response format. Used to generate structured JSON responses. Learn more about [Structured Outputs](/docs/guides/structured-outputs). properties: type: type: string description: The type of response format being defined. Always `json_schema`. enum: - json_schema x-stainless-const: true description: type: string description: > A description of what the response format is for, used by the model to determine how to respond in the format. name: type: string description: | The name of the response format. Must be a-z, A-Z, 0-9, or contain underscores and dashes, with a maximum length of 64. schema: $ref: "#/components/schemas/ResponseFormatJsonSchemaSchema" strict: type: boolean default: false description: > Whether to enable strict schema adherence when generating the output. If set to true, the model will always follow the exact schema defined in the `schema` field. Only a subset of JSON Schema is supported when `strict` is `true`. To learn more, read the [Structured Outputs guide](/docs/guides/structured-outputs). required: - type - schema - name Reasoning: type: object description: | **o-series models only** Configuration options for [reasoning models](https://platform.openai.com/docs/guides/reasoning). title: Reasoning properties: effort: $ref: "#/components/schemas/ReasoningEffort" summary: type: string description: > A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process. One of `auto`, `concise`, or `detailed`. enum: - auto - concise - detailed generate_summary: type: string deprecated: true description: > **Deprecated:** use `summary` instead. A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process. One of `auto`, `concise`, or `detailed`. enum: - auto - concise - detailed ReasoningEffort: type: string enum: - low - medium - high default: medium description: | **o-series models only** Constrains effort on reasoning for [reasoning models](https://platform.openai.com/docs/guides/reasoning). Currently supported values are `low`, `medium`, and `high`. Reducing reasoning effort can result in faster responses and fewer tokens used on reasoning in a response. WebSearchPreviewTool: properties: type: type: string enum: - web_search_preview - web_search_preview_2025_03_11 description: The type of the web search tool. One of `web_search_preview` or `web_search_preview_2025_03_11`. default: web_search_preview x-stainless-const: true search_context_size: type: string enum: - low - medium - high description: High level guidance for the amount of context window space to use for the search. One of `low`, `medium`, or `high`. `medium` is the default. type: object required: *a1 title: Web search preview description: This tool searches the web for relevant results to use in a response. Learn more about the [web search tool](https://platform.openai.com/docs/guides/tools-web-search). ComputerUsePreviewTool: properties: type: type: string enum: - computer_use_preview description: The type of the computer use tool. Always `computer_use_preview`. default: computer_use_preview x-stainless-const: true environment: type: string enum: - windows - mac - linux - ubuntu - browser description: The type of computer environment to control. display_width: type: integer description: The width of the computer display. display_height: type: integer description: The height of the computer display. type: object required: - type - environment - display_width - display_height title: Computer use preview description: A tool that controls a virtual computer. Learn more about the [computer tool](https://platform.openai.com/docs/guides/tools-computer-use). Tool: oneOf: - $ref: "#/components/schemas/FileSearchTool" - $ref: "#/components/schemas/FunctionTool" - $ref: "#/components/schemas/WebSearchPreviewTool" - $ref: "#/components/schemas/ComputerUsePreviewTool" discriminator: propertyName: type ResponseErrorEvent: type: object description: Emitted when an error occurs. properties: type: type: string description: | The type of the event. Always `error`. enum: - error x-stainless-const: true code: type: string description: | The error code. message: type: string description: | The error message. param: type: string description: | The error parameter. required: - type - code - message - param ResponseOutputItemAddedEvent: type: object description: Emitted when a new output item is added. properties: type: type: string description: | The type of the event. Always `response.output_item.added`. enum: - response.output_item.added x-stainless-const: true output_index: type: integer description: | The index of the output item that was added. item: $ref: "#/components/schemas/OutputItem" description: | The output item that was added. required: - type - output_index - item ResponseOutputItemDoneEvent: type: object description: Emitted when an output item is marked done. properties: type: type: string description: | The type of the event. Always `response.output_item.done`. enum: - response.output_item.done x-stainless-const: true output_index: type: integer description: | The index of the output item that was marked done. item: $ref: "#/components/schemas/OutputItem" description: | The output item that was marked done. required: - type - output_index - item ToolChoiceFunction: type: object title: Function tool description: | Use this option to force the model to call a specific function. properties: type: type: string enum: - function description: For function calling, the type is always `function`. x-stainless-const: true name: type: string description: The name of the function to call. required: - type - name ToolChoiceOptions: type: string title: Tool choice mode description: > Controls which (if any) tool is called by the model. `none` means the model will not call any tool and instead generates a message. `auto` means the model can pick between generating a message or calling one or more tools. `required` means the model must call one or more tools. enum: - none - auto - required ToolChoiceTypes: type: object title: Hosted tool description: > Indicates that the model should use a built-in tool to generate a response. [Learn more about built-in tools](/docs/guides/tools). properties: type: type: string description: | The type of hosted tool the model should to use. Learn more about [built-in tools](/docs/guides/tools). Allowed values are: - `file_search` - `web_search_preview` - `computer_use_preview` enum: - file_search - web_search_preview - computer_use_preview - web_search_preview_2025_03_11 required: - type ResponseFailedEvent: type: object description: | An event that is emitted when a response fails. properties: type: type: string description: | The type of the event. Always `response.failed`. enum: - response.failed x-stainless-const: true response: $ref: "#/components/schemas/OpenAIResponse" description: | The response that failed. required: - type - response ResponseInProgressEvent: type: object description: Emitted when the response is in progress. properties: type: type: string description: | The type of the event. Always `response.in_progress`. enum: - response.in_progress x-stainless-const: true response: $ref: "#/components/schemas/OpenAIResponse" description: | The response that is in progress. required: - type - response ResponseIncompleteEvent: type: object description: | An event that is emitted when a response finishes as incomplete. properties: type: type: string description: | The type of the event. Always `response.incomplete`. enum: - response.incomplete x-stainless-const: true response: $ref: "#/components/schemas/OpenAIResponse" description: | The response that was incomplete. required: - type - response ResponseCreatedEvent: type: object description: An event that is emitted when a response is created. properties: type: type: string description: The type of the event. Always `response.created`. enum: - response.created x-stainless-const: true response: $ref: "#/components/schemas/OpenAIResponse" description: The response that was created. required: - type - response ResponseCompletedEvent: type: object description: Emitted when the model response is complete. properties: type: type: string description: The type of the event. Always `response.completed`. enum: - response.completed x-stainless-const: true response: $ref: "#/components/schemas/OpenAIResponse" description: Properties of the completed response. required: - type - response ResponseContentPartAddedEvent: type: object description: Emitted when a new content part is added. properties: type: type: string description: The type of the event. Always `response.content_part.added`. enum: - response.content_part.added x-stainless-const: true item_id: type: string description: The ID of the output item that the content part was added to. output_index: type: integer description: The index of the output item that the content part was added to. content_index: type: integer description: The index of the content part that was added. part: $ref: "#/components/schemas/OutputContent" description: The content part that was added. required: - type - item_id - output_index - content_index - part ResponseContentPartDoneEvent: type: object description: Emitted when a content part is done. properties: type: type: string description: The type of the event. Always `response.content_part.done`. enum: - response.content_part.done x-stainless-const: true item_id: type: string description: The ID of the output item that the content part was added to. output_index: type: integer description: The index of the output item that the content part was added to. content_index: type: integer description: The index of the content part that is done. part: $ref: "#/components/schemas/OutputContent" description: The content part that is done. required: - type - item_id - output_index - content_index - part ResponseTool: oneOf: - $ref: "#/components/schemas/WebSearchTool" - $ref: "#/components/schemas/FileSearchTool" - $ref: "#/components/schemas/FunctionTool" WebSearchTool: type: object properties: type: type: string enum: [web_search] description: The type of tool domains: type: array items: type: string description: Optional list of domains to restrict search to required: - type FileSearchTool: type: object properties: type: type: string enum: [file_search] description: The type of tool vector_store_ids: type: array items: type: string description: IDs of vector stores to search in required: - type - vector_store_ids FunctionTool: type: object properties: type: type: string enum: [function] description: The type of tool name: type: string description: Name of the function description: type: string description: Description of what the function does parameters: type: object description: JSON Schema object describing the function parameters required: - type - name - parameters OutputItem: oneOf: - $ref: "#/components/schemas/OutputMessage" - $ref: "#/components/schemas/FileSearchToolCall" - $ref: "#/components/schemas/FunctionToolCall" - $ref: "#/components/schemas/WebSearchToolCall" - $ref: "#/components/schemas/ComputerToolCall" - $ref: "#/components/schemas/ReasoningItem" WebSearchToolCall: type: object title: Web search tool call description: | The results of a web search tool call. See the [web search guide](/docs/guides/tools-web-search) for more information. properties: id: type: string description: | The unique ID of the web search tool call. type: type: string enum: - web_search_call description: | The type of the web search tool call. Always `web_search_call`. x-stainless-const: true status: type: string description: | The status of the web search tool call. enum: - in_progress - searching - completed - failed required: - id - type - status FileSearchToolCall: type: object title: File search tool call description: > The results of a file search tool call. See the [file search guide](/docs/guides/tools-file-search) for more information. properties: id: type: string description: | The unique ID of the file search tool call. type: type: string enum: - file_search_call description: | The type of the file search tool call. Always `file_search_call`. x-stainless-const: true status: type: string description: | The status of the file search tool call. One of `in_progress`, `searching`, `incomplete` or `failed`, enum: - in_progress - searching - completed - incomplete - failed queries: type: array items: type: string description: | The queries used to search for files. results: type: array description: | The results of the file search tool call. items: type: object properties: file_id: type: string description: | The unique ID of the file. text: type: string description: | The text that was retrieved from the file. filename: type: string description: | The name of the file. score: type: number format: float description: | The relevance score of the file - a value between 0 and 1. required: - id - type - status - queries FunctionToolCall: type: object title: Function tool call description: > A tool call to run a function. See the [function calling guide](/docs/guides/function-calling) for more information. properties: id: type: string description: | The unique ID of the function tool call. type: type: string enum: - function_call description: | The type of the function tool call. Always `function_call`. x-stainless-const: true call_id: type: string description: | The unique ID of the function tool call generated by the model. name: type: string description: | The name of the function to run. arguments: type: string description: | A JSON string of the arguments to pass to the function. status: type: string description: | The status of the item. One of `in_progress`, `completed`, or `incomplete`. Populated when items are returned via API. enum: - in_progress - completed - incomplete required: - type - call_id - name - arguments OutputMessage: type: object properties: type: type: string enum: [message] description: The type of output item role: type: string enum: [assistant] description: The role of the message content: type: array items: $ref: "#/components/schemas/OutputContent" description: The content of the message required: - type - role - content OutputContent: oneOf: - $ref: "#/components/schemas/OutputTextContent" - $ref: "#/components/schemas/OutputAudioContent" OutputTextContent: type: object properties: type: type: string enum: [output_text] description: The type of output content text: type: string description: The text content required: - type - text OutputAudioContent: type: object properties: type: type: string enum: [output_audio] description: The type of output content data: type: string description: Base64-encoded audio data transcript: type: string description: Transcript of the audio required: - type - data - transcript ResponseUsage: type: object description: | Represents token usage details including input tokens, output tokens, a breakdown of output tokens, and the total tokens used. properties: input_tokens: type: integer description: The number of input tokens. input_tokens_details: type: object description: A detailed breakdown of the input tokens. properties: cached_tokens: type: integer description: | The number of tokens that were retrieved from the cache. [More on prompt caching](/docs/guides/prompt-caching). required: - cached_tokens output_tokens: type: integer description: The number of output tokens. output_tokens_details: type: object description: A detailed breakdown of the output tokens. properties: reasoning_tokens: type: integer description: The number of reasoning tokens. required: - reasoning_tokens total_tokens: type: integer description: The total number of tokens used. required: - input_tokens - input_tokens_details - output_tokens - output_tokens_details - total_tokens OpenAIResponse: type: object description: A response from the model allOf: - $ref: "#/components/schemas/ModelResponseProperties" - $ref: "#/components/schemas/ResponseProperties" - type: object properties: id: type: string description: Unique identifier for this Response. object: type: string description: The object type of this resource - always set to `response`. enum: - response x-stainless-const: true status: type: string description: The status of the response generation. One of `completed`, `failed`, `in_progress`, or `incomplete`. enum: - completed - failed - in_progress - incomplete created_at: type: number description: Unix timestamp (in seconds) of when this Response was created. error: $ref: "#/components/schemas/ResponseError" incomplete_details: type: object nullable: true description: | Details about why the response is incomplete. properties: reason: type: string description: The reason why the response is incomplete. enum: - max_output_tokens - content_filter output: type: array description: > An array of content items generated by the model. - The length and order of items in the `output` array is dependent on the model's response. - Rather than accessing the first item in the `output` array and assuming it's an `assistant` message with the content generated by the model, you might consider using the `output_text` property where supported in SDKs. items: $ref: "#/components/schemas/OutputItem" output_text: type: string nullable: true description: > SDK-only convenience property that contains the aggregated text output from all `output_text` items in the `output` array, if any are present. Supported in the Python and JavaScript SDKs. x-oaiSupportedSDKs: - python - javascript usage: $ref: "#/components/schemas/ResponseUsage" parallel_tool_calls: type: boolean description: | Whether to allow the model to run tool calls in parallel. default: true ResponseError: type: object description: An error object returned when the model fails to generate a Response. properties: code: $ref: "#/components/schemas/ResponseErrorCode" message: type: string description: A human-readable description of the error. required: - code - message ResponseErrorCode: type: string description: The error code for the response. enum: - server_error - rate_limit_exceeded - invalid_prompt - vector_store_timeout - invalid_image - invalid_image_format - invalid_base64_image - invalid_image_url - image_too_large - image_too_small - image_parse_error - image_content_policy_violation - invalid_image_mode - image_file_too_large - unsupported_image_media_type - empty_image_file - failed_to_download_image - image_file_not_found OpenAIResponseStreamEvent: type: object description: Events that can be emitted during response streaming anyOf: - $ref: "#/components/schemas/ResponseCreatedEvent" - $ref: "#/components/schemas/ResponseInProgressEvent" - $ref: "#/components/schemas/ResponseCompletedEvent" - $ref: "#/components/schemas/ResponseFailedEvent" - $ref: "#/components/schemas/ResponseIncompleteEvent" - $ref: "#/components/schemas/ResponseOutputItemAddedEvent" - $ref: "#/components/schemas/ResponseOutputItemDoneEvent" - $ref: "#/components/schemas/ResponseContentPartAddedEvent" - $ref: "#/components/schemas/ResponseContentPartDoneEvent" - $ref: "#/components/schemas/ResponseErrorEvent" InputMessage: type: object properties: type: type: string enum: - message role: type: string enum: - user - system - developer status: type: string enum: - in_progress - completed - incomplete content: $ref: "#/components/schemas/InputMessageContentList" InputMessageContentList: type: array title: Input item content list description: > A list of one or many input items to the model, containing different content types. items: $ref: "#/components/schemas/InputContent" InputContent: oneOf: - $ref: "#/components/schemas/InputTextContent" - $ref: "#/components/schemas/InputImageContent" - $ref: "#/components/schemas/InputFileContent" InputTextContent: properties: type: type: string enum: - input_text description: The type of the input item. Always `input_text`. default: input_text x-stainless-const: true text: type: string description: The text input to the model. type: object required: - type - text title: Input text description: A text input to the model. InputImageContent: properties: type: type: string enum: - input_image description: The type of the input item. Always `input_image`. default: input_image x-stainless-const: true image_url: type: string description: The URL of the image to be sent to the model. A fully qualified URL or base64 encoded image in a data URL. file_id: type: string description: The ID of the file to be sent to the model. detail: type: string enum: - low - high - auto description: The detail level of the image to be sent to the model. One of `high`, `low`, or `auto`. Defaults to `auto`. type: object required: - type - detail title: Input image description: An image input to the model. Learn about [image inputs](/docs/guides/vision). InputMessageResource: allOf: - $ref: "#/components/schemas/InputMessage" - type: object properties: id: type: string description: | The unique ID of the message input. required: - id ItemResource: description: | Content item used to generate a response. oneOf: - $ref: "#/components/schemas/InputMessageResource" - $ref: "#/components/schemas/OutputMessage" - $ref: "#/components/schemas/FileSearchToolCall" - $ref: "#/components/schemas/ComputerToolCall" - $ref: "#/components/schemas/WebSearchToolCall" - $ref: "#/components/schemas/FunctionToolCallResource" discriminator: propertyName: type FunctionToolCallResource: allOf: - $ref: "#/components/schemas/FunctionToolCall" - type: object properties: id: type: string description: | The unique ID of the function tool call. required: - id ComputerToolCall: type: object title: Computer tool call description: > A tool call to a computer use tool. See the [computer use guide](/docs/guides/tools-computer-use) for more information. properties: type: type: string description: The type of the computer call. Always `computer_call`. enum: - computer_call default: computer_call id: type: string description: The unique ID of the computer call. call_id: type: string description: | An identifier used when responding to the tool call with output. action: type: object status: type: string description: | The status of the item. One of `in_progress`, `completed`, or `incomplete`. Populated when items are returned via API. enum: - in_progress - completed - incomplete required: - type - id - action - call_id - pending_safety_checks - status ResponseItemList: type: object description: A list of Response items. properties: object: type: string description: The type of object returned, must be `list`. enum: - list x-stainless-const: true data: type: array description: A list of items used to generate this response. items: $ref: "#/components/schemas/ItemResource" has_more: type: boolean description: Whether there are more items available. first_id: type: string description: The ID of the first item in the list. last_id: type: string description: The ID of the last item in the list. required: - object - data - has_more - first_id - last_id Includable: type: string description: > Specify additional output data to include in the model response. Currently supported values are: - `file_search_call.results`: Include the search results of the file search tool call. - `message.input_image.image_url`: Include image urls from the input message. - `computer_call_output.output.image_url`: Include image urls from the computer call output. enum: - file_search_call.results - message.input_image.image_url - computer_call_output.output.image_url CreateModelResponseProperties: allOf: - $ref: "#/components/schemas/ModelResponseProperties" InputItem: oneOf: - $ref: "#/components/schemas/EasyInputMessage" - $ref: "#/components/schemas/Item" Item: type: object description: | Content item used to generate a response. oneOf: - $ref: "#/components/schemas/InputMessage" - $ref: "#/components/schemas/OutputMessage" - $ref: "#/components/schemas/FileSearchToolCall" - $ref: "#/components/schemas/ComputerToolCall" - $ref: "#/components/schemas/WebSearchToolCall" - $ref: "#/components/schemas/FunctionToolCall" - $ref: "#/components/schemas/ReasoningItem" ReasoningItem: type: object description: > A description of the chain of thought used by a reasoning model while generating a response. title: Reasoning properties: type: type: string description: | The type of the object. Always `reasoning`. enum: - reasoning x-stainless-const: true id: type: string description: | The unique identifier of the reasoning content. summary: type: array description: | Reasoning text contents. items: type: object properties: type: type: string description: | The type of the object. Always `summary_text`. enum: - summary_text x-stainless-const: true text: type: string description: > A short summary of the reasoning used by the model when generating the response. required: - type - text status: type: string description: | The status of the item. One of `in_progress`, `completed`, or `incomplete`. Populated when items are returned via API. enum: - in_progress - completed - incomplete required: - id - summary - type EasyInputMessage: type: object title: Input message description: > A message input to the model with a role indicating instruction following hierarchy. Instructions given with the `developer` or `system` role take precedence over instructions given with the `user` role. Messages with the `assistant` role are presumed to have been generated by the model in previous interactions. properties: role: type: string description: > The role of the message input. One of `user`, `assistant`, `system`, or `developer`. enum: - user - assistant - system - developer content: description: > Text, image, or audio input to the model, used to generate a response. Can also contain previous assistant responses. oneOf: - type: string title: Text input description: | A text input to the model. - $ref: "#/components/schemas/InputMessageContentList" type: type: string description: | The type of the message input. Always `message`. enum: - message x-stainless-const: true required: - role - content OpenAICreateResponse: allOf: - $ref: "#/components/schemas/CreateModelResponseProperties" - $ref: "#/components/schemas/ResponseProperties" - type: object properties: input: description: > Text, image, or file inputs to the model, used to generate a response. Learn more: - [Text inputs and outputs](/docs/guides/text) - [Image inputs](/docs/guides/images) - [File inputs](/docs/guides/pdf-files) - [Conversation state](/docs/guides/conversation-state) - [Function calling](/docs/guides/function-calling) oneOf: - type: string title: Text input description: > A text input to the model, equivalent to a text input with the `user` role. - type: array title: Input item list description: | A list of one or many input items to the model, containing different content types. items: $ref: "#/components/schemas/InputItem" include: type: array description: > Specify additional output data to include in the model response. Currently supported values are: - `file_search_call.results`: Include the search results of the file search tool call. - `message.input_image.image_url`: Include image urls from the input message. - `computer_call_output.output.image_url`: Include image urls from the computer call output. items: $ref: "#/components/schemas/Includable" nullable: true usage: $ref: "#/components/schemas/ResponseUsage" parallel_tool_calls: type: boolean description: | Whether to allow the model to run tool calls in parallel. default: true nullable: true store: type: boolean description: > Whether to store the generated model response for later retrieval via API. default: true nullable: true stream: description: | If set to true, the model response data will be streamed to the client as it is generated using [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Event_stream_format). See the [Streaming section below](/docs/api-reference/responses-streaming) for more information. type: boolean nullable: true default: false required: - model - input OpenAIModels: type: string enum: # Base GPT-4 Models - gpt-4 - gpt-4-0314 - gpt-4-0613 - gpt-4-32k - gpt-4-32k-0314 - gpt-4-32k-0613 - gpt-4-0125-preview - gpt-4-turbo - gpt-4-turbo-2024-04-09 - gpt-4-turbo-preview - gpt-4-1106-preview - gpt-4-vision-preview # GPT-3.5 Models - gpt-3.5-turbo - gpt-3.5-turbo-16k - gpt-3.5-turbo-0301 - gpt-3.5-turbo-0613 - gpt-3.5-turbo-1106 - gpt-3.5-turbo-0125 - gpt-3.5-turbo-16k-0613 # GPT-4.1 Models - gpt-4.1 - gpt-4.1-mini - gpt-4.1-nano - gpt-4.1-2025-04-14 - gpt-4.1-mini-2025-04-14 - gpt-4.1-nano-2025-04-14 # O-Series Models - o1 - o1-mini - o1-preview - o1-pro - o1-2024-12-17 - o1-preview-2024-09-12 - o1-mini-2024-09-12 - o1-pro-2025-03-19 - o3 - o3-mini - o3-2025-04-16 - o3-mini-2025-01-31 - o4-mini - o4-mini-2025-04-16 # GPT-4O Models - gpt-4o - gpt-4o-mini - gpt-4o-2024-11-20 - gpt-4o-2024-08-06 - gpt-4o-2024-05-13 - gpt-4o-mini-2024-07-18 # GPT-4O Special Purpose Models - gpt-4o-audio-preview - gpt-4o-audio-preview-2024-10-01 - gpt-4o-audio-preview-2024-12-17 - gpt-4o-mini-audio-preview - gpt-4o-mini-audio-preview-2024-12-17 - gpt-4o-search-preview - gpt-4o-mini-search-preview - gpt-4o-search-preview-2025-03-11 - gpt-4o-mini-search-preview-2025-03-11 # Computer Use Models - computer-use-preview - computer-use-preview-2025-03-11 # GPT-5 models - gpt-5 - gpt-5-mini - gpt-5-nano # Other - chatgpt-4o-latest MoonvalleyTextToVideoInferenceParams: type: object properties: height: type: integer default: 1080 description: Height of the generated video in pixels width: type: integer default: 1920 description: Width of the generated video in pixels guidance_scale: type: number format: float default: 10 description: Guidance scale for generation control seed: type: integer description: "Random seed for generation (default: random)" default: 9 steps: type: integer default: 80 description: Number of denoising steps use_negative_prompts: type: boolean default: true description: Whether to use negative prompts negative_prompt: type: string description: Negative prompt text MoonvalleyVideoToVideoInferenceParams: type: object properties: guidance_scale: type: number format: float default: 10 description: Guidance scale for generation control seed: type: integer description: "Random seed for generation (default: random)" default: 9 steps: type: integer default: 80 description: Number of denoising steps use_negative_prompts: type: boolean default: true description: Whether to use negative prompts negative_prompt: type: string description: Negative prompt text control_params: type: object properties: motion_intensity: type: integer format: int32 default: 6 description: Intensity of motion control MoonvalleyTextToImageRequest: type: object properties: prompt_text: type: string image_url: type: string inference_params: $ref: "#/components/schemas/MoonvalleyTextToVideoInferenceParams" webhook_url: type: string MoonvalleyTextToVideoRequest: type: object properties: prompt_text: type: string image_url: type: string inference_params: $ref: "#/components/schemas/MoonvalleyTextToVideoInferenceParams" webhook_url: type: string MoonvalleyVideoToVideoRequest: type: object required: [prompt_text, video_url, control_type] properties: prompt_text: type: string description: Describes the video to generate video_url: type: string description: Url to control video control_type: type: string enum: [motion_control, pose_control] description: Supported types for video control image_url: type: string description: Url to control image inference_params: $ref: "#/components/schemas/MoonvalleyVideoToVideoInferenceParams" description: Parameters for video-to-video generation inference webhook_url: type: string description: Optional webhook URL for notifications MoonvalleyPromptResponse: type: object properties: id: type: string status: type: string prompt_text: type: string output_url: type: string inference_params: type: object model_params: type: object meta: type: object frame_conditioning: type: object error: type: object MoonvalleyImageToVideoRequest: allOf: - $ref: "#/components/schemas/MoonvalleyTextToVideoRequest" - type: object properties: keyframes: type: object additionalProperties: type: object properties: image_url: type: string MoonvalleyResizeVideoRequest: allOf: - $ref: "#/components/schemas/MoonvalleyVideoToVideoRequest" - type: object properties: frame_position: type: array items: type: integer minItems: 2 maxItems: 2 frame_resolution: type: array items: type: integer minItems: 2 maxItems: 2 scale: type: array items: type: integer minItems: 2 maxItems: 2 MoonvalleyUploadFileRequest: type: object properties: file: type: string format: binary MoonvalleyUploadFileResponse: type: object properties: access_url: type: string GithubReleaseWebhook: type: object description: GitHub release webhook payload based on official webhook documentation properties: action: type: string enum: [ published, unpublished, created, edited, deleted, prereleased, released, ] description: The action performed on the release release: type: object description: The release object properties: id: type: integer description: The ID of the release node_id: type: string description: The node ID of the release url: type: string description: The API URL of the release html_url: type: string description: The HTML URL of the release assets_url: type: string description: The URL to the release assets upload_url: type: string description: The URL to upload release assets tag_name: type: string description: The tag name of the release target_commitish: type: string description: The branch or commit the release was created from name: type: string nullable: true description: The name of the release body: type: string nullable: true description: The release notes/body draft: type: boolean description: Whether the release is a draft prerelease: type: boolean description: Whether the release is a prerelease created_at: type: string format: date-time description: When the release was created published_at: type: string format: date-time nullable: true description: When the release was published author: $ref: "#/components/schemas/GithubUser" tarball_url: type: string description: URL to the tarball zipball_url: type: string description: URL to the zipball assets: type: array items: $ref: "#/components/schemas/GithubReleaseAsset" description: Array of release assets required: - id - node_id - url - html_url - tag_name - target_commitish - draft - prerelease - created_at - author - tarball_url - zipball_url - assets repository: $ref: "#/components/schemas/GithubRepository" sender: $ref: "#/components/schemas/GithubUser" organization: $ref: "#/components/schemas/GithubOrganization" installation: $ref: "#/components/schemas/GithubInstallation" enterprise: $ref: "#/components/schemas/GithubEnterprise" required: - action - release - repository - sender GithubUser: type: object description: A GitHub user properties: login: type: string description: The user's login name id: type: integer description: The user's ID node_id: type: string description: The user's node ID avatar_url: type: string description: URL to the user's avatar gravatar_id: type: string nullable: true description: The user's gravatar ID url: type: string description: The API URL of the user html_url: type: string description: The HTML URL of the user type: type: string enum: [Bot, User, Organization] description: The type of user site_admin: type: boolean description: Whether the user is a site admin required: - login - id - node_id - avatar_url - url - html_url - type - site_admin GithubRepository: type: object description: A GitHub repository properties: id: type: integer description: The repository ID node_id: type: string description: The repository node ID name: type: string description: The name of the repository full_name: type: string description: The full name of the repository (owner/repo) private: type: boolean description: Whether the repository is private owner: $ref: "#/components/schemas/GithubUser" html_url: type: string description: The HTML URL of the repository description: type: string nullable: true description: The repository description fork: type: boolean description: Whether the repository is a fork url: type: string description: The API URL of the repository clone_url: type: string description: The clone URL of the repository git_url: type: string description: The git URL of the repository ssh_url: type: string description: The SSH URL of the repository default_branch: type: string description: The default branch of the repository created_at: type: string format: date-time description: When the repository was created updated_at: type: string format: date-time description: When the repository was last updated pushed_at: type: string format: date-time description: When the repository was last pushed to required: - id - node_id - name - full_name - private - owner - html_url - fork - url - clone_url - git_url - ssh_url - default_branch - created_at - updated_at - pushed_at GithubReleaseAsset: type: object description: A GitHub release asset properties: id: type: integer description: The asset ID node_id: type: string description: The asset node ID name: type: string description: The name of the asset label: type: string nullable: true description: The label of the asset content_type: type: string description: The content type of the asset state: type: string enum: [uploaded, open] description: The state of the asset size: type: integer description: The size of the asset in bytes download_count: type: integer description: The number of downloads created_at: type: string format: date-time description: When the asset was created updated_at: type: string format: date-time description: When the asset was last updated browser_download_url: type: string description: The browser download URL uploader: $ref: "#/components/schemas/GithubUser" required: - id - node_id - name - content_type - state - size - download_count - created_at - updated_at - browser_download_url - uploader GithubOrganization: type: object description: A GitHub organization properties: login: type: string description: The organization's login name id: type: integer description: The organization ID node_id: type: string description: The organization node ID url: type: string description: The API URL of the organization repos_url: type: string description: The API URL of the organization's repositories events_url: type: string description: The API URL of the organization's events hooks_url: type: string description: The API URL of the organization's hooks issues_url: type: string description: The API URL of the organization's issues members_url: type: string description: The API URL of the organization's members public_members_url: type: string description: The API URL of the organization's public members avatar_url: type: string description: URL to the organization's avatar description: type: string nullable: true description: The organization description required: - login - id - node_id - url - repos_url - events_url - hooks_url - issues_url - members_url - public_members_url - avatar_url GithubInstallation: type: object description: A GitHub App installation properties: id: type: integer description: The installation ID account: $ref: "#/components/schemas/GithubUser" repository_selection: type: string enum: [selected, all] description: Repository selection for the installation access_tokens_url: type: string description: The API URL for access tokens repositories_url: type: string description: The API URL for repositories html_url: type: string description: The HTML URL of the installation app_id: type: integer description: The GitHub App ID target_id: type: integer description: The target ID target_type: type: string description: The target type permissions: type: object description: The installation permissions events: type: array items: type: string description: The events the installation subscribes to created_at: type: string format: date-time description: When the installation was created updated_at: type: string format: date-time description: When the installation was last updated single_file_name: type: string nullable: true description: The single file name if applicable required: - id - account - repository_selection - access_tokens_url - repositories_url - html_url - app_id - target_id - target_type - permissions - events - created_at - updated_at GithubEnterprise: type: object description: A GitHub enterprise properties: id: type: integer description: The enterprise ID slug: type: string description: The enterprise slug name: type: string description: The enterprise name node_id: type: string description: The enterprise node ID avatar_url: type: string description: URL to the enterprise avatar description: type: string nullable: true description: The enterprise description website_url: type: string nullable: true description: The enterprise website URL html_url: type: string description: The HTML URL of the enterprise created_at: type: string format: date-time description: When the enterprise was created updated_at: type: string format: date-time description: When the enterprise was last updated required: - id - slug - name - node_id - avatar_url - html_url - created_at - updated_at ReleaseNote: type: object properties: id: type: integer description: Unique identifier for the release note project: type: string enum: [comfyui, comfyui_frontend, desktop, cloud] description: The project this release note belongs to version: type: string description: The version of the release attention: type: string enum: [low, medium, high] description: The attention level for this release content: type: string description: The content of the release note in markdown format published_at: type: string format: date-time description: When the release note was published required: - id - project - version - attention - content - published_at ViduCreation: type: object properties: id: type: string url: type: string cover_url: type: string watermarked_url: type: string moderation_url: type: array items: type: string ViduState: enum: - created - processing - queueing - success - failed type: string ViduGetCreationsReply: type: object properties: state: $ref: "#/components/schemas/ViduState" err_code: type: string creations: type: array items: $ref: "#/components/schemas/ViduCreation" id: type: string ViduTaskReply: type: object properties: task_id: type: string state: $ref: "#/components/schemas/ViduState" model: type: string style: enum: - general - anime type: string prompt: type: string images: type: array items: type: string duration: type: integer format: int32 seed: type: integer format: int32 aspect_ratio: type: string resolution: type: string movement_amplitude: enum: - auto - small - medium - large type: string bgm: type: boolean description: Whether background music was added payload: type: string description: Transparent transmission parameters off_peak: type: boolean description: Off peak mode status watermark: type: boolean description: Whether watermark was added created_at: type: string format: date-time credits: type: integer format: int32 required: - task_id - state - credits ViduTaskRequest: type: object properties: model: type: string description: "Model name: viduq3-pro, viduq2-pro-fast, viduq2-pro, viduq2-turbo, viduq1, viduq1-classic, vidu2.0" style: enum: - general - anime type: string prompt: type: string description: Text prompt for video generation (max 2000 characters) images: type: array items: type: string description: Images for img2video (accepts 1 image as start frame) audio: type: boolean description: Enable direct audio-video generation capability (default true for q3 model) audio_type: type: string enum: - all - speech_only - sound_effect_only description: "Audio type when audio is true: all (sound effects + vocals), speech_only, sound_effect_only. Ineffective for q3 model" voice_id: type: string description: Voice ID for audio (ineffective for q3 model) is_rec: type: boolean description: Use recommended prompt (consumes additional 10 credits) bgm: type: boolean description: Add background music to generated video (ineffective for q3 model) duration: type: integer format: int32 description: "Video duration in seconds. viduq3-pro: 1-16, viduq2-pro-fast: 1-10, viduq2-pro/turbo: 1-8" seed: type: integer format: int32 description: Random seed (defaults to random if not specified) aspect_ratio: type: string resolution: type: string description: "Resolution: 360p, 540p, 720p, 1080p, 2K (availability depends on model and duration)" movement_amplitude: enum: - auto - small - medium - large type: string description: Movement amplitude of objects in frame (ineffective for q2, q3 models) payload: type: string description: Transparent transmission parameters (max 1048576 characters) off_peak: type: boolean description: Off peak mode (lower cost, tasks generated within 48 hours) watermark: type: boolean description: Add watermark to video (default false) wm_position: type: integer format: int32 description: "Watermark position: 1 (top left), 2 (top right), 3 (bottom right, default), 4 (bottom left)" wm_url: type: string description: Watermark image URL (uses default watermark if not provided) meta_data: type: string description: Metadata identification, JSON format string for custom metadata enhance: type: boolean callback_url: type: string description: Callback URL for task status updates priority: type: integer format: int32 ViduExtendRequest: type: object properties: model: type: string description: Model name (viduq2-pro or viduq2-turbo) video_creation_id: type: string description: Vidu video_creation_id, required with video_url video_url: type: string description: Any video URL, required with video_creation_id images: type: array items: type: string description: Extended reference image to the end frame (only accepts 1 image) prompt: type: string description: Text prompt for video generation (max 2000 characters) duration: type: integer format: int32 description: Extended duration in seconds (1-7, default 5) resolution: type: string description: Resolution (540p, 720p, 1080p) payload: type: string description: Transparent transmission parameters (max 1048576 characters) callback_url: type: string description: Callback URL for task status updates required: - model ViduExtendReply: type: object properties: task_id: type: string state: $ref: "#/components/schemas/ViduState" model: type: string video_creation_id: type: string video_url: type: string images: type: array items: type: string prompt: type: string duration: type: integer format: int32 resolution: type: string payload: type: string credits: type: integer format: int32 created_at: type: string format: date-time required: - task_id - state - credits ViduImageSetting: type: object properties: prompt: type: string description: Prompt for extending the previous frame key_image: type: string description: Reference image for each key frame duration: type: integer format: int32 description: Duration between key frames in seconds (2-7, default 5) required: - key_image ViduMultiframeRequest: type: object properties: model: type: string description: Model name (viduq2-pro or viduq2-turbo) start_image: type: string description: The first frame image (Base64 or URL) image_settings: type: array items: $ref: "#/components/schemas/ViduImageSetting" description: Configuration for intelligent multi-frame generation (2-9 frames) resolution: type: string description: Video resolution (540p, 720p, 1080p) payload: type: string description: Transparent transmission parameters (max 1048576 characters) callback_url: type: string description: Callback URL for task status updates required: - model - start_image - image_settings ViduMultiframeReply: type: object properties: task_id: type: string state: $ref: "#/components/schemas/ViduState" model: type: string start_image: type: string image_settings: type: array items: $ref: "#/components/schemas/ViduImageSetting" resolution: type: string payload: type: string credits: type: integer format: int32 created_at: type: string format: date-time required: - task_id - state - credits BytePlusImageGenerationRequest: type: object properties: model: type: string enum: - seedream-3-0-t2i-250415 - seededit-3-0-i2i-250628 - seedream-4-0-250828 - seedream-4-5-251128 - seedream-5-0-260128 prompt: type: string description: Text description for image generation or transformation image: oneOf: - type: string description: Single image (URL or Base64) - type: array items: type: string maxItems: 14 description: Multiple images (URLs or Base64) - supported by seedream-5.0-lite, 4.5 and 4.0 description: | Seedream-5.0-lite, 4.5 and 4.0, and seededit-3.0-i2i support this parameter. Enter the Base64 encoding or an accessible URL of the image to edit. Seedream-5.0-lite, 4.5 and 4.0 support inputting a single image or multiple images (see the multi-image blending example), while seededit-3.0-i2i only supports single-image input. • Image URL: Make sure that the image URL is accessible. • Base64 encoding: The format must be data:image/;base64,. Note: must be in lowercase, e.g., data:image/png;base64,. An input image must meet the following requirements: • Image format: jpeg, png (seedream-5.0-lite, 4.5 and 4.0 also support webp, bmp, tiff and gif) • Aspect ratio (width/height): In the range [1/16, 16] for seedream-5.0-lite, 4.5 and 4.0; [1/3, 3] for seededit-3.0-i2i • Width and height (px): > 14 • Size: No more than 10 MB • Maximum of 14 reference images size: type: string description: | "seedream-3-0-t2i-250415": Specifies the dimensions (width x height in pixels) of the generated image. Must be between [512x512, 2048x2048] "seededit-3-0-i2i-250628": The width and height pixels of the generated image. Currently only supports adaptive. "seedream-4-0-250828": Set the specification for the generated image. Two methods are available but cannot be used together. Method 1 | Specify the resolution. Optional values: 1K, 2K, 4K Method 2 | Specify width and height in pixels. Default: 2048x2048, total pixels: [1024x1024, 4096x4096], aspect ratio: [1/16, 16] "seedream-4-5-251128": Two methods available. Method 1 | Specify the resolution. Optional values: 2K, 4K Method 2 | Specify width and height in pixels. Default: 2048x2048, total pixels: [2560x1440, 4096x4096], aspect ratio: [1/16, 16] "seedream-5-0-260128": Two methods available. Method 1 | Specify the resolution. Optional values: 2K, 3K Method 2 | Specify width and height in pixels. Default: 2048x2048, total pixels: [2560x1440, ~3072x3072], aspect ratio: [1/16, 16] response_format: type: string enum: - url - b64_json description: Specifies the format of the generated image returned in the response default: "url" seed: type: integer description: "Random seed to control the stochasticity of image generation. Range: [-1, 2147483647]. If not specified, a seed will be automatically generated. To reproduce the same output, use the same seed value." default: -1 sequential_image_generation: type: string description: | Controls whether to disable the batch generation feature. This parameter is only supported on seedream-5.0-lite, 4.5 and 4.0. Valid values: auto: In automatic mode, the model automatically determines whether to return multiple images and how many images it will contain based on the user's prompt. disabled: Disables batch generation feature. The model will only generate one image. sequential_image_generation_options: type: object description: | Only seedream-5.0-lite, 4.5 and 4.0 support this parameter. Configuration for the batch image generation feature. This parameter is only effective when sequential_image_generation is set to auto. properties: max_images: type: integer description: "Specifies the maximum number of images to generate in this request. Number of input reference images + Number of generated images ≤ 15." minimum: 1 maximum: 15 default: 15 guidance_scale: type: number format: float description: "Controls how closely the output image aligns with the input prompt. Range [1, 10]. Higher values result in stronger prompt adherence. Default 2.5 for seedream-3-0-t2i-250415 and 5.5 for seededit-3-0-i2i-250628. Not supported by seedream-5.0-lite, 4.5 and 4.0." minimum: 1 maximum: 10 watermark: type: boolean description: "Specifies whether to add a watermark to the generated image. false = No watermark, true = Adds watermark with 'AI generated' label" default: true output_format: type: string enum: - png - jpeg description: "Specifies the format of the output image. Only seedream-5.0-lite supports this parameter." default: "jpeg" stream: type: boolean description: "Whether to enable streaming output mode. Only seedream-5.0-lite, 4.5 and 4.0 support this parameter. false = All output images are returned at once. true = Each output image is returned immediately after generated." default: false optimize_prompt_options: type: object description: | Configuration for prompt optimization feature. Only seedream-5.0-lite/4.5 (only supports standard mode) and seedream-4.0 support this parameter. properties: mode: type: string enum: - standard - fast description: "Set the mode for the prompt optimization feature. standard = Higher quality, longer generation time. fast = Faster but at a more average quality." default: "standard" required: - prompt - model BytePlusImageGenerationResponse: type: object properties: model: type: string description: The model ID used for the request example: "seedream-3-0-t2i-250415" created: type: integer description: Unix timestamp (in seconds) indicating the time when the request was created data: type: array items: type: object properties: url: type: string format: uri description: URL for image download (if response_format is "url") b64_json: type: string description: Base64-encoded image data (if response_format is "b64_json") size: type: string description: "The width and height of the image in pixels, in the format x. Only seedream-5.0-lite, 4.5 and 4.0 support this parameter." description: Contains information about the generated image(s) usage: type: object properties: generated_images: type: integer description: Number of images generated by the model output_tokens: type: integer description: The number of tokens used for the picture generated by the model. total_tokens: type: integer description: The total number of tokens consumed by this request. error: type: object properties: code: type: string description: Error code message: type: string description: Error message description: Error information (if any) BytePlusVideoGenerationRequest: type: object properties: model: type: string description: The ID of the model to call. Available models include seedance-1-5-pro-251215, seedance-1-0-pro-250528, seedance-1-0-pro-fast-251015, seedance-1-0-lite-t2v-250428, seedance-1-0-lite-i2v-250428 enum: - seedance-1-5-pro-251215 - seedance-1-0-pro-250528 - seedance-1-0-lite-t2v-250428 - seedance-1-0-lite-i2v-250428 - seedance-1-0-pro-fast-251015 content: type: array description: The input content for the model to generate a video items: $ref: "#/components/schemas/BytePlusVideoGenerationContent" minItems: 1 callback_url: type: string format: uri description: Callback notification address for the result of this generation task return_last_frame: type: boolean default: false description: | Whether to return the last frame image of the generated video. true: Returns the last frame image of the generated video. After setting this parameter to true, you can obtain the last frame image by calling the Querying the information about a video generation task. The last frame image is in PNG format, with its pixel width and height consistent with those of the generated video, and it contains no watermarks. Using this parameter allows the generation of multiple consecutive videos: the last frame of the previously generated video is used as the first frame of the next video task, enabling quick generation of multiple consecutive videos. false: Does not return the last frame image of the generated video. generate_audio: type: boolean default: true description: | Only supported by Seedance 1.5 pro. Whether the generated video includes audio synchronized with the visuals. true: The model outputs a video with synchronized audio. Seedance 1.5 pro can automatically generate matching voice, sound effects, or background music based on the prompt and visual content. It is recommended to enclose dialogue in double quotes. Example: A man stops a woman and says, "Remember, never point your finger at the moon." false: The model outputs a silent video. required: - model - content BytePlusVideoGenerationContent: type: object properties: type: type: string enum: - text - image_url description: The type of the input content text: type: string description: | The input text information for the model. Includes text prompt and optional parameters. Text prompt (required): Description of the video to be generated using Chinese and English characters. Parameters (optional): Add --[parameters] after the text prompt to control video specifications: - --resolution (--rs): 480p, 720p, 1080p (default: 720p) - --ratio (--rt): 21:9, 16:9, 4:3, 1:1, 3:4, 9:16, 9:21, adaptive (default: 16:9 or adaptive) - --duration (--dur): 3-12 seconds (default: 5) - --framepersecond (--fps): 24 (default: 24) - --watermark (--wm): true/false (default: false) - --seed (--seed): -1 to 2^32-1 (default: -1) - --camerafixed (--cf): true/false (default: false) Example: "A beautiful landscape --ratio 16:9 --resolution 720p --duration 5" maxLength: 4096 image_url: type: object properties: url: type: string description: | Image content for image-to-video generation (when type is "image") Image URL: Make sure that the image URL is accessible. Base64-encoded content: Format must be data:image/;base64, required: - type BytePlusVideoGenerationResponse: type: object properties: id: type: string description: The ID of the video generation task required: - id BytePlusVideoGenerationQueryResponse: type: object properties: id: type: string description: The ID of the video generation task model: type: string description: The name and version of the model used by the task status: type: string enum: - queued - running - cancelled - succeeded - failed description: The state of the task error: type: object nullable: true description: The error information. If the task succeeds, null is returned. If the task fails, the error information is returned. properties: code: type: string description: The error code message: type: string description: The error message created_at: type: integer description: The time when the task was created. The value is a UNIX timestamp in seconds. updated_at: type: integer description: The time when the task was last updated. The value is a UNIX timestamp in seconds. content: type: object description: The output after the video generation task is completed, which contains the download URL of the output video. properties: video_url: type: string description: The URL of the output video. For security purposes, the output video is cleared after 24 hours. usage: type: object description: The token usage for the request properties: completion_tokens: type: integer description: The number of tokens generated by the model total_tokens: type: integer description: For the video generation model, the number of input tokens is not calculated and defaults to 0. Therefore, total_tokens = completion_tokens. WanVideoGenerationRequest: type: object properties: model: type: string description: The ID of the model to call enum: - wan2.5-t2v-preview - wan2.5-i2v-preview - wan2.6-t2v - wan2.6-i2v - wan2.6-r2v input: type: object description: Enter basic information, such as prompt words, etc. properties: prompt: type: string description: | Text prompt words. Support Chinese and English, length not exceeding 800 characters. For wan2.6-r2v with multiple reference videos, use 'character1', 'character2', etc. to refer to subjects in the order of reference videos. Example: "Character1 sings on the roadside, Character2 dances beside it" maxLength: 800 negative_prompt: type: string description: Reverse prompt words are used to describe content that you do not want to see in the video screen maxLength: 500 audio_url: type: string description: "Audio file download URL. Supported formats: mp3 and wav. Cannot be used with reference_video_urls." img_url: type: string description: "First frame image URL or Base64 encoded data. Required for I2V models. Image formats: JPEG, JPG, PNG, BMP, WEBP. Resolution: 360-2000 pixels. File size: max 10MB." template: type: string description: "Video effect template name. Optional. Currently supported: squish, flying, carousel. When used, prompt parameter is ignored." reference_video_urls: type: array description: | Reference video URLs for wan2.6-r2v model only. Array of 1-3 video URLs. Input restrictions: - Format: mp4, mov - Quantity: 1-3 videos - Single video length: 2-30 seconds - Single file size: max 30MB - Cannot be used with audio_url Reference duration: Single video max 5s, two videos max 2.5s each, three videos proportionally less. Billing: Based on actual reference duration used. items: type: string minItems: 1 maxItems: 3 required: - prompt parameters: type: object description: Video processing parameters properties: size: type: string description: | Video resolution in format width*height. Supported resolutions vary by model: For wan2.5 T2V: 480P (480*832, 832*480, 624*624), 720P, 1080P sizes For wan2.6 T2V/R2V (no 480P): 720P: 1280*720, 720*1280, 960*960, 1088*832, 832*1088 1080P: 1920*1080, 1080*1920, 1440*1440, 1632*1248, 1248*1632 resolution: type: string description: | Resolution level for I2V models. Supported values vary by model: - wan2.5-i2v-preview: 480P, 720P, 1080P - wan2.6-i2v: 720P, 1080P only (no 480P support) enum: - "480P" - "720P" - "1080P" duration: type: integer description: | The duration of the video generated, in seconds: - wan2.5 models: 5 or 10 seconds - wan2.6-t2v, wan2.6-i2v: 5, 10, or 15 seconds - wan2.6-r2v: 5 or 10 seconds only (no 15s support) enum: [5, 10, 15] default: 5 prompt_extend: type: boolean description: Is it enabled prompt intelligent rewriting. Default is true default: true shot_type: type: string description: | Intelligent multi-lens control. Only active when prompt_extend is enabled. For wan2.6 models only. - multi: Intelligent disassembly into multiple lenses (default) - single: Single lens generation enum: - multi - single default: multi seed: type: integer description: Random number seed, used to control the randomness of the model generated content minimum: 0 maximum: 2147483647 watermark: type: boolean description: Whether to add a watermark logo, the watermark is located in the lower right corner default: false audio: type: boolean description: Whether to add audio to the video default: true required: - model - input WanVideoGenerationResponse: type: object properties: output: type: object properties: task_id: type: string description: Task ID task_status: type: string description: Task status enum: - PENDING - RUNNING - SUCCEEDED - FAILED - CANCELED - UNKNOWN required: - task_id - task_status request_id: type: string description: Unique request identifier code: type: string description: The error code for the failed request (not returned if request is successful) message: type: string description: Detailed information about the failed request (not returned if request is successful) required: - output - request_id WanTaskQueryResponse: type: object properties: request_id: type: string description: Unique request identifier output: type: object properties: task_id: type: string description: Task ID task_status: type: string description: Task status enum: - PENDING - RUNNING - SUCCEEDED - FAILED - CANCELED - UNKNOWN submit_time: type: string description: Task submission time scheduled_time: type: string description: Task execution time end_time: type: string description: Task completion time video_url: type: string description: Video URL for completed video generation tasks. Link validity period 24 hours check_audio: type: string description: Audio URL for I2V tasks with audio generation orig_prompt: type: string description: Original input prompt (for video tasks) actual_prompt: type: string description: Actual prompt after intelligent rewriting (for video tasks) results: type: array description: List of task results for image generation tasks items: type: object properties: orig_prompt: type: string description: Original input prompt actual_prompt: type: string description: Actual prompt after intelligent rewriting (if enabled) url: type: string description: Generated image URL address code: type: string description: Image error code (returned when some tasks fail) message: type: string description: Image error information (returned when some tasks fail) task_metrics: type: object description: Task result statistics for image generation tasks properties: TOTAL: type: integer description: Total number of tasks SUCCEEDED: type: integer description: Number of successful tasks FAILED: type: integer description: Number of failed tasks code: type: string description: The error code for the failed request (not returned if request is successful) message: type: string description: Detailed information about the failed request (not returned if request is successful) required: - task_id - task_status usage: type: object description: Output information statistics. Only successful results are counted properties: video_duration: type: number description: Duration of generated video in seconds (T2V tasks) video_ratio: type: string description: Video resolution ratio (T2V tasks) video_count: type: integer description: Number of generated videos (T2V tasks) duration: type: number description: Duration of generated video in seconds (I2V tasks) SR: type: integer description: Video resolution level (I2V tasks) size: type: string description: Image resolution (T2I tasks) image_count: type: integer description: Number of generated images (T2I tasks) required: - request_id - output WanImageGenerationRequest: type: object properties: model: type: string description: The ID of the model to call for text-to-image generation enum: - wan2.5-t2i-preview input: type: object description: Enter basic information, such as prompt words, etc. properties: prompt: type: string description: Positive prompt words to describe expected image elements and visual features. Support Chinese and English, length not exceeding 800 characters negative_prompt: type: string description: Reverse prompt words to describe content that you do not want to see in the image required: - prompt parameters: type: object description: Image processing parameters properties: size: type: string description: Output image resolution. Default is 1024*1024. Pixel range [512, 1440], up to 200 megapixels default: "1024*1024" n: type: integer description: Number of generated images. Range 1-4, default is 4 minimum: 1 maximum: 4 default: 4 seed: type: integer description: Random number seed to control randomness. Range [0, 2147483647] minimum: 0 maximum: 2147483647 prompt_extend: type: boolean description: Enable prompt intelligent rewriting. Default is true default: true watermark: type: boolean description: Whether to add watermark logo in lower right corner default: false required: - model - input WanImageGenerationResponse: type: object properties: output: type: object properties: task_id: type: string description: Task ID task_status: type: string description: Task status enum: - PENDING - RUNNING - SUCCEEDED - FAILED - CANCELED - UNKNOWN required: - task_id - task_status request_id: type: string description: Unique request identifier code: type: string description: The error code for the failed request (not returned if request is successful) message: type: string description: Detailed information about the failed request (not returned if request is successful) required: - request_id - output WanImage2ImageGenerationRequest: type: object properties: model: type: string description: The ID of the model to call for image-to-image generation enum: - wan2.5-i2i-preview input: type: object description: Enter basic information, such as prompt words, images, etc. properties: prompt: type: string description: Positive prompt words to describe expected image elements and visual features. Support Chinese and English, length not exceeding 2000 characters maxLength: 2000 images: type: array description: Array of image URLs for image-to-image generation items: type: string description: Image URL. Supported formats JPEG, JPG, PNG, BMP, WEBP. Resolution width and height must be between 384 and 5000 pixels. File size no larger than 10MB. minItems: 1 maxItems: 2 negative_prompt: type: string description: Reverse prompt words to describe content that you do not want to see in the image maxLength: 500 required: - prompt - images parameters: type: object description: Image processing parameters properties: size: type: string description: Output image resolution. Default is 1280*1280. Width and height must be between 384 and 5000 pixels. default: "1280*1280" n: type: integer description: Number of generated images. Range 1-4, default is 1 minimum: 1 maximum: 4 default: 1 seed: type: integer description: Random number seed to control randomness. Range [0, 2147483647] minimum: 0 maximum: 2147483647 watermark: type: boolean description: Whether to add watermark logo in lower right corner default: false required: - model - input WanImage2ImageGenerationResponse: type: object properties: output: type: object properties: task_id: type: string description: Task ID task_status: type: string description: Task status enum: - PENDING - RUNNING - SUCCEEDED - FAILED - CANCELED - UNKNOWN required: - task_id - task_status request_id: type: string description: Unique request identifier code: type: string description: The error code for the failed request (not returned if request is successful) message: type: string description: Detailed information about the failed request (not returned if request is successful) required: - request_id - output TopazEnhanceGenRequest: type: object properties: output_format: type: string enum: - jpeg - jpg - png - tiff - tif description: The desired format of the output image default: jpeg subject_detection: type: string enum: - "All" - "Foreground" - "Background" description: Specifies whether you want to detect all subjects in the image, only the foreground subject, or only the background for the AI model to run on default: "All" face_enhancement: type: boolean description: By default, faces (if any) are enhanced during image processing as well. Set face_enhancement to false if you don't want this default: true face_enhancement_creativity: type: number minimum: 0 maximum: 1 description: Choose the level of creativity for face enhancement from 0 to 1. Defaults to 0, and is ignored if face_enhancement is false default: 0 face_enhancement_strength: type: number minimum: 0 maximum: 1 description: Control how sharp the enhanced faces are relative to the background from 0 to 1. Defaults to 0.8, and is ignored if face_enhancement is false default: 0.8 image: type: string format: binary description: The image file to be processed. Supported formats - jpeg (or jpg), png, tiff (or tif) source_id: type: string description: Unique identifier of the source image example: "d7b3b3b3-7b3b-4b3b-8b3b-3b3b3b3b3b3b" source_url: type: string description: The URL of the source image example: "https://example.com/image.jpg" model: type: string enum: - "Reimagine" description: The model to use for processing the image (Bloom - Creative Upscale) default: "Reimagine" output_height: type: integer minimum: 1 maximum: 32000 description: The desired height of the output image in pixels output_width: type: integer minimum: 1 maximum: 32000 description: The desired width of the output image in pixels crop_to_fill: type: boolean description: Default behavior is to letterbox the image if a differing aspect ratio is chosen. Enable crop_to_fill by setting this to true if you instead want to crop the image to fill the dimensions default: false prompt: type: string description: Text prompt for creative upscaling guidance - available for Reimagine only example: "enter-your-prompt-here" creativity: type: integer minimum: 1 maximum: 9 description: Creativity settings range from 1 to 9 - - available for Reimagine only default: 3 face_preservation: type: string enum: - "true" - "false" description: To preserve the identity of characters - available for Reimagine only (must be string "true" or "false" due to Topaz API requirement) default: "true" color_preservation: type: string enum: - "true" - "false" description: To preserve the original color - available for Reimagine only (must be string "true" or "false" due to Topaz API requirement) default: "true" required: - model TopazEnhanceGenResponse: type: object properties: process_id: type: string description: Unique identifier for the processing job example: "d7b3b3b3-7b3b-4b3b-8b3b-3b3b3b3b3b3b" source_id: type: string description: Unique identifier of the source image example: "d7b3b3b3-7b3b-4b3b-8b3b-3b3b3b3b3b3b" eta: type: integer description: Expected completion time in Unix timestamp example: 1617220000 required: - process_id - eta TopazStatusResponse: type: object properties: process_id: type: string description: Unique identifier for the processing job source_id: type: string description: Unique identifier of the source image filename: type: string description: Original filename without extension input_format: type: string description: Format of the input image input_height: type: integer description: Height of the input image in pixels input_width: type: integer description: Width of the input image in pixels output_format: type: string description: Format of the output image output_height: type: integer description: Height of the output image in pixels output_width: type: integer description: Width of the output image in pixels category: type: string description: Processing category (e.g., "Enhance") model_type: type: string description: Type of model used (e.g., "Generative") model: type: string description: Specific model used (e.g., "Reimagine") subject_detection: type: string description: Subject detection setting face_enhancement: type: boolean description: Whether face enhancement is enabled face_enhancement_creativity: type: number description: Face enhancement creativity level face_enhancement_strength: type: number description: Face enhancement strength level crop_to_fill: type: boolean description: Whether crop to fill is enabled options_json: type: string description: JSON string containing additional options sync: type: boolean description: Whether this was a synchronous request status: type: string enum: - Pending - Processing - Completed - Failed - Cancelled description: Current status of the processing job progress: type: number minimum: 0 maximum: 100 description: Progress percentage (0-100) eta: type: integer description: Expected completion time in Unix timestamp creation_time: type: integer description: Creation time in Unix timestamp modification_time: type: integer description: Last modification time in Unix timestamp credits: type: integer description: Credits consumed for this job required: - process_id - status - credits TopazDownloadResponse: type: object properties: download_url: type: string description: Presigned URL to download the image example: "https://example.com/d7b3b3b3-7b3b-4b3b-8b3b-3b3b3b3b3b3b?presigned_headers" head_url: type: string description: Presigned URL to get image metadata example: "https://example.com/d7b3b3b3-7b3b-4b3b-8b3b-3b3b3b3b3b3b?presigned_headers" expiry: type: integer description: Expiration time of the presigned URLs in Unix timestamp example: 1617220000 required: - download_url - expiry TopazVideoSourceResolution: type: object required: - width - height properties: width: type: integer description: Width of the video in pixels example: 1920 height: type: integer description: Height of the video in pixels example: 1080 TopazVideoOutputResolution: type: object required: - width - height properties: width: type: integer description: Desired output width in pixels example: 3840 height: type: integer description: Desired output height in pixels example: 2160 TopazVideoEnhancementFilter: type: object required: - model properties: model: type: string description: Short code name for AI model enum: - aaa-9 - ahq-12 - alq-13 - alqs-2 - amq-13 - amqs-2 - ddv-3 - dtd-4 - dtds-2 - dtv-4 - dtvs-2 - gcg-5 - ghq-5 - iris-2 - iris-3 - nxf-1 - nyx-3 - prob-4 - rhea-1 - rxl-1 - thd-3 - thf-4 - thm-2 - slf-1 # Starlight Fast - slc-1 # Starlight Creative example: prob-4 videoType: type: string enum: - Progressive - Interlaced - ProgressiveInterlaced description: Frame/field type of the video example: Progressive auto: type: string enum: - Auto - Manual - Relative description: Parameter mode of the selected model example: Auto fieldOrder: type: string enum: - TopFirst - BottomFirst - Auto description: Optional specification of field order for interlaced input videos example: Auto focusFixLevel: type: string enum: - None - Normal - Strong description: Downscales video input for stronger correction of blurred subjects example: Normal compression: type: number minimum: -1 maximum: 1 description: Adjust strength of compression recovery example: 0.1 details: type: number minimum: -1 maximum: 1 description: Amount of detail reconstruction example: 0.2 prenoise: type: number minimum: 0 maximum: 0.1 description: Adds noise to input to reduce over-smoothing example: 0.01 noise: type: number minimum: -1 maximum: 1 description: Amount of noise reduction example: 0.3 halo: type: number minimum: -1 maximum: 1 description: Amount of halo reduction example: 0.4 preblur: type: number minimum: -1 maximum: 1 description: Adjust anti-aliasing and deblurring strength example: 0.5 blur: type: number minimum: -1 maximum: 1 description: Amount of sharpness applied example: 0.6 grain: type: number minimum: 0 maximum: 0.1 description: Adds grain after AI model processing example: 0.02 grainSize: type: number minimum: 0 maximum: 5 description: Size of generated grain example: 1 recoverOriginalDetailValue: type: number minimum: 0 maximum: 1 description: Reintroduce source details into the output video example: 0.7 creativity: type: string enum: - low - high description: Creativity level for Starlight Creative (slc-1) only isOptimizedMode: type: boolean description: Set to true for Starlight Creative (slc-1) only TopazVideoFrameInterpolationFilter: type: object required: - model properties: model: type: string description: Short code name for AI model enum: - aion-1 - apf-2 - apo-8 - chf-3 - chr-2 example: apo-8 slowmo: type: number minimum: 1 maximum: 16 description: Slow motion factor applied to input video example: 2 fps: type: number minimum: 15 maximum: 240 description: Output frame rate, does not increase duration example: 60 duplicate: type: boolean description: Analyze input for duplicate frames and remove them example: true duplicateThreshold: type: number minimum: 0.001 maximum: 0.1 description: Sensitivity of detection for duplicate frames example: 0.01 TopazCombinedCreateRequest: oneOf: - $ref: '#/components/schemas/TopazCreateRequestVideoSchema' - $ref: '#/components/schemas/TopazCreateRequestImageSequenceSchema' TopazCreateRequestVideoSchema: title: Video AI type: object required: - source - filters - output properties: source: type: object description: Source details for the video required: - container - size - duration - frameCount - frameRate - resolution properties: container: type: string enum: - mp4 - mov - mkv description: The container format of the video file example: mp4 size: type: integer description: Size of the video file in bytes example: 123456000 duration: type: number description: Duration of the video file in seconds example: 600 frameCount: type: number description: Total number of frames in the video example: 18000 frameRate: type: number description: Frame rate of the video example: 30 resolution: type: object description: Resolution details of the video required: - width - height properties: width: type: integer description: Width of the video in pixels example: 1920 height: type: integer description: Height of the video in pixels example: 1080 external: $ref: '#/components/schemas/TopazExternalStorage' filters: $ref: '#/components/schemas/TopazInputFilters' output: $ref: '#/components/schemas/TopazOutputInformationVideo' destination: type: object properties: external: $ref: '#/components/schemas/TopazExternalStorage' overrides: type: object properties: isPaidDiffusion: type: boolean TopazCreateRequestImageSequenceSchema: title: Image Sequence type: object required: - source - filters - output - destination properties: source: type: object description: Source details for the video required: - container - frameCount - frameRate - resolution - external properties: container: type: string enum: - DPX - EXR - JPEG - PNG - TIFF description: The container format of the image files example: TIFF frameCount: type: number description: Total number of frames in the video, in this case, equal to the number of image files. example: 18000 frameRate: type: number description: Frame rate of the video example: 30 resolution: type: object description: Resolution details of the image required: - width - height properties: width: type: integer description: Width of the image in pixels example: 1920 height: type: integer description: Height of the image in pixels example: 1080 startNumber: type: integer description: Optional starting frame number for image sequences example: 120 endNumber: type: integer description: Optional ending frame number for image sequences example: 120 external: $ref: '#/components/schemas/TopazExternalStorage' filters: $ref: '#/components/schemas/TopazInputFilters' output: $ref: '#/components/schemas/TopazOutputInformationImageSequence' destination: type: object properties: external: $ref: '#/components/schemas/TopazExternalStorage' TopazExternalStorage: type: object required: - provider - credentials - bucketName - key properties: provider: type: string enum: [s3] example: s3 credentials: $ref: '#/components/schemas/TopazCredentialsS3' bucketName: type: string example: galaxies key: type: string description: | The example includes the standard specifier for image sequence requests, with optional directory path. It must begin with "%" and end with the integer specifier "d". The "0" in the example indicates left-padding with zeroes, and "6" indicates the number of digits in the file name. Keys for video requests must be valid characters supported by S3. example: milky_way/%06d.tiff TopazCredentialsS3: type: object required: - roleArn - externalId properties: roleArn: type: string description: AWS ARN of the role to assume example: arn:aws:iam::123456789:role/topazlabs externalId: type: string description: Kind of like a secret string for extra layer of security example: MSTnuGztXtTU25XKjVfMJCsujv6VtAGtv1TGSjtOL6M= TopazInputFilters: type: array description: Array of EnhancementFilter or FrameInterpolationFilter objects items: anyOf: - $ref: '#/components/schemas/TopazVideoEnhancementFilter' - $ref: '#/components/schemas/TopazVideoFrameInterpolationFilter' example: - model: prob-4 videoType: Progressive auto: Auto fieldOrder: Auto focusFixLevel: Normal compression: 0.1 details: 0.2 prenoise: 0.01 noise: 0.3 halo: 0.4 preblur: 0.5 blur: 0.6 grain: 0.02 grainSize: 1 recoverOriginalDetailValue: 0.7 - model: apo-8 slowmo: 2 fps: 60 duplicate: true duplicateThreshold: 0.01 TopazOutputInformationVideo: type: object required: - resolution - frameRate - audioCodec - audioTransfer properties: resolution: type: object description: Desired output resolution required: - width - height properties: width: type: integer description: Width in pixels. The maximum size depends on the encoder and can be referenced using the table below
H264 H265 ProRes AV1 VP9
4096 8192 16386 16384 8192
example: 7680 height: type: integer description: Height in pixels. The maximum size depends on the encoder and can be referenced using the table below
H264 H265 ProRes AV1 VP9
4096 8192 16386 8704 8192
example: 4320 frameRate: type: number description: Frame rate example: 30 audioBitrate: type: string description: Audio bitrate, if audioTransfer is Copy or Convert. Default values for the codec are used if not provided. example: "320" audioCodec: type: string enum: [AAC, AC3, PCM] description: __Required if audioTransfer is Copy or Convert.__ example: AAC audioTransfer: type: string enum: [Copy, Convert, None] example: Copy codecId: type: string description: Video codec ID, if known. Defaults to videoEncoder. example: h265-main-win-nvidia videoEncoder: type: string enum: - AV1 - FFV1 - H264 - H265 - ProRes - QuickTime Animation - QuickTime R210 - QuickTime V210 - VP9 example: H265 videoBitrate: type: string description: __Required if dynamicCompressionLevel is not provided.__ Constant bitrate, suffixed with "k" for kilobits or "m" for megabits per second. example: "1k" dynamicCompressionLevel: type: string enum: [Low, Mid, High] description: __Required if videoBitrate is not provided.__ Automatic CQP selection. example: Mid videoProfile: type: string description: Codec profile specific to videoEncoder. The following are some combinations of available profiles based on the 'videoEncoder' selection
H264 H265 ProRes AV1 VP9
High Main, Main10 422 Proxy, 422 LT, 422 Std, 422 HQ 8-bit, 10-bit Good, Best
example: Main cropToFit: type: boolean description: Center cropping to fit the output dimensions example: true container: type: string enum: - mp4 - mov - mkv description: Desired output container example: mp4 TopazOutputInformationImageSequence: type: object required: - resolution - frameRate properties: resolution: type: object description: Desired output resolution required: - width - height properties: width: type: integer description: Width in pixels. The maximum size depends on the encoder and can be referenced using the table below
H264 H265 ProRes AV1 VP9
4096 8192 16386 16384 8192
example: 7680 height: type: integer description: Height in pixels. The maximum size depends on the encoder and can be referenced using the table below
H264 H265 ProRes AV1 VP9
4096 8192 16386 8704 8192
example: 4320 frameRate: type: number description: Frame rate example: 30 codecId: type: string description: Video codec ID, if known. Defaults to videoEncoder. example: h265-main-win-nvidia videoEncoder: type: string enum: - DPX - EXR - JPEG - PNG - TIFF example: TIFF videoProfile: type: string description: Codec profile specific to videoEncoder example: Main cropToFit: type: boolean description: Center cropping to fit the output dimensions example: true container: type: string enum: - DPX - EXR - JPEG - PNG - TIFF description: Desired output container, defaults to the input container example: TIFF TopazVideoCreateRequest: $ref: '#/components/schemas/TopazCombinedCreateRequest' TopazVideoRequestEstimates: type: object description: Lower and upper bound estimates properties: cost: type: array description: Cost range in credits items: type: integer example: [10, 12] time: type: array description: Time range in seconds items: type: integer example: [600, 700] TopazVideoCreateResponse: type: object properties: requestId: type: string format: uuid description: Unique identifier for the video processing request example: "c1f96dc2-c448-00e6-82ed-14ecb6403c62" estimates: $ref: "#/components/schemas/TopazVideoRequestEstimates" required: - requestId - estimates TopazVideoAcceptResponse: type: object properties: uploadId: type: string description: Upload ID for completing multi-part upload example: "GDlWC7qIaE6okS41Xf/ktpuS5XzTRabg" urls: type: array items: type: string description: URLs to PUT the parts to example: - "https://videocloud.s3.amazonaws.com/source.mp4?uploadPart1" - "https://videocloud.s3.amazonaws.com/source.mp4?uploadPart2" message: type: string description: Response message example: "Accepted" required: - uploadId - urls TopazVideoCompleteUploadRequest: type: object required: - uploadResults properties: md5Hash: type: string description: MD5 hash of the source video file in hex example: 4d186321c1a7f0f354b297e8914ab240 uploadResults: type: array description: An array of part number and ETag pairs of the uploaded parts. ETags are returned by S3 upon upload of the part. items: type: object required: - partNum - eTag properties: partNum: type: integer description: Part number of the uploaded part, starting from 1 example: 1 eTag: type: string description: eTag value returned by S3 upon upload of the part example: "d41d8cd98f00b204e9800998ecf8427e" TopazVideoCompleteUploadResponse: type: object properties: message: type: string description: Confirmation message example: "Processing has been queued" required: - message TopazVideoEnhancedDownload: type: object description: Signed download URL to the enhanced video file properties: url: type: string example: "https://videocloud.r2.cloudflarestorage.com/enhanced.mp4" expiresIn: type: integer description: TTL in milliseconds example: 86400000 expiresAt: type: integer description: Time in milliseconds since UTC epoch example: 1727213400000 TopazVideoStatusResponse: type: object properties: status: type: string enum: - requested - accepted - initializing - preprocessing - processing - postprocessing - complete - canceling - canceled - failed description: Current status of the video processing example: "processing" progress: type: number minimum: 0 maximum: 100 description: Total progress percentage example: 82 estimates: $ref: "#/components/schemas/TopazVideoRequestEstimates" outputSize: type: string description: Size of output video example: "10 GB" averageFps: type: number description: Average processing speed of each node example: 1.23 combinedFps: type: number description: Combined processing speed of all nodes example: 12.34 message: type: string example: "Processing" download: $ref: "#/components/schemas/TopazVideoEnhancedDownload" required: - status # Meshy API Schemas MeshyTextTo3DRequest: oneOf: - $ref: "#/components/schemas/MeshyTextTo3DPreviewRequest" - $ref: "#/components/schemas/MeshyTextTo3DRefineRequest" discriminator: propertyName: mode mapping: preview: "#/components/schemas/MeshyTextTo3DPreviewRequest" refine: "#/components/schemas/MeshyTextTo3DRefineRequest" MeshyTextTo3DPreviewRequest: type: object required: - mode - prompt properties: mode: type: string description: This field should be set to "preview" when creating a preview task. enum: - preview prompt: type: string description: Describe what kind of object the 3D model is. Maximum 600 characters. maxLength: 600 art_style: $ref: "#/components/schemas/MeshyArtStyle" ai_model: $ref: "#/components/schemas/MeshyAiModel" topology: $ref: "#/components/schemas/MeshyTopology" target_polycount: type: integer description: Specify the target number of polygons in the generated model. Valid range is 100 to 300,000. minimum: 100 maximum: 300000 default: 30000 should_remesh: type: boolean description: Controls whether to enable the remesh phase. When false, returns highest-precision triangular mesh. default: true symmetry_mode: $ref: "#/components/schemas/MeshySymmetryMode" pose_mode: $ref: "#/components/schemas/MeshyPoseMode" is_a_t_pose: type: boolean description: Deprecated. Use pose_mode instead. Whether to generate the model in an A/T pose. default: false moderation: type: boolean description: When true, input content will be screened for potentially harmful content. default: false MeshyTextTo3DRefineRequest: type: object required: - mode - preview_task_id properties: mode: type: string description: This field should be set to "refine" when creating a refine task. enum: - refine preview_task_id: type: string description: The corresponding preview task id. The status of the given preview task must be SUCCEEDED. enable_pbr: type: boolean description: Generate PBR Maps (metallic, roughness, normal) in addition to the base color. Note that enable_pbr should be set to false when using Sculpture style. default: false texture_prompt: type: string description: Provide an additional text prompt to guide the texturing process. Maximum 600 characters. maxLength: 600 texture_image_url: type: string description: Provide a 2d image to guide the texturing process. Supports .jpg, .jpeg, .png formats or base64-encoded data URI. ai_model: $ref: "#/components/schemas/MeshyAiModel" moderation: type: boolean description: When true, input content will be screened for potentially harmful content. default: false MeshyArtStyle: type: string description: Describe your desired art style of the object. enum: - realistic - sculpture default: realistic MeshyAiModel: type: string description: ID of the model to use. enum: - meshy-5 - latest default: latest MeshyTopology: type: string description: Specify the topology of the generated model. enum: - quad - triangle default: triangle MeshySymmetryMode: type: string description: Controls symmetry behavior during model generation. enum: - "off" - auto - "on" default: auto MeshyPoseMode: type: string description: Specify the pose mode for the generated model. enum: - a-pose - t-pose - "" MeshyTextTo3DCreateResponse: type: object properties: result: type: string description: The task id of the newly created Text to 3D task. required: - result MeshyTextTo3DTask: type: object properties: id: type: string description: Unique identifier for the task. type: type: string description: Type of the Text to 3D task. enum: - text-to-3d-preview - text-to-3d-refine model_urls: $ref: "#/components/schemas/MeshyModelUrls" prompt: type: string description: The unmodified prompt that was used to create the task. negative_prompt: type: string description: Deprecated field maintained for backward compatibility. art_style: type: string description: The unmodified art_style that was used to create the preview task. texture_richness: type: string description: Deprecated field maintained for backward compatibility. texture_prompt: type: string description: Additional text prompt provided to guide the texturing process during the refine stage. texture_image_url: type: string description: Downloadable URL to the texture image that was used to guide the texturing process. thumbnail_url: type: string description: Downloadable URL to the thumbnail image of the model file. video_url: type: string description: Deprecated field returning the downloadable URL to the preview video. progress: type: integer description: Progress of the task. 0 if not started, 100 when succeeded. minimum: 0 maximum: 100 started_at: type: integer description: Timestamp of when the task was started, in milliseconds. 0 if not started. created_at: type: integer description: Timestamp of when the task was created, in milliseconds. finished_at: type: integer description: Timestamp of when the task was finished, in milliseconds. 0 if not finished. status: $ref: "#/components/schemas/MeshyTaskStatus" texture_urls: type: array items: $ref: "#/components/schemas/MeshyTextureUrls" description: An array of texture URL objects that are generated from the task. preceding_tasks: type: integer description: The count of preceding tasks. Only meaningful when status is PENDING. task_error: $ref: "#/components/schemas/MeshyTaskError" required: - id - status MeshyModelUrls: type: object description: Downloadable URLs to the textured 3D model files generated by Meshy. properties: glb: type: string description: Downloadable URL to the GLB file. fbx: type: string description: Downloadable URL to the FBX file. usdz: type: string description: Downloadable URL to the USDZ file. obj: type: string description: Downloadable URL to the OBJ file. mtl: type: string description: Downloadable URL to the MTL file. MeshyTaskStatus: type: string description: Status of the task. enum: - PENDING - IN_PROGRESS - SUCCEEDED - FAILED - CANCELED MeshyTextureUrls: type: object description: Texture URL object containing PBR maps. properties: base_color: type: string description: Downloadable URL to the base color map image. metallic: type: string description: Downloadable URL to the metallic map image. normal: type: string description: Downloadable URL to the normal map image. roughness: type: string description: Downloadable URL to the roughness map image. MeshyTaskError: type: object description: Error object that contains the error message if the task failed. properties: message: type: string description: Detailed error message. # Meshy Image to 3D Schemas MeshyImageTo3DRequest: type: object required: - image_url properties: image_url: type: string description: Provide an image for Meshy to use in model creation. Supports .jpg, .jpeg, .png formats or base64-encoded data URI. model_type: type: string description: | Specify the type of 3D mesh generation. - standard: Regular high-detail 3D mesh generation. - lowpoly: Generates low-poly mesh optimized for cleaner polygons. When lowpoly is selected, ai_model, topology, target_polycount, should_remesh, save_pre_remeshed_model are ignored. enum: - standard - lowpoly default: standard ai_model: $ref: "#/components/schemas/MeshyAiModel" topology: $ref: "#/components/schemas/MeshyTopology" target_polycount: type: integer description: Specify the target number of polygons in the generated model. Valid range is 100 to 300,000. minimum: 100 maximum: 300000 default: 30000 symmetry_mode: $ref: "#/components/schemas/MeshySymmetryMode" should_remesh: type: boolean description: Controls whether to enable the remesh phase. When false, returns highest-precision triangular mesh. default: true save_pre_remeshed_model: type: boolean description: When true, stores an extra GLB file before the remesh phase completes. Only takes effect when should_remesh is true. default: false should_texture: type: boolean description: Determines if textures are generated. When false, provides a mesh without textures. default: true enable_pbr: type: boolean description: Generate PBR Maps (metallic, roughness, normal) in addition to the base color. default: false pose_mode: $ref: "#/components/schemas/MeshyPoseMode" is_a_t_pose: type: boolean description: Deprecated. Use pose_mode instead. Whether to generate the model in an A/T pose. default: false texture_prompt: type: string description: Provide a text prompt to guide the texturing process. Maximum 600 characters. maxLength: 600 texture_image_url: type: string description: Provide a 2d image to guide the texturing process. Supports .jpg, .jpeg, .png formats or base64-encoded data URI. moderation: type: boolean description: When true, input content will be screened for potentially harmful content. default: false MeshyImageTo3DCreateResponse: type: object properties: result: type: string description: The task id of the newly created Image to 3D task. required: - result MeshyImageTo3DTask: type: object properties: id: type: string description: Unique identifier for the task. type: type: string description: Type of the Image to 3D task. enum: - image-to-3d model_urls: $ref: "#/components/schemas/MeshyImageTo3DModelUrls" thumbnail_url: type: string description: Downloadable URL to the thumbnail image of the model file. texture_prompt: type: string description: The text prompt that was used to guide the texturing process. texture_image_url: type: string description: Downloadable URL to the texture image that was used to guide the texturing process. progress: type: integer description: Progress of the task. 0 if not started, 100 when succeeded. minimum: 0 maximum: 100 started_at: type: integer description: Timestamp of when the task was started, in milliseconds. 0 if not started. created_at: type: integer description: Timestamp of when the task was created, in milliseconds. expires_at: type: integer description: Timestamp of when the task result expires, in milliseconds. finished_at: type: integer description: Timestamp of when the task was finished, in milliseconds. 0 if not finished. status: $ref: "#/components/schemas/MeshyTaskStatus" texture_urls: type: array items: $ref: "#/components/schemas/MeshyTextureUrls" description: An array of texture URL objects that are generated from the task. preceding_tasks: type: integer description: The count of preceding tasks. Only meaningful when status is PENDING. task_error: $ref: "#/components/schemas/MeshyTaskError" required: - id - status MeshyImageTo3DModelUrls: type: object description: Downloadable URLs to the 3D model files generated by Meshy. properties: glb: type: string description: Downloadable URL to the GLB file. fbx: type: string description: Downloadable URL to the FBX file. obj: type: string description: Downloadable URL to the OBJ file. usdz: type: string description: Downloadable URL to the USDZ file. mtl: type: string description: Downloadable URL to the MTL file. pre_remeshed_glb: type: string description: Downloadable URL to the original GLB output before remeshing. Available only when should_remesh and save_pre_remeshed_model are both true. # Meshy Multi-Image to 3D Schemas MeshyMultiImageTo3DRequest: type: object required: - image_urls properties: image_urls: type: array items: type: string minItems: 1 maxItems: 4 description: Provide 1 to 4 images for Meshy to use in model creation. All images should depict the same object from different angles. ai_model: type: string description: ID of the model to use. enum: - meshy-5 - latest default: latest topology: $ref: "#/components/schemas/MeshyTopology" target_polycount: type: integer description: Specify the target number of polygons in the generated model. Valid range is 100 to 300,000. minimum: 100 maximum: 300000 default: 30000 symmetry_mode: $ref: "#/components/schemas/MeshySymmetryMode" should_remesh: type: boolean description: Controls whether to enable the remesh phase. When false, returns highest-precision triangular mesh. default: true save_pre_remeshed_model: type: boolean description: When true, stores an extra GLB file before the remesh phase completes. Only takes effect when should_remesh is true. default: false should_texture: type: boolean description: Determines if textures are generated. When false, provides a mesh without textures for 5 credits. default: true enable_pbr: type: boolean description: Generate PBR Maps (metallic, roughness, normal) in addition to the base color. default: false pose_mode: $ref: "#/components/schemas/MeshyPoseMode" is_a_t_pose: type: boolean description: Deprecated. Use pose_mode instead. Whether to generate the model in an A/T pose. default: false texture_prompt: type: string description: Provide a text prompt to guide the texturing process. Maximum 600 characters. maxLength: 600 texture_image_url: type: string description: Provide a 2d image to guide the texturing process. Supports .jpg, .jpeg, .png formats or base64-encoded data URI. moderation: type: boolean description: When true, input content will be screened for potentially harmful content. default: false MeshyMultiImageTo3DCreateResponse: type: object properties: result: type: string description: The task id of the newly created Multi-Image to 3D task. required: - result MeshyMultiImageTo3DTask: type: object properties: id: type: string description: Unique identifier for the task. type: type: string description: Type of the Multi-Image to 3D task. enum: - multi-image-to-3d model_urls: $ref: "#/components/schemas/MeshyImageTo3DModelUrls" thumbnail_url: type: string description: Downloadable URL to the thumbnail image of the model file. texture_prompt: type: string description: The text prompt that was used to guide the texturing process. progress: type: integer description: Progress of the task. 0 if not started, 100 when succeeded. minimum: 0 maximum: 100 started_at: type: integer description: Timestamp of when the task was started, in milliseconds. 0 if not started. created_at: type: integer description: Timestamp of when the task was created, in milliseconds. expires_at: type: integer description: Timestamp of when the task result expires, in milliseconds. finished_at: type: integer description: Timestamp of when the task was finished, in milliseconds. 0 if not finished. status: $ref: "#/components/schemas/MeshyTaskStatus" texture_urls: type: array items: $ref: "#/components/schemas/MeshyTextureUrls" description: An array of texture URL objects that are generated from the task. preceding_tasks: type: integer description: The count of preceding tasks. Only meaningful when status is PENDING. task_error: $ref: "#/components/schemas/MeshyTaskError" required: - id - status # Meshy Remesh Schemas MeshyRemeshRequest: type: object properties: input_task_id: type: string description: The ID of the completed Image to 3D or Text to 3D task you wish to remesh. Required if model_url is not provided. model_url: type: string description: A publicly accessible URL or data URI to a 3D model. Supported formats glb, gltf, obj, fbx, stl. Required if input_task_id is not provided. target_formats: type: array items: type: string enum: - glb - fbx - obj - usdz - blend - stl description: A list of target formats for the remeshed model. default: - glb topology: $ref: "#/components/schemas/MeshyTopology" target_polycount: type: integer description: Specify the target number of polygons in the generated model. Valid range is 100 to 300,000. minimum: 100 maximum: 300000 default: 30000 resize_height: type: number description: Resize the model to a certain height measured in meters. 0 means no resizing. default: 0 origin_at: type: string description: Position of the origin. enum: - bottom - center - "" convert_format_only: type: boolean description: If true, only changes the format of the input model file, ignoring other inputs like topology, resize_height, and target_polycount. default: false MeshyRemeshCreateResponse: type: object properties: result: type: string description: The id of the newly created remesh task. required: - result MeshyRemeshTask: type: object properties: id: type: string description: Unique identifier for the task. type: type: string description: Type of the Remesh task. enum: - remesh model_urls: $ref: "#/components/schemas/MeshyRemeshModelUrls" progress: type: integer description: Progress of the task. 0 if not started, 100 when succeeded. minimum: 0 maximum: 100 status: $ref: "#/components/schemas/MeshyRemeshTaskStatus" preceding_tasks: type: integer description: The count of preceding tasks. Only meaningful when status is PENDING. created_at: type: integer description: Timestamp of when the task was created, in milliseconds. started_at: type: integer description: Timestamp of when the task was started, in milliseconds. 0 if not started. finished_at: type: integer description: Timestamp of when the task was finished, in milliseconds. 0 if not finished. task_error: $ref: "#/components/schemas/MeshyTaskError" required: - id - status MeshyRemeshModelUrls: type: object description: Downloadable URLs to the remeshed 3D model files. properties: glb: type: string description: Downloadable URL to the GLB file. fbx: type: string description: Downloadable URL to the FBX file. obj: type: string description: Downloadable URL to the OBJ file. usdz: type: string description: Downloadable URL to the USDZ file. blend: type: string description: Downloadable URL to the Blender file. stl: type: string description: Downloadable URL to the STL file. MeshyRemeshTaskStatus: type: string description: Status of the remesh task. enum: - PENDING - PROCESSING - SUCCEEDED - FAILED # Meshy Rigging Schemas MeshyRiggingRequest: type: object properties: input_task_id: type: string description: The input task that needs to be rigged. Required if model_url is not provided. model_url: type: string description: A publicly accessible URL or Data URI to a textured humanoid GLB file. Required if input_task_id is not provided. height_meters: type: number description: The approximate height of the character model in meters. Must be a positive number. default: 1.7 texture_image_url: type: string description: The model's UV-unwrapped base color texture image. Publicly accessible URL or Data URI. Supports .png format. MeshyRiggingCreateResponse: type: object properties: result: type: string description: The task id of the newly created rigging task. required: - result MeshyRiggingTask: type: object properties: id: type: string description: Unique identifier for the task. type: type: string description: Type of the Rigging task. enum: - rig status: $ref: "#/components/schemas/MeshyTaskStatus" progress: type: integer description: Progress of the task (0-100). 0 if not started, 100 if succeeded. minimum: 0 maximum: 100 created_at: type: integer description: Timestamp of when the task was created, in milliseconds. started_at: type: integer description: Timestamp of when the task was started, in milliseconds. 0 if not started. finished_at: type: integer description: Timestamp of when the task was finished, in milliseconds. 0 if not finished. expires_at: type: integer description: Timestamp of when the task result expires, in milliseconds. task_error: $ref: "#/components/schemas/MeshyTaskError" result: $ref: "#/components/schemas/MeshyRiggingResult" preceding_tasks: type: integer description: The count of preceding tasks. Only meaningful when status is PENDING. required: - id - status MeshyRiggingResult: type: object description: Contains the output asset URLs if the task SUCCEEDED. properties: rigged_character_fbx_url: type: string description: Downloadable URL for the rigged character in FBX format. rigged_character_glb_url: type: string description: Downloadable URL for the rigged character in GLB format. basic_animations: $ref: "#/components/schemas/MeshyRiggingBasicAnimations" MeshyRiggingBasicAnimations: type: object description: Contains URLs for default animations. properties: walking_glb_url: type: string description: Downloadable URL for walking animation in GLB format (with skin). walking_fbx_url: type: string description: Downloadable URL for walking animation in FBX format (with skin). walking_armature_glb_url: type: string description: Downloadable URL for walking animation armature in GLB format. running_glb_url: type: string description: Downloadable URL for running animation in GLB format (with skin). running_fbx_url: type: string description: Downloadable URL for running animation in FBX format (with skin). running_armature_glb_url: type: string description: Downloadable URL for running animation armature in GLB format. # Meshy Retexture Schemas MeshyRetextureRequest: type: object properties: input_task_id: type: string description: The ID of the completed Image to 3D or Text to 3D task you wish to retexture. Required if model_url is not provided. model_url: type: string description: A publicly accessible URL or Data URI to a 3D model. Supported formats glb, gltf, obj, fbx, stl. Required if input_task_id is not provided. text_style_prompt: type: string description: Describe your desired texture style of the object using text. Maximum 600 characters. Required if image_style_url is not provided. maxLength: 600 image_style_url: type: string description: A 2d image to guide the texturing process. Supports jpg, jpeg, png formats or base64-encoded data URI. Required if text_style_prompt is not provided. ai_model: $ref: "#/components/schemas/MeshyAiModel" enable_original_uv: type: boolean description: Use the original UV of the model instead of generating new UVs. default: true enable_pbr: type: boolean description: Generate PBR Maps (metallic, roughness, normal) in addition to the base color. default: false MeshyRetextureCreateResponse: type: object properties: result: type: string description: The task id of the newly created Retexture task. required: - result MeshyRetextureTask: type: object properties: id: type: string description: Unique identifier for the task. type: type: string description: Type of the Retexture task. enum: - retexture model_urls: $ref: "#/components/schemas/MeshyRetextureModelUrls" text_style_prompt: type: string description: The text prompt that was used to create the texturing task. image_style_url: type: string description: The image input that was used to create the texturing task. thumbnail_url: type: string description: Downloadable URL to the thumbnail image of the model file. progress: type: integer description: Progress of the task. 0 if not started, 100 when succeeded. minimum: 0 maximum: 100 started_at: type: integer description: Timestamp of when the task was started, in milliseconds. 0 if not started. created_at: type: integer description: Timestamp of when the task was created, in milliseconds. expires_at: type: integer description: Timestamp of when the task result expires, in milliseconds. finished_at: type: integer description: Timestamp of when the task was finished, in milliseconds. 0 if not finished. status: $ref: "#/components/schemas/MeshyTaskStatus" texture_urls: type: array items: $ref: "#/components/schemas/MeshyTextureUrls" description: An array of texture URL objects that are generated from the task. preceding_tasks: type: integer description: The count of preceding tasks. Only meaningful when status is PENDING. task_error: $ref: "#/components/schemas/MeshyTaskError" required: - id - status MeshyRetextureModelUrls: type: object description: Downloadable URLs to the textured 3D model files. properties: glb: type: string description: Downloadable URL to the GLB file. fbx: type: string description: Downloadable URL to the FBX file. usdz: type: string description: Downloadable URL to the USDZ file. # Meshy Animation Schemas MeshyAnimationRequest: type: object required: - rig_task_id - action_id properties: rig_task_id: type: string description: The id of a successfully completed rigging task (from POST /openapi/v1/rigging). The character from this task will be animated. action_id: type: integer description: The identifier of the animation action to apply. post_process: $ref: "#/components/schemas/MeshyAnimationPostProcess" MeshyAnimationPostProcess: type: object description: Parameters for post-processing animation files. required: - operation_type properties: operation_type: type: string description: The type of operation to perform. enum: - change_fps - fbx2usdz - extract_armature fps: type: integer description: The target frame rate. Default is 30. Applicable only when operation_type is change_fps. enum: - 24 - 25 - 30 - 60 default: 30 MeshyAnimationCreateResponse: type: object properties: result: type: string description: The task id of the newly created animation task. required: - result MeshyAnimationTask: type: object properties: id: type: string description: Unique identifier for the task. type: type: string description: Type of the Animation task. enum: - animate status: $ref: "#/components/schemas/MeshyTaskStatus" progress: type: integer description: Progress of the task (0-100). minimum: 0 maximum: 100 created_at: type: integer description: Timestamp of when the task was created, in milliseconds. started_at: type: integer description: Timestamp of when the task was started, in milliseconds. 0 if not started. finished_at: type: integer description: Timestamp of when the task was finished, in milliseconds. 0 if not finished. expires_at: type: integer description: Timestamp of when the task result expires, in milliseconds. task_error: $ref: "#/components/schemas/MeshyTaskError" result: $ref: "#/components/schemas/MeshyAnimationResult" preceding_tasks: type: integer description: The count of preceding tasks. Only meaningful when status is PENDING. required: - id - status MeshyAnimationResult: type: object description: Contains the output animation URLs if the task SUCCEEDED. properties: animation_glb_url: type: string description: Downloadable URL for the animation in GLB format. animation_fbx_url: type: string description: Downloadable URL for the animation in FBX format. processed_usdz_url: type: string description: Downloadable URL for the processed animation in USDZ format. processed_armature_fbx_url: type: string description: Downloadable URL for the processed armature in FBX format. processed_animation_fps_fbx_url: type: string description: Downloadable URL for the animation with changed FPS in FBX format. XAIImageGenerationRequest: type: object description: Request body for xAI Grok Imagine image generation required: - prompt properties: model: type: string description: Model to be used default: grok-imagine-image n: type: integer description: Number of images to be generated minimum: 1 maximum: 10 default: 1 prompt: type: string description: Prompt for image generation response_format: type: string description: Response format to return the image in. Can be url or b64_json. enum: - url - b64_json default: url aspect_ratio: type: string description: Aspect ratio of the generated image. Defaults to auto for automatically selecting the best ratio for the prompt. enum: - "1:1" - "3:4" - "4:3" - "9:16" - "16:9" - "2:3" - "3:2" - "9:19.5" - "19.5:9" - "9:20" - "20:9" - "1:2" - "2:1" - "auto" default: "auto" resolution: type: string description: Resolution of the generated image. Defaults to 1k. enum: - 1k - 2k default: 1k quality: type: string description: Quality of the output image. Currently a no-op, reserved for future use. enum: - low - medium - high size: type: string description: Size of the image (not supported) style: type: string description: Style of the image (not supported) user: type: string description: A unique identifier representing your end-user, which can help xAI to monitor and detect abuse XAIImageEditRequest: type: object description: Request body for xAI Grok Imagine image editing required: - prompt properties: prompt: type: string description: Prompt for image editing image: $ref: "#/components/schemas/XAIImageObject" images: type: array description: List of input images for multi-reference editing. Mutually exclusive with image. When multiple images are provided, refer to them as , , etc. in the prompt. items: $ref: "#/components/schemas/XAIImageObject" mask: $ref: "#/components/schemas/XAIImageObject" model: type: string description: Model to be used default: grok-imagine-image n: type: integer description: Number of image edits to be generated response_format: type: string description: Response format to return the image in. Can be url or b64_json. enum: - url - b64_json default: url resolution: type: string description: Resolution of the generated image. Defaults to 1k. enum: - 1k - 2k default: 1k aspect_ratio: type: string description: Aspect ratio of the output image for image editing with multiple images. For single image editing, do not set this. enum: - "1:1" - "3:4" - "4:3" - "9:16" - "16:9" - "2:3" - "3:2" - "9:19.5" - "19.5:9" - "9:20" - "20:9" - "1:2" - "2:1" - "auto" quality: type: string description: Quality of the output image. Currently a no-op, reserved for future use. enum: - low - medium - high size: type: string description: Size of the image (not supported) style: type: string description: Style of the image (not supported) user: type: string description: A unique identifier representing your end-user, which can help xAI to monitor and detect abuse XAIImageObject: type: object description: Input image object for xAI endpoints required: - url properties: url: type: string description: URL of the input image (public URL or base64-encoded data URI) type: type: string description: Type of the image input enum: - image_url XAIImageGenerationResponse: type: object description: Response from xAI image generation or editing properties: data: type: array description: A list of generated image objects items: $ref: "#/components/schemas/XAIGeneratedImage" block_reason: type: string description: If the request was blocked by input moderation, contains the block reason usage: $ref: "#/components/schemas/XAIImageUsage" XAIGeneratedImage: type: object description: A generated image from xAI properties: url: type: string description: A url to the generated image (if response_format is url) b64_json: type: string description: A base64-encoded string representation of the generated image in jpeg encoding (if response_format is b64_json) mime_type: type: string description: The MIME type of the generated image (e.g. image/png, image/jpeg, image/webp). XAIImageUsage: type: object description: Usage information for the image generation request properties: cost_in_usd_ticks: type: integer description: Accurate cost of this request in USD ticks (10,000,000,000 ticks = 1 USD) XAIVideoGenerationRequest: type: object description: Request body for xAI Grok Imagine video generation required: - prompt properties: prompt: type: string description: Prompt for video generation model: type: string description: Model to be used image: $ref: "#/components/schemas/XAIImageObject" duration: type: integer nullable: true description: Video duration in seconds. Range [1, 15]. Default 8. minimum: 1 maximum: 15 default: 8 aspect_ratio: type: string description: Aspect ratio of the generated video enum: - "1:1" - "16:9" - "9:16" - "4:3" - "3:4" - "3:2" - "2:3" default: "16:9" resolution: type: string nullable: true description: Resolution of the output video size: type: string nullable: true description: Size of the output video output: type: object nullable: true description: Optional output destination for generated video user: type: string nullable: true description: A unique identifier representing your end-user XAIVideoObject: type: object description: Input video object for xAI endpoints required: - url properties: url: type: string description: URL of the video (public URL or base64-encoded data URL). The video must have the .mp4 file extension and be encoded with .mp4 supported codecs such as H.265, H.264, AV1, etc. XAIVideoEditRequest: type: object description: Request body for xAI Grok Imagine video editing required: - prompt - video properties: prompt: type: string description: Prompt for video editing video: $ref: "#/components/schemas/XAIVideoObject" model: type: string nullable: true description: Model to be used output: type: object nullable: true description: Optional output destination for generated video user: type: string nullable: true description: A unique identifier representing your end-user XAIVideoAsyncResponse: type: object description: Response from xAI video generation or editing (async operation) properties: request_id: type: string description: Unique identifier to poll for the completed video XAIVideoResultResponse: type: object description: Response from getting video generation result properties: status: type: string description: 'Status of the deferred request: "pending" or "done"' enum: - pending - done block_reason: type: string nullable: true description: If the request was blocked by input moderation, contains the block reason model: type: string description: The model used to generate the video usage: $ref: "#/components/schemas/XAIVideoUsage" video: $ref: "#/components/schemas/XAIGeneratedVideo" XAIVideoUsage: type: object description: Usage information for the video generation request properties: cost_in_usd_ticks: type: integer description: > The cost of this request expressed in USD ticks. One USD cent equals 100,000,000 ticks, so one US dollar equals 10,000,000,000 ticks. XAIGeneratedVideo: type: object description: A generated video from xAI properties: duration: type: integer description: Duration of the generated video in seconds respect_moderation: type: boolean description: Whether the video generated by the model respects moderation rules url: type: string nullable: true description: A url to the generated video RevePostprocessingOperation: type: object description: A postprocessing operation to apply after image generation. required: - process properties: process: type: string description: "The postprocessing operation: upscale, remove_background, fit_image, or effect." enum: - upscale - remove_background - fit_image - effect upscale_factor: type: integer description: Upscale factor (2, 3, or 4). Only used when process is upscale. minimum: 2 maximum: 4 max_dim: type: integer description: Maximum dimension for fit_image. At least one of max_dim, max_width, or max_height must be set. maximum: 1024 max_width: type: integer description: Maximum width for fit_image. maximum: 1024 max_height: type: integer description: Maximum height for fit_image. maximum: 1024 effect_name: type: string description: Name of the effect to apply. Only used when process is effect. effect_parameters: type: object description: Optional parameters to override default effect settings. ReveImageCreateRequest: type: object description: Request body for Reve image creation. required: - prompt properties: prompt: type: string description: The text description of the desired image. Maximum length is 2560 characters. maxLength: 2560 aspect_ratio: type: string description: "The desired aspect ratio of the generated image." enum: - "16:9" - "9:16" - "3:2" - "2:3" - "4:3" - "3:4" - "1:1" default: "3:2" version: type: string description: "Model version to use. Supported: latest, reve-create@20250915." default: "latest" postprocessing: type: array description: Optional postprocessing operations to apply after generation. May add additional cost. items: $ref: "#/components/schemas/RevePostprocessingOperation" test_time_scaling: type: number description: If included, the model will spend more effort making better images. Values between 1 and 15 are accepted. Adds additional credits cost. minimum: 1 maximum: 15 ReveImageEditRequest: type: object description: Request body for Reve image editing. required: - edit_instruction - reference_image properties: edit_instruction: type: string description: The text description of how to edit the provided image. Maximum length is 2560 characters. maxLength: 2560 reference_image: type: string description: A base64 encoded image to use as reference for the edit. aspect_ratio: type: string description: "The desired aspect ratio. Defaults to the aspect ratio of the reference image if not provided." enum: - "16:9" - "9:16" - "3:2" - "2:3" - "4:3" - "3:4" - "1:1" version: type: string description: "Model version to use. Supported: latest-fast, latest, reve-edit-fast@20251030, reve-edit@20250915." default: "latest" postprocessing: type: array description: Optional postprocessing operations to apply after generation. May add additional cost. items: $ref: "#/components/schemas/RevePostprocessingOperation" test_time_scaling: type: number description: If included, the model will spend more effort making better images. Values between 1 and 15 are accepted. Adds additional credits cost. minimum: 1 maximum: 15 ReveImageRemixRequest: type: object description: Request body for Reve image remixing. required: - prompt - reference_images properties: prompt: type: string description: The text description of the desired image. May include xml img tags to refer to specific reference images by index. Maximum length is 2560 characters. maxLength: 2560 reference_images: type: array description: A list of 1-6 base64 encoded reference images. Each must be less than 10 MB. Total pixel count must be no more than 32 million pixels. items: type: string minItems: 1 maxItems: 6 aspect_ratio: type: string description: "The desired aspect ratio. If not provided, smartly chosen by the model." enum: - "16:9" - "9:16" - "3:2" - "2:3" - "4:3" - "3:4" - "1:1" version: type: string description: "Model version to use. Supported: latest-fast, latest, reve-remix-fast@20251030, reve-remix@20250915." default: "latest" postprocessing: type: array description: Optional postprocessing operations to apply after generation. May add additional cost. items: $ref: "#/components/schemas/RevePostprocessingOperation" test_time_scaling: type: number description: If included, the model will spend more effort making better images. Values between 1 and 15 are accepted. Adds additional credits cost. minimum: 1 maximum: 15 ReveImageResponse: type: object description: Response from the Reve image API. properties: image: type: string description: The base64 encoded image data. Empty if the request was not successful. request_id: type: string description: A unique id for the request. credits_used: type: number description: The number of credits used for this request. credits_remaining: type: number description: The number of credits remaining in your budget. version: type: string description: The specific model version used in the generation process. content_violation: type: boolean description: Indicates whether the generated image violates the content policy. BriaFiboEditRequest: type: object description: Request body for Bria FIBO Edit API required: - images properties: instruction: type: string description: Text-based edit instruction (e.g., "make the sky blue", "add a cat"). Either instruction or structured_instruction must be provided. images: type: array items: type: string description: The source image to be edited. Publicly available URL or Base64-encoded. Accepted formats JPEG, JPG, PNG, WEBP. Must contain exactly one item. minItems: 1 maxItems: 1 mask: type: string description: Optional mask image URL or Base64-encoded. Black areas will be preserved, white areas will be edited. structured_instruction: type: string description: A string containing the structured edit instruction in JSON format. Use this instead of instruction for precise, programmatic control. negative_prompt: type: string description: A text prompt specifying concepts, styles, or objects to exclude from the edited image. guidance_scale: type: number format: float description: Determines how closely the generated image should adhere to the instruction. default: 5 minimum: 3 maximum: 5 model_version: type: string description: The version of the model to use. enum: - FIBO default: FIBO steps_num: type: integer description: Number of diffusion steps. default: 50 minimum: 20 maximum: 50 seed: type: integer description: Seed for deterministic generation. If omitted, a random seed is used. ip_signal: type: boolean description: If true, returns a warning for potential IP content in the instruction. default: false prompt_content_moderation: type: boolean description: If true, returns 422 on instruction moderation failure. default: true visual_input_content_moderation: type: boolean description: If true, returns 422 on images or mask moderation failure. default: true visual_output_content_moderation: type: boolean description: If true, returns 422 on visual output moderation failure. default: true BriaStructuredInstructionRequest: type: object description: Request body for Bria Structured Instruction Generate API required: - images - instruction properties: instruction: type: string description: Required. Text-based edit instruction (e.g., "make the sky blue", "add a cat"). images: type: array items: type: string description: The source image to be edited. Publicly available URL or Base64-encoded. Must contain exactly one item. minItems: 1 maxItems: 1 mask: type: string description: Optional mask image URL or Base64-encoded. Black areas will be preserved, white areas will be edited. seed: type: integer description: Seed for deterministic generation. If omitted, a random seed is used. ip_signal: type: boolean description: If true, returns a warning for potential IP content in the instruction. default: false prompt_content_moderation: type: boolean description: If true, returns 422 on instruction moderation failure. default: true visual_input_content_moderation: type: boolean description: If true, returns 422 on images or mask moderation failure. default: true BriaAsyncResponse: type: object description: Asynchronous response from Bria API (202 Accepted) properties: request_id: type: string description: Unique identifier for the request. status_url: type: string description: URL to poll for the result. warning: type: string description: Optional warning message. BriaErrorResponse: type: object description: Error response from Bria API properties: error: type: object properties: code: type: integer description: Error code. message: type: string description: Error message. details: type: string description: Additional error details. request_id: type: string description: Unique identifier for the request. BriaStatusResponse: type: object description: Status response from Bria API properties: status: type: string description: Current status of the request. enum: - IN_PROGRESS - COMPLETED - ERROR - UNKNOWN request_id: type: string description: Unique identifier for the request. result: type: object description: Result object (only present when status is COMPLETED) properties: image_url: type: string description: URL of the generated/edited image. video_url: type: string description: URL of the generated video. seed: type: integer description: Seed used for generation. prompt: type: string description: Original prompt. refined_prompt: type: string description: Refined version of the prompt. structured_prompt: type: string description: The detailed JSON structured prompt. error: type: object description: Error object (only present when status is ERROR) properties: code: type: integer description: Error code. message: type: string description: Error message. details: type: string description: Additional error details. BriaVideoRemoveBackgroundRequest: type: object description: Request body for Bria Video Remove Background API required: - video properties: video: type: string description: Publicly accessible URL of the input video. Input resolution supported up to 16000x16000 (16K). Max duration 60 seconds. background_color: type: string description: Background color for the output video. If Transparent, the output codec must support alpha. enum: - Transparent - Black - White - Gray - Red - Green - Blue - Yellow - Cyan - Magenta - Orange output_container_and_codec: type: string description: Output container and codec preset. enum: - mp4_h264 - mp4_h265 - webm_vp9 - mov_h265 - mov_proresks - mkv_h264 - mkv_h265 - mkv_vp9 - gif preserve_audio: type: boolean description: Whether to preserve audio from the input video. BriaImageRemoveBackgroundRequest: type: object description: Request body for Bria Image Remove Background API required: - image properties: image: type: string description: The image to remove background from. Supported input types are Base64-encoded string or URL pointing to a publicly accessible image file. Accepted formats JPEG, JPG, PNG, WEBP. preserve_alpha: type: boolean description: Controls whether partially transparent areas from the input image are retained in the output after background removal. sync: type: boolean description: When false (default), the request is processed asynchronously. When true, the API holds the connection open until complete. visual_input_content_moderation: type: boolean description: When enabled, applies content moderation to input visual. Returns 422 if the image fails moderation. visual_output_content_moderation: type: boolean description: When enabled, applies content moderation to result visual. Returns 422 if the output fails moderation. BriaStatusNotFoundResponse: type: object description: Response when request_id is not found or expired properties: status: type: string enum: - NOT_FOUND required: - status WavespeedFlashVSRRequest: type: object description: Request body for WavespeedAI FlashVSR video upscaling properties: video: type: string description: | The video to upscale. Can be a URL to the video file or a base64-encoded video. target_resolution: type: string description: Target resolution to upscale to. enum: - 720p - 1080p - 2k - 4k default: 1080p duration: type: number description: | Duration of the video in seconds required: - video - duration WavespeedSeedVR2ImageRequest: type: object description: Request body for WavespeedAI SeedVR2 image upscaling properties: image: type: string description: The URL of the image to upscale. target_resolution: type: string description: The target resolution of the output image. enum: - 2k - 4k - 8k default: 4k output_format: type: string description: The format of the output image. enum: - jpeg - png - webp default: jpeg enable_base64_output: type: boolean description: If enabled, the output will be encoded into a BASE64 string instead of a URL. default: false required: - image WavespeedTaskResponse: type: object description: Response from WavespeedAI task submission properties: code: type: integer description: HTTP status code (e.g., 200 for success) message: type: string description: Status message (e.g., "success") data: type: object properties: id: type: string description: Unique identifier for the prediction/task model: type: string description: Model ID used for the prediction outputs: type: array items: type: string description: Array of URLs to the generated content (empty when status is not completed) urls: type: object properties: get: type: string description: URL to retrieve the prediction result has_nsfw_contents: type: array items: type: boolean description: Array of boolean values indicating NSFW detection for each output status: type: string description: Status of the task enum: - created - processing - completed - failed created_at: type: string description: ISO timestamp of when the request was created error: type: string description: Error message (empty if no error occurred) timings: type: object properties: inference: type: integer description: Inference time in milliseconds WavespeedTaskResultResponse: type: object description: Response from WavespeedAI task result query properties: code: type: integer description: HTTP status code (e.g., 200 for success) message: type: string description: Status message (e.g., "success") data: type: object properties: id: type: string description: Unique identifier for the prediction/task model: type: string description: Model ID used for the prediction outputs: type: array items: type: string description: Array of URLs to the generated content (empty when status is not completed) urls: type: object properties: get: type: string description: URL to retrieve the prediction result status: type: string description: Status of the task enum: - created - processing - completed - failed created_at: type: string description: ISO timestamp of when the request was created error: type: string description: Error message (empty if no error occurred) timings: type: object properties: inference: type: integer description: Inference time in milliseconds parameters: PixverseAiTraceId: name: Ai-trace-id in: header required: true schema: type: string description: Unique UUID for each request. securitySchemes: BearerAuth: type: http scheme: bearer bearerFormat: JWT ================================================ FILE: comfy_cli/command/generate/spec.py ================================================ """Load the bundled openapi.yml and expose the curated image-endpoint registry. Lookup order on disk: 1. ``~/.comfy/openapi-cache.yml`` if fresher than CACHE_TTL_DAYS 2. The vendored copy under ``comfy_cli/command/generate/spec/openapi.yml`` The parsed spec is cached in-process via functools.lru_cache so repeated lookups inside a single CLI invocation don't re-parse the 30k-line YAML. """ from __future__ import annotations import os import re as _re import time from dataclasses import dataclass from functools import lru_cache from pathlib import Path from typing import Any import yaml class _YamlLoader(yaml.SafeLoader): """SafeLoader that strips YAML 1.1's bool aliases for ``on``/``off``/ ``yes``/``no``/``y``/``n``. The vendored openapi uses unquoted ``[on, off]`` and similar as **string** enum values (e.g. Kling's ``sound`` field), but PyYAML's default resolvers promote them to ``True``/``False`` — which then breaks our flag-rendering (`'|'.join` on a list with booleans) and the upstream API contract. Limit bool resolution to the YAML 1.2 spelling (``true``/``false`` only).""" _YamlLoader.yaml_implicit_resolvers = { k: [(t, r) for (t, r) in resolvers if t != "tag:yaml.org,2002:bool"] for k, resolvers in yaml.SafeLoader.yaml_implicit_resolvers.items() } _YamlLoader.add_implicit_resolver( "tag:yaml.org,2002:bool", _re.compile(r"^(?:true|True|TRUE|false|False|FALSE)$"), list("tTfF"), ) PROXY_PREFIX = "/proxy/" DEFAULT_BASE_URL = "https://api.comfy.org" CACHE_TTL_SECONDS = 7 * 24 * 60 * 60 _BUNDLED_SPEC = Path(__file__).parent / "spec" / "openapi.yml" _USER_CACHE = Path(os.path.expanduser("~/.comfy/openapi-cache.yml")) @dataclass(frozen=True) class Endpoint: """A single curated cloud API endpoint, resolved against the openapi spec.""" id: str # path with /proxy/ stripped, e.g. "openai/images/generations" path: str # full openapi path, e.g. "/proxy/openai/images/generations" method: str # "post" / "get" partner: str # first path segment under /proxy/ summary: str category: str # "text-to-image", "image-edit", "upscale", "inpaint", ... request_schema: dict[str, Any] # resolved (no $ref) request body schema request_content_type: str # "application/json" | "multipart/form-data" response_schema: dict[str, Any] # resolved 200 response schema polling: str | None # "bfl" | "kling" | "luma" | "topaz" | None # Short, creative-facing aliases mapping to the curated openapi paths below. # Aliases are what end users actually type: `comfy generate flux-pro --prompt …`. # The full openapi path remains accepted as a power-user escape hatch. _ALIASES: dict[str, str] = { # Flux / BFL "flux-pro": "bfl/flux-pro-1.1/generate", "flux-ultra": "bfl/flux-pro-1.1-ultra/generate", "flux-2": "bfl/flux-2-pro/generate", "flux-kontext": "bfl/flux-kontext-pro/generate", "flux-kontext-max": "bfl/flux-kontext-max/generate", "flux-fill": "bfl/flux-pro-1.0-fill/generate", "flux-expand": "bfl/flux-pro-1.0-expand/generate", "flux-canny": "bfl/flux-pro-1.0-canny/generate", "flux-depth": "bfl/flux-pro-1.0-depth/generate", # Ideogram "ideogram": "ideogram/ideogram-v3/generate", "ideogram-edit": "ideogram/ideogram-v3/edit", "ideogram-remix": "ideogram/ideogram-v3/remix", "ideogram-reframe": "ideogram/ideogram-v3/reframe", "ideogram-bg": "ideogram/ideogram-v3/replace-background", # Stability "stability-ultra": "stability/v2beta/stable-image/generate/ultra", "stability-sd3": "stability/v2beta/stable-image/generate/sd3", "stability-upscale": "stability/v2beta/stable-image/upscale/conservative", "stability-upscale-creative": "stability/v2beta/stable-image/upscale/creative", "stability-upscale-fast": "stability/v2beta/stable-image/upscale/fast", # Recraft "recraft": "recraft/image_generation", "recraft-vectorize": "recraft/images/vectorize", "recraft-upscale": "recraft/images/crispUpscale", "recraft-upscale-creative": "recraft/images/creativeUpscale", "recraft-rmbg": "recraft/images/removeBackground", "recraft-replace-bg": "recraft/images/replaceBackground", "recraft-i2i": "recraft/images/imageToImage", "recraft-inpaint": "recraft/images/inpaint", # OpenAI / DALL·E "dalle": "openai/images/generations", "dalle-edit": "openai/images/edits", # xAI / Grok "grok": "xai/v1/images/generations", "grok-edit": "xai/v1/images/edits", # Reve "reve": "reve/v1/image/create", "reve-edit": "reve/v1/image/edit", # Runway "runway": "runway/text_to_image", # Video — Kling "kling": "kling/v1/videos/text2video", "kling-i2v": "kling/v1/videos/image2video", "kling-extend": "kling/v1/videos/video-extend", "kling-lipsync": "kling/v1/videos/lip-sync", # Video — Luma Dream Machine "luma": "luma/generations", "luma-i2v": "luma/generations/image", # Video — MiniMax / Hailuo "hailuo": "minimax/video_generation", # Video — Runway Gen-3 "runway-i2v": "runway/image_to_video", # Video — Moonvalley "moonvalley-t2v": "moonvalley/prompts/text-to-video", "moonvalley-i2v": "moonvalley/prompts/image-to-video", # Video — Pika "pika": "pika/generate/2.2/t2v", "pika-i2v": "pika/generate/2.2/i2v", # Video — Vidu "vidu": "vidu/text2video", "vidu-i2v": "vidu/img2video", "vidu-extend": "vidu/extend", # Video — xAI Grok "grok-video": "xai/v1/videos/generations", # Google Gemini Flash Image (nano-banana). The model variant lives in the # URL path; the adapter substitutes ``--model`` at send time. "nano-banana": "vertexai/gemini/{model}", # ByteDance Seedance (video). "seedance": "byteplus/api/v3/contents/generations/tasks", } # Used in the `list` table for endpoints whose openapi summary is empty or too # generic to convey what the model is. _SUMMARY_OVERRIDES: dict[str, str] = { "vertexai/gemini/{model}": ( "Google Gemini Flash Image (nano-banana) — text-to-image and image edits " "from a prompt plus optional reference images." ), "byteplus/api/v3/contents/generations/tasks": ( "ByteDance Seedance — text-to-video and image-to-video (3–12s clips, up to 1080p)." ), } _PREFERRED_ALIAS: dict[str, str] = {v: k for k, v in _ALIASES.items()} def aliases() -> dict[str, str]: """Return a copy of the alias → endpoint-id map (used for `list`).""" return dict(_ALIASES) def preferred_alias(endpoint_id: str) -> str | None: """Return the short alias for an endpoint id, if any.""" return _PREFERRED_ALIAS.get(endpoint_id) def resolve_alias(target: str) -> str: """Map a user-typed model name to the canonical endpoint id. Accepts an alias, an endpoint id, or the full /proxy/... path.""" if target in _ALIASES: return _ALIASES[target] if target.startswith(PROXY_PREFIX): return target[len(PROXY_PREFIX) :] return target # Curated endpoint allowlist. Tuples of (endpoint_id, category, polling). # ``polling`` is the partner-key the poll registry uses (None = sync). # Endpoint id is the openapi path with /proxy/ stripped. _ENDPOINT_ALLOWLIST: list[tuple[str, str, str | None]] = [ # OpenAI ("openai/images/generations", "text-to-image", None), ("openai/images/edits", "image-edit", None), # BFL / Flux — all async via polling_url ("bfl/flux-pro-1.1/generate", "text-to-image", "bfl"), ("bfl/flux-pro-1.1-ultra/generate", "text-to-image", "bfl"), ("bfl/flux-kontext-pro/generate", "image-edit", "bfl"), ("bfl/flux-kontext-max/generate", "image-edit", "bfl"), ("bfl/flux-2-pro/generate", "text-to-image", "bfl"), ("bfl/flux-pro-1.0-fill/generate", "inpaint", "bfl"), ("bfl/flux-pro-1.0-expand/generate", "outpaint", "bfl"), ("bfl/flux-pro-1.0-canny/generate", "controlnet", "bfl"), ("bfl/flux-pro-1.0-depth/generate", "controlnet", "bfl"), # Ideogram ("ideogram/ideogram-v3/generate", "text-to-image", None), ("ideogram/ideogram-v3/edit", "image-edit", None), ("ideogram/ideogram-v3/remix", "image-edit", None), ("ideogram/ideogram-v3/reframe", "image-edit", None), ("ideogram/ideogram-v3/replace-background", "image-edit", None), # Stability ("stability/v2beta/stable-image/generate/ultra", "text-to-image", None), ("stability/v2beta/stable-image/generate/sd3", "text-to-image", None), ("stability/v2beta/stable-image/upscale/conservative", "upscale", None), ("stability/v2beta/stable-image/upscale/creative", "upscale", None), ("stability/v2beta/stable-image/upscale/fast", "upscale", None), # Recraft ("recraft/image_generation", "text-to-image", None), ("recraft/images/vectorize", "vectorize", None), ("recraft/images/crispUpscale", "upscale", None), ("recraft/images/removeBackground", "background", None), ("recraft/images/imageToImage", "image-to-image", None), ("recraft/images/inpaint", "inpaint", None), ("recraft/images/replaceBackground", "background", None), ("recraft/images/creativeUpscale", "upscale", None), # xAI ("xai/v1/images/generations", "text-to-image", None), ("xai/v1/images/edits", "image-edit", None), # Reve ("reve/v1/image/create", "text-to-image", None), ("reve/v1/image/edit", "image-edit", None), # Runway (image) ("runway/text_to_image", "text-to-image", None), # Video — Kling ("kling/v1/videos/text2video", "text-to-video", "kling"), ("kling/v1/videos/image2video", "image-to-video", "kling"), ("kling/v1/videos/video-extend", "video-extend", "kling"), ("kling/v1/videos/lip-sync", "lipsync", "kling"), # Video — Luma ("luma/generations", "text-to-video", "luma"), ("luma/generations/image", "image-to-video", "luma"), # Video — MiniMax / Hailuo ("minimax/video_generation", "text-to-video", "minimax"), # Video — Runway ("runway/image_to_video", "image-to-video", "runway"), # Video — Moonvalley ("moonvalley/prompts/text-to-video", "text-to-video", "moonvalley"), ("moonvalley/prompts/image-to-video", "image-to-video", "moonvalley"), # Video — Pika ("pika/generate/2.2/t2v", "text-to-video", "pika"), ("pika/generate/2.2/i2v", "image-to-video", "pika"), # Video — Vidu ("vidu/text2video", "text-to-video", "vidu"), ("vidu/img2video", "image-to-video", "vidu"), ("vidu/extend", "video-extend", "vidu"), # Video — xAI Grok ("xai/v1/videos/generations", "text-to-video", "xai_video"), # Google Gemini Flash Image (nano-banana). Sync; adapter decodes inline data. ("vertexai/gemini/{model}", "image-edit", None), # ByteDance Seedance (video) — async, custom poller. ("byteplus/api/v3/contents/generations/tasks", "text-to-video", "seedance"), ] class SpecError(RuntimeError): pass def _select_spec_path() -> Path: if _USER_CACHE.is_file(): age = time.time() - _USER_CACHE.stat().st_mtime if age < CACHE_TTL_SECONDS: return _USER_CACHE if not _BUNDLED_SPEC.is_file(): raise SpecError(f"openapi.yml not found at {_BUNDLED_SPEC}") return _BUNDLED_SPEC @lru_cache(maxsize=1) def load_raw_spec() -> dict[str, Any]: path = _select_spec_path() with path.open("r", encoding="utf-8") as f: return yaml.load(f, Loader=_YamlLoader) def base_url() -> str: override = os.environ.get("COMFY_API_BASE_URL") if override: return override.rstrip("/") spec = load_raw_spec() servers = spec.get("servers") or [{"url": DEFAULT_BASE_URL}] return str(servers[0]["url"]).rstrip("/") def _resolve_ref(spec: dict[str, Any], ref: str) -> dict[str, Any]: if not ref.startswith("#/"): raise SpecError(f"Only local $refs are supported: {ref}") parts = ref[2:].split("/") node: Any = spec for p in parts: node = node[p] return node def _resolve(spec: dict[str, Any], node: Any, seen: frozenset[str] = frozenset()) -> Any: """Recursively inline $refs in a schema. Cycles are broken with a placeholder.""" if isinstance(node, dict): if "$ref" in node: ref = node["$ref"] if ref in seen: return {"type": "object", "x-recursive-ref": ref} resolved = _resolve_ref(spec, ref) return _resolve(spec, resolved, seen | {ref}) return {k: _resolve(spec, v, seen) for k, v in node.items()} if isinstance(node, list): return [_resolve(spec, item, seen) for item in node] return node def _detect_polling(partner: str, response_schema: dict[str, Any]) -> str | None: """Heuristic: classify async polling style by partner + response shape.""" props = response_schema.get("properties", {}) if isinstance(response_schema, dict) else {} if partner == "bfl" and "polling_url" in props: return "bfl" if partner == "kling" and "data" in props: return "kling" if partner == "luma" and ("state" in props or "id" in props): return "luma" if partner == "topaz" and "process_id" in props: return "topaz" return None @lru_cache(maxsize=1) def _registry() -> dict[str, Endpoint]: spec = load_raw_spec() paths = spec.get("paths") or {} registry: dict[str, Endpoint] = {} for endpoint_id, category, polling_hint in _ENDPOINT_ALLOWLIST: path = PROXY_PREFIX + endpoint_id node = paths.get(path) if not node: continue # spec drift — skip silently, surfaced via `comfy api models` # All image endpoints are POST; pick the first defined method anyway. method = "post" if "post" in node else next(iter(node.keys())) op = node[method] partner = endpoint_id.split("/", 1)[0] req_body = op.get("requestBody") or {} content = req_body.get("content") or {} if "application/json" in content: ctype = "application/json" elif "multipart/form-data" in content: ctype = "multipart/form-data" else: ctype = next(iter(content.keys()), "application/json") req_schema = _resolve(spec, (content.get(ctype) or {}).get("schema") or {}) # 200 response resp = (op.get("responses") or {}).get("200") or {} resp_content = resp.get("content") or {} resp_ctype = "application/json" if "application/json" in resp_content else next(iter(resp_content), "") resp_schema = _resolve(spec, (resp_content.get(resp_ctype) or {}).get("schema") or {}) if resp_ctype else {} polling = polling_hint or _detect_polling(partner, resp_schema) registry[endpoint_id] = Endpoint( id=endpoint_id, path=path, method=method, partner=partner, summary=_SUMMARY_OVERRIDES.get(endpoint_id) or str(op.get("summary") or op.get("description") or "").strip(), category=category, request_schema=req_schema if isinstance(req_schema, dict) else {}, request_content_type=ctype, response_schema=resp_schema if isinstance(resp_schema, dict) else {}, polling=polling, ) return registry def list_endpoints( partner: str | None = None, category: str | None = None, query: str | None = None, ) -> list[Endpoint]: out = list(_registry().values()) if partner: out = [e for e in out if e.partner == partner.lower()] if category: out = [e for e in out if e.category == category] if query: q = query.lower() out = [e for e in out if q in e.id.lower() or q in e.summary.lower()] out.sort(key=lambda e: (e.partner, e.id)) return out def get_endpoint(endpoint_id: str) -> Endpoint: reg = _registry() canonical = resolve_alias(endpoint_id) if canonical in reg: return reg[canonical] raise SpecError(_unknown_endpoint_message(endpoint_id)) def _unknown_endpoint_message(endpoint_id: str) -> str: """Build a helpful error suggesting close matches.""" import difflib candidates = list(_registry().keys()) + list(_ALIASES.keys()) close = difflib.get_close_matches(endpoint_id, candidates, n=3, cutoff=0.5) msg = f"Unknown model: {endpoint_id!r}." if close: msg += "\nDid you mean: " + ", ".join(close) + "?" msg += "\nRun `comfy generate list` to see available models." return msg def write_cache(yaml_text: str) -> Path: """Write `yaml_text` to the user cache, ensuring the parent dir exists.""" _USER_CACHE.parent.mkdir(parents=True, exist_ok=True) _USER_CACHE.write_text(yaml_text, encoding="utf-8") # Invalidate in-process cache so the next load picks it up. load_raw_spec.cache_clear() _registry.cache_clear() return _USER_CACHE def active_spec_path() -> Path: return _select_spec_path() ================================================ FILE: comfy_cli/command/generate/upload.py ================================================ """Upload reference assets via ``/customers/storage``. The cloud endpoint issues short-lived signed URLs: 1. POST ``/customers/storage`` with ``{file_name, content_type, file_hash?}`` → ``{upload_url, download_url, expires_at, existing_file}``. 2. If ``existing_file`` is true the server already has a hash-match — skip the PUT and reuse ``download_url``. Otherwise PUT the bytes to ``upload_url`` with the same ``Content-Type`` header. 3. ``download_url`` is what downstream model calls reference; it's a signed URL that expires after 24 hours. This module also exposes a small helper that takes either a local path or a remote ``http(s)://`` URL — remote URLs are re-hosted by downloading and then running the same flow. """ from __future__ import annotations import hashlib import mimetypes from dataclasses import dataclass from pathlib import Path import httpx from comfy_cli.command.generate import client, spec _DEFAULT_CONTENT_TYPE = "application/octet-stream" @dataclass(frozen=True) class UploadResult: url: str # the signed download_url to feed into downstream model calls expires_at: str | None # ISO 8601 timestamp from the server existing_file: bool # True when the server returned a hash-match (no upload) def _guess_content_type(name: str) -> str: ctype, _ = mimetypes.guess_type(name) return ctype or _DEFAULT_CONTENT_TYPE def _sha256_hex(data: bytes) -> str: return hashlib.sha256(data).hexdigest() def _request_signed_url( file_name: str, content_type: str, file_hash: str, api_key: str, ) -> dict: """POST /customers/storage and return the parsed response dict.""" url = spec.base_url() + "/customers/storage" body = {"file_name": file_name, "content_type": content_type, "file_hash": file_hash} headers = client._auth_headers(api_key, {"Content-Type": "application/json"}) resp = httpx.post(url, json=body, headers=headers, timeout=30.0) client.raise_for_status(resp) try: return resp.json() except ValueError as e: # Surface the same way other parse errors do — bare ValueError would # leak a traceback into CLI output. raise client.ApiError(resp.status_code, resp.text or str(e), "Storage response was not valid JSON") from e def _put_bytes(upload_url: str, data: bytes, content_type: str) -> None: """PUT the raw bytes to the signed URL. No auth header — the URL is signed.""" with httpx.Client(timeout=120.0, follow_redirects=False) as c: r = c.put(upload_url, content=data, headers={"Content-Type": content_type}) if r.status_code >= 400: raise client.ApiError(r.status_code, r.text, f"Upload to signed URL failed (HTTP {r.status_code})") def upload_bytes(data: bytes, file_name: str, api_key: str, content_type: str | None = None) -> UploadResult: """Upload raw bytes and return the signed download URL. Hash-based dedup is handled transparently — if the server already has these bytes, ``existing_file`` is True and no PUT happens.""" ctype = content_type or _guess_content_type(file_name) file_hash = _sha256_hex(data) signed = _request_signed_url(file_name=file_name, content_type=ctype, file_hash=file_hash, api_key=api_key) if not signed.get("existing_file"): upload_url = signed.get("upload_url") if not upload_url: raise client.ApiError(0, str(signed), "Server response missing upload_url") _put_bytes(upload_url, data, ctype) download_url = signed.get("download_url") if not download_url: raise client.ApiError(0, str(signed), "Server response missing download_url") return UploadResult( url=download_url, expires_at=signed.get("expires_at"), existing_file=bool(signed.get("existing_file", False)), ) def upload_path(path: Path | str, api_key: str) -> UploadResult: p = Path(path).expanduser() if not p.is_file(): raise client.ApiError(0, "", f"File not found: {p}") try: data = p.read_bytes() except OSError as e: raise client.ApiError(0, "", f"Unable to read file: {p} ({e})") from e return upload_bytes(data, file_name=p.name, api_key=api_key) def upload_remote_url(url: str, api_key: str) -> UploadResult: """Re-host a remote http(s) URL through /customers/storage so it ends up on Comfy's CDN. Mirrors the genmedia behavior of accepting URLs to `upload`.""" with httpx.Client(timeout=60.0, follow_redirects=True) as c: r = c.get(url) r.raise_for_status() data = r.content # Prefer the server's Content-Type; fall back to URL extension. ctype = r.headers.get("content-type", "").split(";", 1)[0].strip() or _guess_content_type(url) # Pick a filename from the URL path, defaulting to a hash-based name. suffix = Path(url.split("?", 1)[0]).name or _sha256_hex(data)[:12] return upload_bytes(data, file_name=suffix, api_key=api_key, content_type=ctype) def upload_target(target: str | Path, api_key: str) -> UploadResult: """Accept either a local file path or a remote URL; return the hosted URL.""" s = str(target) if s.startswith(("http://", "https://")): return upload_remote_url(s, api_key=api_key) return upload_path(s, api_key=api_key) ================================================ FILE: comfy_cli/command/github/pr_info.py ================================================ from typing import NamedTuple class PRInfo(NamedTuple): number: int head_repo_url: str head_branch: str base_repo_url: str base_branch: str title: str user: str mergeable: bool @property def is_fork(self) -> bool: return self.head_repo_url != self.base_repo_url ================================================ FILE: comfy_cli/command/install.py ================================================ import os import platform import re import subprocess import sys from typing import TypedDict from urllib.parse import urlparse import git import requests import semver import typer from rich import print as rprint from rich.console import Console from rich.panel import Panel from rich.prompt import Confirm from comfy_cli import constants, ui from comfy_cli.command.custom_nodes.command import update_node_id_cache from comfy_cli.command.github.pr_info import PRInfo from comfy_cli.constants import GPU_OPTION from comfy_cli.cuda_detect import DEFAULT_CUDA_TAG from comfy_cli.git_utils import checkout_pr, git_checkout_tag from comfy_cli.resolve_python import ensure_workspace_python from comfy_cli.uv import DependencyCompiler from comfy_cli.workspace_manager import WorkspaceManager, check_comfy_repo workspace_manager = WorkspaceManager() console = Console() def get_os_details(): os_name = platform.system() # e.g., Linux, Darwin (macOS), Windows os_version = platform.release() return os_name, os_version def _pip_install_torch(python: str, index_args: list[str]) -> subprocess.CompletedProcess: """Install torch, torchvision, and torchaudio with the given index arguments.""" return subprocess.run( [python, "-m", "pip", "install", "torch", "torchvision", "torchaudio"] + index_args, check=False, ) def pip_install_comfyui_dependencies( repo_dir, gpu: GPU_OPTION, plat: constants.OS, cuda_version: constants.CUDAVersion | None, skip_torch_or_directml: bool, skip_requirement: bool, python: str = sys.executable, rocm_version: constants.ROCmVersion = constants.ROCmVersion.v6_3, cuda_tag: str | None = None, ): os.chdir(repo_dir) result = None if not skip_torch_or_directml: # install torch for AMD Linux if gpu == GPU_OPTION.AMD and plat == constants.OS.LINUX: result = _pip_install_torch( python, ["--index-url", f"https://download.pytorch.org/whl/rocm{rocm_version.value}"] ) # install torch for NVIDIA if gpu == GPU_OPTION.NVIDIA: if cuda_tag is None: cuda_tag = f"cu{cuda_version.value.replace('.', '')}" if cuda_version else DEFAULT_CUDA_TAG result = _pip_install_torch(python, ["--index-url", f"https://download.pytorch.org/whl/{cuda_tag}"]) # install torch for Intel Arc GPUs (upstream torch xpu) # https://github.com/comfyanonymous/ComfyUI/pull/7767 if gpu == GPU_OPTION.INTEL_ARC: result = _pip_install_torch(python, ["--extra-index-url", "https://download.pytorch.org/whl/xpu"]) # install torch for CPU if gpu is None: result = _pip_install_torch(python, ["--extra-index-url", "https://download.pytorch.org/whl/cpu"]) if result and result.returncode != 0: rprint("Failed to install PyTorch dependencies. Please check your environment (`comfy env`) and try again") sys.exit(1) # install directml for AMD windows if gpu == GPU_OPTION.AMD and plat == constants.OS.WINDOWS: subprocess.run([python, "-m", "pip", "install", "torch-directml"], check=True) # install torch for Mac M Series if gpu == GPU_OPTION.MAC_M_SERIES: subprocess.run( [ python, "-m", "pip", "install", "--pre", "torch", "torchvision", "torchaudio", "--extra-index-url", "https://download.pytorch.org/whl/nightly/cpu", ], check=True, ) # install requirements.txt if skip_requirement: return result = subprocess.run([python, "-m", "pip", "install", "-r", "requirements.txt"], check=False) if result.returncode != 0: rprint("Failed to install ComfyUI dependencies. Please check your environment (`comfy env`) and try again.") sys.exit(1) def pip_install_manager(repo_dir, python=sys.executable): """Install ComfyUI-Manager via manager_requirements.txt.""" from comfy_cli.command.custom_nodes.cm_cli_util import find_cm_cli manager_req_path = os.path.join(repo_dir, constants.MANAGER_REQUIREMENTS_FILE) if not os.path.exists(manager_req_path): rprint( f"[bold yellow]Warning: {constants.MANAGER_REQUIREMENTS_FILE} not found. " "Skipping manager installation (older ComfyUI version?).[/bold yellow]" ) return False result = subprocess.run( [python, "-m", "pip", "install", "-r", constants.MANAGER_REQUIREMENTS_FILE], cwd=repo_dir, check=False, capture_output=True, text=True, ) if result.returncode != 0: rprint("[bold red]Failed to install ComfyUI-Manager.[/bold red]") if result.stderr: rprint(f"[dim]{result.stderr.strip()}[/dim]") return False # Clear cache so find_cm_cli() picks up the newly installed module find_cm_cli.cache_clear() return True def execute( url: str, comfy_path: str, restore: bool, skip_manager: bool, version: str, commit: str | None = None, gpu: constants.GPU_OPTION = None, cuda_version: constants.CUDAVersion | None = None, cuda_tag: str | None = None, rocm_version: constants.ROCmVersion = constants.ROCmVersion.v6_3, plat: constants.OS = None, skip_torch_or_directml: bool = False, skip_requirement: bool = False, fast_deps: bool = False, pr: str | None = None, *args, **kwargs, ): # Install ComfyUI from a given PR reference. if pr: url = handle_pr_checkout(pr, comfy_path) version = "nightly" """ Install ComfyUI from a given URL. """ if not workspace_manager.skip_prompting: res = ui.prompt_confirm_action(f"Install from {url} to {comfy_path}?", True) if not res: rprint("Aborting...") raise typer.Exit(code=1) rprint(f"Installing from repository [bold yellow]'{url}'[/bold yellow] to '{comfy_path}'") repo_dir = comfy_path parent_path = os.path.abspath(os.path.join(repo_dir, "..")) if not os.path.exists(parent_path): os.makedirs(parent_path, exist_ok=True) if not os.path.exists(repo_dir): clone_comfyui(url=url, repo_dir=repo_dir) if version != "nightly": try: checkout_stable_comfyui(version=version, repo_dir=repo_dir, url=url) except GitHubRateLimitError as e: rprint(f"[bold red]Error checking out ComfyUI version: {e}[/bold red]") sys.exit(1) elif not check_comfy_repo(repo_dir)[0]: # Get actual remote URL for better error message try: repo = git.Repo(repo_dir) remote_urls = [r.url for r in repo.remotes] rprint( f"[bold red]'{repo_dir}' exists but its remote URL is not a recognized ComfyUI repository.[/bold red]" ) if remote_urls: rprint(f"[yellow]Found remotes: {', '.join(remote_urls)}[/yellow]") rprint("[yellow]Recognized sources: Comfy-Org, comfyanonymous, drip-art, ltdrdata[/yellow]") except git.InvalidGitRepositoryError: rprint(f"[bold red]'{repo_dir}' exists but is not a valid git repository.[/bold red]") except Exception: rprint( f"[bold red]'{repo_dir}' already exists. But it is an invalid ComfyUI repository. Remove it and retry.[/bold red]" ) sys.exit(-1) # checkout specified commit if commit is not None: os.chdir(repo_dir) subprocess.run(["git", "checkout", commit], check=True) python = ensure_workspace_python(repo_dir) rprint(f"Using Python: [bold]{python}[/bold]") if not fast_deps: pip_install_comfyui_dependencies( repo_dir, gpu, plat, cuda_version, skip_torch_or_directml, skip_requirement, python=python, rocm_version=rocm_version, cuda_tag=cuda_tag, ) WorkspaceManager().set_recent_workspace(repo_dir) workspace_manager.setup_workspace_manager(specified_workspace=repo_dir) rprint("") # install ComfyUI-Manager if skip_manager: rprint("Skipping installation of ComfyUI-Manager. (by --skip-manager)") # Save to config so launch doesn't inject --enable-manager from comfy_cli.config_manager import ConfigManager ConfigManager().set(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable") else: rprint("\nInstalling ComfyUI-Manager..") if not fast_deps: if not pip_install_manager(repo_dir, python=python): # Manager installation failed - disable to prevent launch issues from comfy_cli.config_manager import ConfigManager ConfigManager().set(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable") rprint("[yellow]Manager not installed. Launch will run without manager flags.[/yellow]") if fast_deps: if python != sys.executable: # Workspace venv needs uv bootstrapped; for the global Python # uv is already available as a comfy-cli dependency. DependencyCompiler.Install_Build_Deps(executable=python) if cuda_tag: # DependencyCompiler expects a dotted version like "13.0", not a tag like "cu130" digits = cuda_tag[2:] resolved_cuda = f"{digits[:2]}.{digits[2:]}" elif cuda_version: resolved_cuda = cuda_version.value else: resolved_cuda = None depComp = DependencyCompiler( cwd=repo_dir, executable=python, gpu=gpu, cuda_version=resolved_cuda, rocm_version=rocm_version.value, skip_torch=skip_torch_or_directml, ) depComp.compile_deps() depComp.install_deps() # Install manager separately (not included in DependencyCompiler) if not skip_manager: if not pip_install_manager(repo_dir, python=python): from comfy_cli.config_manager import ConfigManager ConfigManager().set(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable") rprint("[yellow]Manager not installed. Launch will run without manager flags.[/yellow]") if not skip_manager: try: update_node_id_cache() except (FileNotFoundError, subprocess.CalledProcessError) as e: rprint(f"Failed to update node id cache: {e}") os.chdir(repo_dir) rprint("") def handle_pr_checkout(pr_ref: str, comfy_path: str) -> str: try: repo_owner, repo_name, pr_number = parse_pr_reference(pr_ref) except ValueError as e: rprint(f"[bold red]Error parsing PR reference: {e}[/bold red]") raise typer.Exit(code=1) try: if pr_number: pr_info = fetch_pr_info(repo_owner, repo_name, pr_number) else: username, branch = pr_ref.split(":", 1) pr_info = find_pr_by_branch("comfyanonymous", "ComfyUI", username, branch) if not pr_info: rprint(f"[bold red]PR not found: {pr_ref}[/bold red]") raise typer.Exit(code=1) except Exception as e: rprint(f"[bold red]Error fetching PR information: {e}[/bold red]") raise typer.Exit(code=1) console.print( Panel( f"[bold]PR #{pr_info.number}[/bold]: {pr_info.title}\n" f"[yellow]Author[/yellow]: {pr_info.user}\n" f"[yellow]Branch[/yellow]: {pr_info.head_branch}\n" f"[yellow]Source[/yellow]: {pr_info.head_repo_url}\n" f"[yellow]Mergeable[/yellow]: {'✓' if pr_info.mergeable else '✗'}", title="[bold blue]Pull Request Information[/bold blue]", border_style="blue", ) ) if not workspace_manager.skip_prompting: if not ui.prompt_confirm_action(f"Install ComfyUI from PR #{pr_info.number}?", True): rprint("Aborting...") raise typer.Exit(code=1) parent_path = os.path.abspath(os.path.join(comfy_path, "..")) if not os.path.exists(parent_path): os.makedirs(parent_path, exist_ok=True) if not os.path.exists(comfy_path): rprint(f"Cloning base repository to {comfy_path}...") clone_comfyui(url=pr_info.base_repo_url, repo_dir=comfy_path) rprint(f"Checking out PR #{pr_info.number}: {pr_info.title}") success = checkout_pr(comfy_path, pr_info) if not success: rprint("[bold red]Failed to checkout PR[/bold red]") raise typer.Exit(code=1) rprint(f"[bold green]✓ Successfully checked out PR #{pr_info.number}[/bold green]") rprint(f"[bold yellow]Note:[/bold yellow] You are now on branch pr-{pr_info.number}") return pr_info.base_repo_url def validate_version(version: str) -> str | None: """ Validates the version string as 'latest', 'nightly', or a semantically version number. Args: version (str): The version string to validate. Returns: Optional[str]: The validated version string, or None if invalid. Raises: ValueError: If the version string is invalid. """ if version.lower() in ["nightly", "latest"]: return version.lower() # Remove 'v' prefix if present if version.startswith("v"): version = version[1:] try: semver.VersionInfo.parse(version) return version except ValueError as exc: raise ValueError( f"Invalid version format: {version}. " "Please use 'nightly', 'latest', or a valid semantic version (e.g., '1.2.3')." ) from exc class GitHubRateLimitError(Exception): """Raised when GitHub API rate limit is exceeded""" def handle_github_rate_limit(response): # Check rate limit headers remaining = int(response.headers.get("x-ratelimit-remaining", 0)) if remaining == 0: reset_time = int(response.headers.get("x-ratelimit-reset", 0)) message = f"Primary rate limit from Github exceeded! Please retry after: {reset_time}" raise GitHubRateLimitError(message) if "retry-after" in response.headers: wait_seconds = int(response.headers["retry-after"]) message = f"Rate limit from Github exceeded! Please wait {wait_seconds} seconds before retrying." rprint(f"[yellow]{message}[/yellow]") raise GitHubRateLimitError(message) class GithubRelease(TypedDict): """ A dictionary representing a GitHub release. Fields: - version: The version number of the release. (Removed the v prefix) - tag: The tag name of the release. - download_url: The URL to download the release. """ version: semver.VersionInfo | None tag: str download_url: str def clone_comfyui(url: str, repo_dir: str): """ Clone the ComfyUI repository from the specified URL. """ if "@" in url: # clone specific branch url, branch = url.rsplit("@", 1) subprocess.run(["git", "clone", "-b", branch, url, repo_dir], check=True) else: subprocess.run(["git", "clone", url, repo_dir], check=True) def _resolve_latest_tag_from_local(repo_dir: str) -> tuple[str | None, bool]: """Pick the highest stable semver tag from the local clone. Returns ``(tag, fetch_ok)``: - ``tag``: the tag string (e.g. ``"v0.20.1"``), or ``None`` when no stable semver tag is available (or the directory isn't a git repo). - ``fetch_ok``: whether ``git fetch --tags`` succeeded. Callers can use this to distinguish "no new releases" from "couldn't reach the remote", which changes the right messaging when falling back to the API. Pre-release tags (e.g. ``v1.2.3-rc1``) are skipped to mirror GitHub's ``releases/latest`` behavior. Note that this picks the highest semver tag, which may differ from the release a maintainer has manually marked as "Latest" on GitHub — acceptable trade-off given the unauthenticated API's 60 req/hr per-IP cap; users can pin a specific version with ``--version`` if needed. ``git_checkout_tag`` skips its own ``git fetch --tags`` when the resolved tag is already present locally, so on the happy path we fetch exactly once here. Crucially, that also lets the cached-tag offline path succeed: if fetch above fails (``fetch_ok=False``) but a tag is found from disk, ``git_checkout_tag`` will not retry the unreachable fetch. """ fetch_ok = False try: completed = subprocess.run( ["git", "-C", repo_dir, "fetch", "--tags", "--quiet"], capture_output=True, text=True, timeout=30, ) fetch_ok = completed.returncode == 0 except (subprocess.SubprocessError, FileNotFoundError, OSError): # Tolerate timeout / OS-level failure; fall through with whatever's on disk. pass try: result = subprocess.run( ["git", "-C", repo_dir, "tag", "--list"], capture_output=True, text=True, check=True, timeout=10, ) except (subprocess.SubprocessError, FileNotFoundError, OSError): return None, fetch_ok best: tuple[semver.VersionInfo, str] | None = None for line in result.stdout.splitlines(): tag = line.strip() if not tag: continue try: parsed = semver.VersionInfo.parse(tag.lstrip("v")) except ValueError: continue if parsed.prerelease: continue if best is None or parsed > best[0]: best = (parsed, tag) return (best[1] if best else None), fetch_ok _GITHUB_REPO_RE = re.compile( # `github.com[:/]/` with optional `.git` and optional setuptools-style # `@branch` suffix (matching what ``clone_comfyui`` accepts via ``rsplit("@", 1)``). # Branch names may contain slashes (`release/1.0`), so the `@` group is greedy # to end-of-string. The repo segment forbids `@` and `/` to avoid eating those parts. r"github\.com[/:]([^/\s]+)/([^/@\s]+?)(?:\.git)?(?:@.+)?/?$", ) def _parse_github_owner_repo(url: str | None) -> tuple[str, str] | None: """Parse a GitHub repo URL into ``(owner, repo)``. Handles the URL forms ``clone_comfyui`` accepts: - ``https://github.com/owner/repo`` - ``https://github.com/owner/repo.git`` - ``https://github.com/owner/repo@branch`` (setuptools-style branch suffix) - ``git@github.com:owner/repo`` (SSH form) Returns ``None`` for empty input, local paths, or non-GitHub URLs (GitLab, self-hosted, etc.) — the caller decides what to do with that. """ if not url: return None match = _GITHUB_REPO_RE.search(url) return (match.group(1), match.group(2)) if match else None def checkout_stable_comfyui(version: str, repo_dir: str, url: str | None = None): """ Supports installing stable releases of Comfy (semantic versioning) or the 'latest' version. For ``version="latest"`` we resolve the highest stable semver tag from the local clone first to avoid burning the unauthenticated GitHub API budget (60 req/hr per IP). The ``releases/latest`` API is only consulted when local resolution turns up nothing. The optional ``url`` is the install URL forwarded from ``execute``; it lets the API fallback query the same repo we cloned from (forks included) instead of always asking upstream. Non-GitHub URLs and missing URLs fall back to ``comfyanonymous/ComfyUI`` so the prior behavior is preserved for users who pass a local path or a non-GitHub remote. """ rprint(f"Looking for ComfyUI version '{version}'...") if version == "latest": tag, fetch_ok = _resolve_latest_tag_from_local(repo_dir) if tag is None: if not fetch_ok: rprint( "[yellow]Could not refresh tags from the remote (offline or auth failure); " "trying GitHub API as a last resort.[/yellow]" ) else: rprint("[yellow]No stable release tags found locally; querying GitHub API.[/yellow]") owner, repo = _parse_github_owner_repo(url) or ("comfyanonymous", "ComfyUI") selected_release = get_latest_release(owner, repo) if selected_release is None: rprint(f"Error: No release found for version '{version}'.") sys.exit(1) tag = str(selected_release["tag"]) elif not fetch_ok: # Tag list comes from a cached state — flag it so the user knows # they may not be on the actual newest release. rprint( f"[yellow]Warning: could not refresh tags from remote; " f"using cached tag {tag}. Re-run with network access to get the newest release.[/yellow]" ) else: # For specific versions, directly construct the tag (add 'v' prefix if needed) tag = f"v{version}" if not version.startswith("v") else version console.print( Panel( f"Checking out ComfyUI version: [bold cyan]{tag}[/bold cyan]", title="[yellow]ComfyUI Checkout[/yellow]", border_style="green", expand=False, ) ) with console.status("[bold green]Checking out tag...", spinner="dots"): success = git_checkout_tag(repo_dir, tag) if not success: console.print(f"\n[bold red]Failed to checkout tag '{tag}'![/bold red]") console.print("[yellow]The version may not exist. Please check available versions.[/yellow]") sys.exit(1) def get_latest_release(repo_owner: str, repo_name: str) -> GithubRelease | None: """ Fetch the latest release information from GitHub API. :param repo_owner: The owner of the repository :param repo_name: The name of the repository :return: A dictionary containing release information, or None if failed """ url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest" headers = {} if github_token := os.getenv("GITHUB_TOKEN"): headers["Authorization"] = f"Bearer {github_token}" try: response = requests.get(url, headers=headers, timeout=5) if response.status_code in (403, 429): handle_github_rate_limit(response) response.raise_for_status() data = response.json() # Forks may use non-semver tags (e.g. "release-2026-04"); the caller # only needs the raw tag string for git checkout, so let `version` # fall back to None instead of crashing. tag_name = data["tag_name"] try: parsed_version = semver.VersionInfo.parse(tag_name.lstrip("v")) except ValueError: parsed_version = None return GithubRelease( tag=tag_name, version=parsed_version, download_url=data["zipball_url"], ) except requests.RequestException as e: rprint(f"Error fetching latest release: {e}") return None def _parse_pr_reference( pr_ref: str, default_owner: str, default_repo: str, ) -> tuple[str, str, int | None]: """Parse a GitHub PR reference into (repo_owner, repo_name, pr_number). Supported formats: - #123 → (default_owner, default_repo, 123) - username:branch-name → (username, default_repo, None) - https://github.com/owner/repo/pull/123 → (owner, repo, 123) """ pr_ref = pr_ref.strip() if pr_ref.startswith("https://github.com/"): parsed = urlparse(pr_ref) if "/pull/" in parsed.path: path_parts = parsed.path.strip("/").split("/") if len(path_parts) >= 4: repo_owner = path_parts[0] repo_name = path_parts[1] pr_number = int(path_parts[3]) return repo_owner, repo_name, pr_number elif pr_ref.startswith("#"): pr_number = int(pr_ref[1:]) return default_owner, default_repo, pr_number elif ":" in pr_ref: username, branch = pr_ref.split(":", 1) return username, default_repo, None else: raise ValueError(f"Invalid PR reference format: {pr_ref}") def parse_pr_reference(pr_ref: str) -> tuple[str, str, int | None]: return _parse_pr_reference(pr_ref, "comfyanonymous", "ComfyUI") def fetch_pr_info(repo_owner: str, repo_name: str, pr_number: int) -> PRInfo: url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/pulls/{pr_number}" headers = {} if github_token := os.getenv("GITHUB_TOKEN"): headers["Authorization"] = f"Bearer {github_token}" try: response = requests.get(url, headers=headers, timeout=10) if response is None: raise Exception(f"Failed to fetch PR #{pr_number}: No response from GitHub API") if response.status_code in (403, 429): handle_github_rate_limit(response) response.raise_for_status() data = response.json() return PRInfo( number=data["number"], head_repo_url=data["head"]["repo"]["clone_url"], head_branch=data["head"]["ref"], base_repo_url=data["base"]["repo"]["clone_url"], base_branch=data["base"]["ref"], title=data["title"], user=data["head"]["repo"]["owner"]["login"], mergeable=data.get("mergeable", True), ) except requests.RequestException as e: raise Exception(f"Failed to fetch PR #{pr_number}: {e}") def find_pr_by_branch(repo_owner: str, repo_name: str, username: str, branch: str) -> PRInfo | None: url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/pulls" params = {"head": f"{username}:{branch}", "state": "open"} headers = {} if github_token := os.getenv("GITHUB_TOKEN"): headers["Authorization"] = f"Bearer {github_token}" try: response = requests.get(url, headers=headers, params=params, timeout=10) response.raise_for_status() data = response.json() if data: pr_data = data[0] return PRInfo( number=pr_data["number"], head_repo_url=pr_data["head"]["repo"]["clone_url"], head_branch=pr_data["head"]["ref"], base_repo_url=pr_data["base"]["repo"]["clone_url"], base_branch=pr_data["base"]["ref"], title=pr_data["title"], user=pr_data["head"]["repo"]["owner"]["login"], mergeable=pr_data.get("mergeable", True), ) return None except requests.RequestException: return None def _print_npm_not_found_help(node_version: str) -> None: """Print detailed help when npm is not found, with OS-specific instructions.""" rprint("[bold red]npm is not installed or not found in PATH.[/bold red]") rprint() rprint("[yellow]npm is a package manager that usually comes bundled with Node.js.[/yellow]") rprint(f"[yellow]Your system has Node.js ({node_version}) but npm was not found.[/yellow]") rprint() current_os = platform.system() if current_os == "Windows": rprint("[bold cyan]How to fix this on Windows:[/bold cyan]") rprint() rprint(" [bold]Step 1:[/bold] Uninstall your current Node.js installation:") rprint(" • Open the Start menu and search for 'Add or remove programs'") rprint(" • Find 'Node.js' in the list and click 'Uninstall'") rprint() rprint(" [bold]Step 2:[/bold] Download and reinstall Node.js:") rprint(" • Go to: [link=https://nodejs.org/]https://nodejs.org/[/link]") rprint(" • Click the green 'Download Node.js (LTS)' button") rprint(" • Run the downloaded installer") rprint(" • [bold]Important:[/bold] Use all default options - do not uncheck anything") rprint() rprint(" [bold]Step 3:[/bold] Restart your terminal:") rprint(" • Close this Command Prompt or PowerShell window completely") rprint(" • Open a new Command Prompt or PowerShell window") rprint() rprint(" [bold]Step 4:[/bold] Verify the installation worked:") rprint(" • Type: [bold]npm --version[/bold]") rprint(" • You should see a version number (e.g., '10.8.0')") rprint() elif current_os == "Darwin": # macOS rprint("[bold cyan]How to fix this on macOS:[/bold cyan]") rprint() rprint(" [bold]Option A - Reinstall Node.js (recommended):[/bold]") rprint() rprint(" [bold]Step 1:[/bold] Download Node.js:") rprint(" • Go to: [link=https://nodejs.org/]https://nodejs.org/[/link]") rprint(" • Click the green 'Download Node.js (LTS)' button") rprint(" • Open the downloaded .pkg file and follow the installer") rprint() rprint(" [bold]Step 2:[/bold] Restart your terminal:") rprint(" • Close this Terminal window completely (Cmd+Q)") rprint(" • Open a new Terminal window") rprint() rprint(" [bold]Option B - If you use Homebrew:[/bold]") rprint(" • Run: [bold]brew install node[/bold]") rprint(" • Then restart your terminal") rprint() rprint(" [bold]Verify the installation:[/bold]") rprint(" • Type: [bold]npm --version[/bold]") rprint(" • You should see a version number (e.g., '10.8.0')") rprint() else: # Linux rprint("[bold cyan]How to fix this on Linux:[/bold cyan]") rprint() rprint(" [bold]Option A - Install npm separately (Ubuntu/Debian):[/bold]") rprint(" • Run: [bold]sudo apt update && sudo apt install npm[/bold]") rprint(" • Enter your password when prompted") rprint() rprint(" [bold]Option B - Reinstall Node.js with npm:[/bold]") rprint() rprint(" [bold]Step 1:[/bold] Remove current Node.js:") rprint(" • Ubuntu/Debian: [bold]sudo apt remove nodejs[/bold]") rprint(" • Fedora: [bold]sudo dnf remove nodejs[/bold]") rprint() rprint(" [bold]Step 2:[/bold] Install Node.js (includes npm):") rprint(" • Go to: [link=https://nodejs.org/]https://nodejs.org/[/link]") rprint(" • Or use NodeSource repository for latest version:") rprint(" [bold]curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -[/bold]") rprint(" [bold]sudo apt install -y nodejs[/bold]") rprint() rprint(" [bold]Step 3:[/bold] Restart your terminal:") rprint(" • Close this terminal window and open a new one") rprint() rprint(" [bold]Verify the installation:[/bold]") rprint(" • Type: [bold]npm --version[/bold]") rprint(" • You should see a version number (e.g., '10.8.0')") rprint() rprint("[dim]After fixing npm, run your comfy command again.[/dim]") rprint() def verify_node_tools() -> bool: """Verify that Node.js, npm, and pnpm are available for frontend building""" try: node_result = subprocess.run(["node", "--version"], capture_output=True, text=True, check=False) except FileNotFoundError: rprint("[bold red]Node.js is not installed or not found in PATH.[/bold red]") rprint("[yellow]To use --frontend-pr, please install Node.js first:[/yellow]") rprint(" • Download from: https://nodejs.org/") rprint(" • Or use a package manager:") rprint(" - macOS: brew install node") rprint(" - Ubuntu/Debian: sudo apt install nodejs npm") rprint(" - Windows: winget install OpenJS.NodeJS") return False if node_result.returncode != 0: rprint("[bold red]Node.js is not installed or not working correctly.[/bold red]") rprint("[yellow]To use --frontend-pr, please install Node.js first:[/yellow]") rprint(" • Download from: https://nodejs.org/") rprint(" • Or use a package manager:") rprint(" - macOS: brew install node") rprint(" - Ubuntu/Debian: sudo apt install nodejs npm") rprint(" - Windows: winget install OpenJS.NodeJS") return False node_version = (node_result.stdout or node_result.stderr or "").strip() if node_version: rprint(f"[green]Found Node.js {node_version}[/green]") else: rprint("[green]Found Node.js[/green]") try: npm_result = subprocess.run(["npm", "--version"], capture_output=True, text=True, check=False) except FileNotFoundError: _print_npm_not_found_help(node_version) return False if npm_result.returncode != 0: _print_npm_not_found_help(node_version) return False npm_version = npm_result.stdout.strip() if npm_version: rprint(f"[green]Found npm {npm_version}[/green]") else: rprint("[green]Found npm[/green]") try: pnpm_result = subprocess.run(["pnpm", "--version"], capture_output=True, text=True, check=False) if pnpm_result.returncode == 0: pnpm_version = pnpm_result.stdout.strip() if pnpm_version: rprint(f"[green]Found pnpm {pnpm_version}[/green]") else: rprint("[green]Found pnpm[/green]") return True except FileNotFoundError: pass rprint("[yellow]pnpm is not installed but is required for the modern frontend.[/yellow]") install_pnpm = Confirm.ask( "[bold yellow]Install pnpm automatically using npm?[/bold yellow] (This will run: npm install -g pnpm)" ) if not install_pnpm: rprint("[bold red]Cannot build frontend without pnpm.[/bold red]") rprint("[yellow]To install manually:[/yellow]") rprint(" npm install -g pnpm") return False rprint("[yellow]Installing pnpm...[/yellow]") install_result = subprocess.run(["npm", "install", "-g", "pnpm"], capture_output=True, text=True, check=False) if install_result.returncode != 0: rprint("[bold red]Failed to install pnpm automatically.[/bold red]") rprint(f"[red]Error: {install_result.stderr}[/red]") rprint("[yellow]Please install manually: npm install -g pnpm[/yellow]") return False try: verify_result = subprocess.run(["pnpm", "--version"], capture_output=True, text=True, check=False) except FileNotFoundError: rprint("[bold red]pnpm installation succeeded but pnpm was not found on PATH.[/bold red]") rprint( "[yellow]Try restarting your shell or add npm global bin to PATH, then verify with: pnpm --version[/yellow]" ) return False if verify_result.returncode != 0: rprint("[bold red]pnpm installation failed to verify.[/bold red]") if verify_result.stderr: rprint(f"[red]{verify_result.stderr.strip()}[/red]") return False pnpm_version = verify_result.stdout.strip() rprint(f"[green]Successfully installed pnpm {pnpm_version}[/green]") return True def handle_temporary_frontend_pr(frontend_pr: str) -> str | None: """Handle temporary frontend PR for launch - returns path to built frontend""" from comfy_cli.pr_cache import PRCache rprint("\n[bold blue]Preparing frontend PR for launch...[/bold blue]") # Verify Node.js tools first if not verify_node_tools(): rprint("[bold red]Cannot build frontend without Node.js and npm[/bold red]") return None # Parse frontend PR reference try: repo_owner, repo_name, pr_number = parse_frontend_pr_reference(frontend_pr) except ValueError as e: rprint(f"[bold red]Error parsing frontend PR reference: {e}[/bold red]") return None # Fetch PR info try: if pr_number: pr_info = fetch_pr_info(repo_owner, repo_name, pr_number) else: username, branch = frontend_pr.split(":", 1) pr_info = find_pr_by_branch("Comfy-Org", "ComfyUI_frontend", username, branch) if not pr_info: rprint(f"[bold red]Frontend PR not found: {frontend_pr}[/bold red]") return None except Exception as e: rprint(f"[bold red]Error fetching frontend PR information: {e}[/bold red]") return None # Check cache first cache = PRCache() cached_path = cache.get_cached_frontend_path(pr_info) if cached_path: rprint(f"[bold green]Using cached frontend build for PR #{pr_info.number}[/bold green]") rprint(f"[bold green]PR #{pr_info.number}: {pr_info.title} by {pr_info.user}[/bold green]") return str(cached_path) # Need to build - show PR info console.print( Panel( f"[bold]Frontend PR #{pr_info.number}[/bold]: {pr_info.title}\n" f"[yellow]Author[/yellow]: {pr_info.user}\n" f"[yellow]Branch[/yellow]: {pr_info.head_branch}\n" f"[yellow]Source[/yellow]: {pr_info.head_repo_url}", title="[bold blue]Building Frontend PR[/bold blue]", border_style="blue", ) ) # Build in cache directory cache_path = cache.get_frontend_cache_path(pr_info) cache_path.mkdir(parents=True, exist_ok=True) # Clone or update repository repo_path = cache_path / "repo" if not (repo_path / ".git").exists(): rprint("Cloning frontend repository...") clone_comfyui(url=pr_info.base_repo_url, repo_dir=str(repo_path)) # Checkout PR rprint(f"Checking out PR #{pr_info.number}...") success = checkout_pr(str(repo_path), pr_info) if not success: rprint("[bold red]Failed to checkout frontend PR[/bold red]") return None # Build frontend rprint("\n[bold yellow]Building frontend (this may take a moment)...[/bold yellow]") original_dir = os.getcwd() try: os.chdir(repo_path) # Run pnpm install rprint("Running pnpm install...") pnpm_install = subprocess.run(["pnpm", "install"], capture_output=True, text=True, check=False) if pnpm_install.returncode != 0: rprint(f"[bold red]pnpm install failed:[/bold red]\n{pnpm_install.stderr}") return None # Build with vite rprint("Building with vite...") vite_build = subprocess.run(["npx", "vite", "build"], capture_output=True, text=True, check=False) if vite_build.returncode != 0: rprint(f"[bold red]vite build failed:[/bold red]\n{vite_build.stderr}") return None # Check if dist exists dist_path = repo_path / "dist" if dist_path.exists(): # Save cache info cache.save_cache_info(pr_info, cache_path) rprint("[bold green]✓ Frontend built and cached successfully[/bold green]") rprint(f"[bold green]Using frontend from PR #{pr_info.number}: {pr_info.title}[/bold green]") rprint(f"[dim]Cache will expire in {cache.DEFAULT_MAX_CACHE_AGE_DAYS} days[/dim]") return str(dist_path) else: rprint("[bold red]Frontend build completed but dist folder not found[/bold red]") return None finally: os.chdir(original_dir) def parse_frontend_pr_reference(pr_ref: str) -> tuple[str, str, int | None]: return _parse_pr_reference(pr_ref, "Comfy-Org", "ComfyUI_frontend") ================================================ FILE: comfy_cli/command/launch.py ================================================ from __future__ import annotations import asyncio import os import subprocess import sys import threading import uuid import typer from rich import print from rich.console import Console from rich.panel import Panel from comfy_cli import constants, utils from comfy_cli.command.custom_nodes.cm_cli_util import find_cm_cli, resolve_manager_gui_mode from comfy_cli.config_manager import ConfigManager from comfy_cli.env_checker import check_comfy_server_running from comfy_cli.resolve_python import resolve_workspace_python from comfy_cli.update import check_for_updates from comfy_cli.workspace_manager import WorkspaceManager, WorkspaceType workspace_manager = WorkspaceManager() console = Console() def _get_manager_flags() -> list[str]: """Get manager flags based on config mode.""" mode = resolve_manager_gui_mode(not_installed_value=None) if mode is None or mode == "disable": return [] # For enable-* modes, verify cm-cli is available if not find_cm_cli(): print( "[bold yellow]Warning: ComfyUI-Manager (cm-cli) not found. " "Manager flags will not be injected.[/bold yellow]" ) return [] if mode == "enable-gui": return ["--enable-manager"] elif mode == "disable-gui": return ["--enable-manager", "--disable-manager-ui"] elif mode == "enable-legacy-gui": return ["--enable-manager", "--enable-manager-legacy-ui"] else: print(f"[bold yellow]Warning: Unknown manager mode '{mode}'. Falling back to --enable-manager.[/bold yellow]") return ["--enable-manager"] # fallback to default def launch_comfyui(extra, frontend_pr=None, python=sys.executable): reboot_path = None new_env = os.environ.copy() session_path = os.path.join(ConfigManager().get_config_path(), "tmp", str(uuid.uuid4())) new_env["__COMFY_CLI_SESSION__"] = session_path new_env["PYTHONENCODING"] = "utf-8" # To minimize the possibility of leaving residue in the tmp directory, use files instead of directories. reboot_path = os.path.join(session_path + ".reboot") extra = extra if extra is not None else [] # Handle temporary frontend PR if frontend_pr: from comfy_cli.command.install import handle_temporary_frontend_pr try: frontend_path = handle_temporary_frontend_pr(frontend_pr) if frontend_path: # Check if --front-end-root is not already specified if not any(arg.startswith("--front-end-root") for arg in extra): extra = ["--front-end-root", frontend_path] + extra except Exception as e: print(f"[bold red]Failed to prepare frontend PR: {e}[/bold red]") # Continue with default frontend process = None if "COMFY_CLI_BACKGROUND" not in os.environ: # If not running in background mode, there's no need to use popen. This can prevent the issue of linefeeds occurring with tqdm. while True: res = subprocess.run([python, "main.py"] + extra, env=new_env, check=False) if reboot_path is None: print("[bold red]ComfyUI is not installed.[/bold red]\n") exit(res.returncode) if not os.path.exists(reboot_path): exit(res.returncode) os.remove(reboot_path) else: # If running in background mode without using a popen, broken pipe errors may occur when flushing stdout/stderr. def redirector_stderr(): while True: if process is not None: print(process.stderr.readline(), end="") def redirector_stdout(): while True: if process is not None: print(process.stdout.readline(), end="") threading.Thread(target=redirector_stderr).start() threading.Thread(target=redirector_stdout).start() try: while True: if sys.platform == "win32": process = subprocess.Popen( [python, "main.py"] + extra, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=new_env, encoding="utf-8", shell=True, # win32 only creationflags=subprocess.CREATE_NEW_PROCESS_GROUP, # win32 only ) else: process = subprocess.Popen( [python, "main.py"] + extra, text=True, env=new_env, encoding="utf-8", stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) process.wait() if reboot_path is None: print("[bold red]ComfyUI is not installed.[/bold red]\n") os._exit(1) if not os.path.exists(reboot_path): os._exit(process.returncode) os.remove(reboot_path) except KeyboardInterrupt: if process is not None: os._exit(1) def launch( background: bool = False, extra: list[str] | None = None, frontend_pr: str | None = None, ): check_for_updates() resolved_workspace = workspace_manager.workspace_path if not resolved_workspace: print( "\nComfyUI is not available.\nTo install ComfyUI, you can run:\n\n\tcomfy install\n\n", file=sys.stderr, ) raise typer.Exit(code=1) if (extra is None or len(extra) == 0) and workspace_manager.workspace_type == WorkspaceType.DEFAULT: launch_extras = workspace_manager.config_manager.config["DEFAULT"].get( constants.CONFIG_KEY_DEFAULT_LAUNCH_EXTRAS, "" ) if launch_extras != "": extra = launch_extras.split(" ") print(f"\nLaunching ComfyUI from: {resolved_workspace}\n") # Update the recent workspace workspace_manager.set_recent_workspace(resolved_workspace) os.chdir(resolved_workspace) python = resolve_workspace_python(resolved_workspace) # Inject manager flags based on config mode manager_flags = _get_manager_flags() if manager_flags: extra = (extra or []) + manager_flags if background: background_launch(extra, frontend_pr) else: launch_comfyui(extra, frontend_pr, python=python) def background_launch(extra, frontend_pr=None): config_background = ConfigManager().background if config_background is not None and utils.is_running(config_background[2]): console.print( "[bold red]ComfyUI is already running in background.\nYou cannot start more than one background service.[/bold red]\n" ) raise typer.Exit(code=1) port = 8188 listen = "127.0.0.1" if extra is not None: for i in range(len(extra) - 1): if extra[i] == "--port": port = extra[i + 1] if extra[i] == "--listen": listen = extra[i + 1] if len(extra) > 0: extra = ["--"] + extra else: extra = [] if check_comfy_server_running(port): console.print( f"[bold red]The {port} port is already in use. A new ComfyUI server cannot be launched.\n[bold red]\n" ) raise typer.Exit(code=1) cmd = [ "comfy", f"--workspace={os.path.abspath(os.getcwd())}", "launch", ] # Add frontend PR option if specified if frontend_pr: cmd.extend(["--frontend-pr", frontend_pr]) cmd.extend(extra) loop = asyncio.get_event_loop() log = loop.run_until_complete(launch_and_monitor(cmd, listen, port)) if log is not None: console.print( Panel( "".join(log), title="[bold red]Error log during ComfyUI execution[/bold red]", border_style="bright_red", ) ) console.print("\n[bold red]Execution error: failed to launch ComfyUI[/bold red]\n") # NOTE: os.exit(0) doesn't work os._exit(1) async def launch_and_monitor(cmd, listen, port): """ Monitor the process during the background launch. If a success message is captured, exit; otherwise, return the log in case of failure. """ logging_flag = False log = [] logging_lock = threading.Lock() # NOTE: To prevent encoding error on Windows platform env = dict(os.environ, PYTHONIOENCODING="utf-8") env["COMFY_CLI_BACKGROUND"] = "true" if sys.platform == "win32": process = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env, encoding="utf-8", shell=True, # win32 only creationflags=subprocess.CREATE_NEW_PROCESS_GROUP, # win32 only ) else: process = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env, encoding="utf-8", ) def msg_hook(stream): nonlocal log nonlocal logging_flag while True: line = stream.readline() if "Launching ComfyUI from:" in line: logging_flag = True elif "To see the GUI go to:" in line: print( f"[bold yellow]ComfyUI is successfully launched in the background.[/bold yellow]\nTo see the GUI go to: http://{listen}:{port}" ) ConfigManager().config["DEFAULT"][constants.CONFIG_KEY_BACKGROUND] = f"{(listen, port, process.pid)}" ConfigManager().write_config() # NOTE: os.exit(0) doesn't work. os._exit(0) with logging_lock: if logging_flag: log.append(line) stdout_thread = threading.Thread(target=msg_hook, args=(process.stdout,)) stderr_thread = threading.Thread(target=msg_hook, args=(process.stderr,)) stdout_thread.start() stderr_thread.start() process.wait() return log ================================================ FILE: comfy_cli/command/models/models.py ================================================ import contextlib import os import pathlib import time from typing import Annotated from urllib.parse import parse_qs, unquote, urlparse import requests import typer from rich import print from rich.markup import escape from comfy_cli import constants, tracking, ui from comfy_cli.config_manager import ConfigManager from comfy_cli.constants import DEFAULT_COMFY_MODEL_PATH from comfy_cli.file_utils import DownloadException, check_unauthorized, download_file from comfy_cli.workspace_manager import WorkspaceManager app = typer.Typer() workspace_manager = WorkspaceManager() config_manager = ConfigManager() _CIVITAI_SUBDOMAIN_SUFFIXES = tuple(f".{h}" for h in constants.CIVITAI_ALLOWED_HOSTS) model_path_map = { "lora": "loras", "hypernetwork": "hypernetworks", "checkpoint": "checkpoints", "textualinversion": "embeddings", "controlnet": "controlnet", } def get_workspace() -> pathlib.Path: return pathlib.Path(workspace_manager.workspace_path) def _format_elapsed(seconds: float) -> str: """Format elapsed seconds into a human-readable string.""" rounded = round(seconds, 1) if rounded < 60: return f"{rounded:.1f}s" minutes, secs = divmod(int(rounded), 60) if minutes < 60: return f"{minutes}m {secs}s" hours, minutes = divmod(minutes, 60) return f"{hours}h {minutes}m {secs}s" def potentially_strip_param_url(path_name: str) -> str: return path_name.split("?")[0] def check_huggingface_url(url: str) -> tuple[bool, str | None, str | None, str | None, str | None]: """ Check if the given URL is a Hugging Face URL and extract relevant information. Args: url (str): The URL to check. Returns: Tuple[bool, Optional[str], Optional[str], Optional[str], Optional[str]]: - is_huggingface_url (bool): True if it's a Hugging Face URL, False otherwise. - repo_id (Optional[str]): The repository ID if it's a Hugging Face URL, None otherwise. - filename (Optional[str]): The filename if present, None otherwise. - folder_name (Optional[str]): The folder name if present, None otherwise. - branch_name (Optional[str]): The git branch name if present, None otherwise. """ parsed_url = urlparse(url) if parsed_url.netloc != "huggingface.co" and parsed_url.netloc != "huggingface.com": return False, None, None, None, None path_parts = [p for p in parsed_url.path.split("/") if p] if len(path_parts) < 5 or (path_parts[2] != "resolve" and path_parts[2] != "blob"): return False, None, None, None, None repo_id = f"{path_parts[0]}/{path_parts[1]}" branch_name = path_parts[3] remaining_path = "/".join(path_parts[4:]) folder_name = os.path.dirname(remaining_path) if "/" in remaining_path else None filename = os.path.basename(remaining_path) # URL decode the filename filename = unquote(filename) return True, repo_id, filename, folder_name, branch_name def check_civitai_url(url: str) -> tuple[bool, bool, int | None, int | None]: """ Returns: is_civitai_model_url: True if the url is a civitai *web* model url (e.g. /models/12345) is_civitai_api_url: True if the url is a civitai *api* url useful for resolving downloads model_id: The model id (for /models/*), else None version_id: The version id (for /api/download/models/* or ?modelVersionId=), else None """ try: parsed = urlparse(url) host = (parsed.hostname or "").lower() if host not in constants.CIVITAI_ALLOWED_HOSTS and not host.endswith(_CIVITAI_SUBDOMAIN_SUFFIXES): return False, False, None, None p_parts = [p for p in parsed.path.split("/") if p] query = parse_qs(parsed.query) if len(p_parts) >= 4 and p_parts[0] == "api": # Case 1: /api/download/models/ # e.g. https://civitai.com/api/download/models/1617665?type=Model&format=SafeTensor if p_parts[1] == "download" and p_parts[2] == "models": try: version_id = int(p_parts[3]) return False, True, None, version_id except ValueError: return False, True, None, None # Case 2: /api/v1/model-versions/ if p_parts[1] == "v1" and p_parts[2] in ("model-versions", "modelVersions"): try: version_id = int(p_parts[3]) return False, True, None, version_id except ValueError: return False, True, None, None # Case 3: /models/[/*] with optional ?modelVersionId= # e.g. https://civitai.com/models/43331 # https://civitai.com/models/43331/majicmix-realistic?modelVersionId=485088 if len(p_parts) >= 2 and p_parts[0] == "models": try: model_id = int(p_parts[1]) except ValueError: return False, False, None, None version_id = None mv = query.get("modelVersionId") if mv and len(mv) > 0: with contextlib.suppress(ValueError): version_id = int(mv[0]) if version_id is None: mv = query.get("version") if mv and len(mv) > 0: with contextlib.suppress(ValueError): version_id = int(mv[0]) return True, False, model_id, version_id return False, False, None, None except Exception: print("Error parsing CivitAI model URL") return False, False, None, None def request_civitai_model_version_api(version_id: int, headers: dict | None = None): # Make a request to the CivitAI API to get the model information response = requests.get( f"https://civitai.com/api/v1/model-versions/{version_id}", headers=headers, timeout=10, ) response.raise_for_status() # Raise an error for bad status codes model_data = response.json() for file in model_data["files"]: if file.get("primary", False): # Assuming we want the primary file model_name = file["name"] download_url = file["downloadUrl"] model_type = model_data["model"]["type"].lower() basemodel = model_data["baseModel"].replace(" ", "") return model_name, download_url, model_type, basemodel def request_civitai_model_api(model_id: int, version_id: int = None, headers: dict | None = None): # Make a request to the CivitAI API to get the model information response = requests.get(f"https://civitai.com/api/v1/models/{model_id}", headers=headers, timeout=10) response.raise_for_status() # Raise an error for bad status codes model_data = response.json() # If version_id is None, use the first version if version_id is None: version_id = model_data["modelVersions"][0]["id"] # Find the version with the specified version_id for version in model_data["modelVersions"]: if version["id"] == version_id: # Get the model name and download URL from the files array for file in version["files"]: if file.get("primary", False): # Assuming we want the primary file model_name = file["name"] download_url = file["downloadUrl"] model_type = model_data["type"].lower() basemodel = version["baseModel"].replace(" ", "") return model_name, download_url, model_type, basemodel # If the specified version_id is not found, raise an error raise ValueError(f"Version ID {version_id} not found for model ID {model_id}") @app.command(help="Download model file from url") @tracking.track_command("model") def download( _ctx: typer.Context, url: Annotated[ str, typer.Option(help="The URL from which to download the model.", show_default=False), ], relative_path: Annotated[ str | None, typer.Option( help="The relative path from the current workspace to install the model.", show_default=True, ), ] = None, filename: Annotated[ str | None, typer.Option( help="The filename to save the model.", show_default=True, ), ] = None, set_civitai_api_token: Annotated[ str | None, typer.Option( "--set-civitai-api-token", help="Set the CivitAI API token to use for model downloading.", show_default=False, ), ] = None, set_hf_api_token: Annotated[ str | None, typer.Option( "--set-hf-api-token", help="Set the Hugging Face API token to use for model downloading.", show_default=False, ), ] = None, downloader: Annotated[ str | None, typer.Option( "--downloader", help="Download backend: 'httpx' (default) or 'aria2' (requires aria2 RPC server).", show_default=False, ), ] = None, ): if relative_path is not None: relative_path = os.path.expanduser(relative_path) local_filename = None headers = None civitai_api_token = config_manager.get_or_override( constants.CIVITAI_API_TOKEN_ENV_KEY, constants.CIVITAI_API_TOKEN_KEY, set_civitai_api_token ) hf_api_token = config_manager.get_or_override( constants.HF_API_TOKEN_ENV_KEY, constants.HF_API_TOKEN_KEY, set_hf_api_token ) resolved_downloader = downloader or config_manager.get(constants.CONFIG_KEY_DEFAULT_DOWNLOADER) or "httpx" is_civitai_model_url, is_civitai_api_url, model_id, version_id = check_civitai_url(url) is_huggingface_url, repo_id, hf_filename, hf_folder_name, hf_branch_name = check_huggingface_url(url) if is_civitai_model_url or is_civitai_api_url: headers = { "Content-Type": "application/json", } if civitai_api_token is not None: headers["Authorization"] = f"Bearer {civitai_api_token}" if is_civitai_model_url: local_filename, url, model_type, basemodel = request_civitai_model_api(model_id, version_id, headers) model_path = model_path_map.get(model_type) if relative_path is None: if model_path is None: model_path = ui.prompt_input("Enter model type path (e.g. loras, checkpoints, ...)", default="") relative_path = os.path.join(DEFAULT_COMFY_MODEL_PATH, model_path, basemodel) elif is_civitai_api_url: local_filename, url, model_type, basemodel = request_civitai_model_version_api(version_id, headers) model_path = model_path_map.get(model_type) if relative_path is None: if model_path is None: model_path = ui.prompt_input("Enter model type path (e.g. loras, checkpoints, ...)", default="") relative_path = os.path.join(DEFAULT_COMFY_MODEL_PATH, model_path, basemodel) elif is_huggingface_url: model_id = "/".join(url.split("/")[-2:]) local_filename = potentially_strip_param_url(url.split("/")[-1]) if relative_path is None: model_path = ui.prompt_input("Enter model type path (e.g. loras, checkpoints, ...)", default="") basemodel = ui.prompt_input("Enter base model (e.g. SD1.5, SDXL, ...)", default="") relative_path = os.path.join(DEFAULT_COMFY_MODEL_PATH, model_path, basemodel) else: print("Model source is unknown") if filename is None: if local_filename is None: local_filename = ui.prompt_input("Enter filename to save model as") else: local_filename = ui.prompt_input("Enter filename to save model as", default=local_filename) else: local_filename = filename if relative_path is None: relative_path = DEFAULT_COMFY_MODEL_PATH if local_filename is None: raise typer.Exit(code=1) if local_filename == "": raise DownloadException("Filename cannot be empty") local_filepath = get_workspace() / relative_path / local_filename if local_filepath.exists(): print(f"[bold red]File already exists: {local_filepath}[/bold red]") return start_time = time.monotonic() if is_huggingface_url and check_unauthorized(url, headers): if hf_api_token is None: print( f"Unauthorized access to Hugging Face model. Please set the Hugging Face API token using `comfy model download --set-hf-api-token` or via the `{constants.HF_API_TOKEN_ENV_KEY}` environment variable" ) return else: try: import huggingface_hub except ImportError: print("huggingface_hub not found. Installing...") import subprocess from comfy_cli.resolve_python import resolve_workspace_python python = resolve_workspace_python(str(get_workspace())) subprocess.check_call([python, "-m", "pip", "install", "huggingface_hub"]) import huggingface_hub print(f"Downloading model {model_id} from Hugging Face...") output_path = huggingface_hub.hf_hub_download( repo_id=repo_id, filename=hf_filename, subfolder=hf_folder_name, revision=hf_branch_name, token=hf_api_token, local_dir=get_workspace() / relative_path, cache_dir=get_workspace() / relative_path, ) print(f"Model downloaded successfully to: {output_path}") else: print(f"Start downloading URL: {url} into {local_filepath}") try: download_file(url, local_filepath, headers, downloader=resolved_downloader) except DownloadException as e: # escape() so a dynamic error message containing "[/]" or similar # rich-markup syntax doesn't trigger MarkupError or get mis-rendered. print(f"[bold red]{escape(str(e))}[/bold red]") raise typer.Exit(code=1) from None elapsed = time.monotonic() - start_time print(f"Done in {_format_elapsed(elapsed)}") @app.command() @tracking.track_command("model") def remove( ctx: typer.Context, relative_path: str = typer.Option( DEFAULT_COMFY_MODEL_PATH, help="The relative path from the current workspace where the models are stored.", show_default=True, ), model_names: list[str] | None = typer.Option( None, help="List of model filenames to delete, separated by spaces", show_default=False, ), confirm: bool = typer.Option( False, help="Confirm for deletion and skip the prompt", show_default=False, ), ): """Remove one or more downloaded models, either by specifying them directly or through an interactive selection.""" model_dir = get_workspace() / relative_path available_models = list_models(model_dir) if not available_models: typer.echo("No models found to remove.") return model_dir_resolved = model_dir.resolve() to_delete = [] # Scenario #1: User provided model names to delete if model_names: # Validate and filter models to delete based on provided names missing_models = [] for name in model_names: model_path = (model_dir / name).resolve() if not model_path.is_relative_to(model_dir_resolved): typer.echo(f"Invalid model path: {name}") continue if model_path.is_file(): to_delete.append(model_path) else: missing_models.append(name) if missing_models: typer.echo("The following models were not found and cannot be removed: " + ", ".join(missing_models)) if not to_delete: return # Exit if no valid models were found # Scenario #2: User did not provide model names, prompt for selection else: rel_names = [str(model.relative_to(model_dir)) for model in available_models] selections = ui.prompt_multi_select("Select models to delete:", rel_names) if not selections: typer.echo("No models selected for deletion.") return to_delete = [model_dir / selection for selection in selections] # Confirm deletion if to_delete and ( confirm or ui.prompt_confirm_action("Are you sure you want to delete the selected files?", False) ): for model_path in to_delete: model_path.unlink() typer.echo(f"Deleted: {model_path}") else: typer.echo("Deletion canceled.") def list_models(path: pathlib.Path) -> list[pathlib.Path]: """List all model files recursively in the specified directory.""" if not path.is_dir(): return [] return sorted(f for f in path.rglob("*") if f.is_file()) @app.command("list") @tracking.track_command("model") def list_command( ctx: typer.Context, relative_path: str = typer.Option( DEFAULT_COMFY_MODEL_PATH, help="The relative path from the current workspace where the models are stored.", show_default=True, ), ): """Display a list of all models currently downloaded in a table format.""" model_dir = get_workspace() / relative_path models = list_models(model_dir) if not models: typer.echo("No models found.") return # Prepare data for table display data = [] for model in models: rel = model.relative_to(model_dir) model_type = str(rel.parent) if len(rel.parts) > 1 else "" data.append((model.name, model_type, f"{model.stat().st_size // 1024} KB")) column_names = ["Model Name", "Type", "Size"] ui.display_table(data, column_names) ================================================ FILE: comfy_cli/command/pr_command.py ================================================ """PR cache management commands. This module provides CLI commands for managing the PR cache, including: - Listing cached PR builds - Cleaning specific or all cached builds - Displaying cache information in a user-friendly format """ import typer from rich import print as rprint from rich.console import Console from rich.table import Table from comfy_cli import tracking from comfy_cli.pr_cache import PRCache app = typer.Typer(help="Manage PR cache") console = Console() @app.command("list", help="List cached PR builds") @tracking.track_command() def list_cached() -> None: """List all cached PR builds.""" cache = PRCache() cached_frontends = cache.list_cached_frontends() if not cached_frontends: rprint("[yellow]No cached PR builds found[/yellow]") return table = Table(title="Cached Frontend PR Builds") table.add_column("PR #", style="cyan") table.add_column("Title", style="white") table.add_column("Author", style="green") table.add_column("Age", style="yellow") table.add_column("Size (MB)", style="magenta") for info in cached_frontends: age = cache.get_cache_age(info.get("cached_at", "")) table.add_row( str(info.get("pr_number", "?")), info.get("pr_title", "Unknown")[:50], # Truncate long titles info.get("user", "Unknown"), age, f"{info.get('size_mb', 0):.1f}", ) console.print(table) # Show cache settings rprint( f"\n[dim]Cache settings: Max age: {cache.DEFAULT_MAX_CACHE_AGE_DAYS} days, " f"Max items: {cache.DEFAULT_MAX_CACHE_ITEMS}[/dim]" ) @app.command("clean", help="Clean PR cache") @tracking.track_command() def clean_cache( pr_number: int = typer.Argument(None, help="Specific PR number to clean (omit to clean all)"), yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"), ) -> None: """Clean cached PR builds.""" cache = PRCache() if pr_number: if not yes: confirm = typer.confirm(f"Remove cache for PR #{pr_number}?") if not confirm: rprint("[yellow]Cancelled[/yellow]") return cache.clean_frontend_cache(pr_number) rprint(f"[green]✓ Cleaned cache for PR #{pr_number}[/green]") else: if not yes: cached = cache.list_cached_frontends() if cached: rprint(f"[yellow]This will remove {len(cached)} cached PR build(s)[/yellow]") confirm = typer.confirm("Remove all cached PR builds?") if not confirm: rprint("[yellow]Cancelled[/yellow]") return cache.clean_frontend_cache() rprint("[green]✓ Cleaned all PR cache[/green]") ================================================ FILE: comfy_cli/command/run.py ================================================ import json import os import sys import time import urllib.error import urllib.parse import uuid from datetime import timedelta from urllib import request import typer from rich import print as pprint from rich.progress import BarColumn, Column, Progress, Table, TimeElapsedColumn from websocket import WebSocket, WebSocketException, WebSocketTimeoutException from comfy_cli.env_checker import check_comfy_server_running from comfy_cli.workflow_to_api import WorkflowConversionError, convert_ui_to_api from comfy_cli.workspace_manager import WorkspaceManager workspace_manager = WorkspaceManager() def is_ui_workflow(workflow) -> bool: return ( isinstance(workflow, dict) and isinstance(workflow.get("nodes"), list) and isinstance(workflow.get("links"), list) ) def _validate_api_workflow(workflow): """Return the workflow dict if it has the shape of API format, else None.""" if not isinstance(workflow, dict) or not workflow: return None node = workflow[next(iter(workflow))] if not isinstance(node, dict) or "class_type" not in node: return None return workflow def fetch_object_info(host: str, port: int, timeout: int) -> dict: """GET ``/object_info`` from the running ComfyUI server. The response describes every loaded node class's input schema and is what the converter uses to map widget values to input names, fill defaults, etc. """ url = f"http://{host}:{port}/object_info" try: with request.urlopen(url, timeout=timeout) as resp: body = resp.read() except urllib.error.HTTPError as e: body = e.read().decode("utf-8", errors="replace").strip() pprint(f"[bold red]Failed to fetch /object_info (HTTP {e.code}): {body[:500]}[/bold red]") raise typer.Exit(code=1) from e except urllib.error.URLError as e: pprint(f"[bold red]Failed to fetch /object_info: {e.reason}[/bold red]") raise typer.Exit(code=1) from e except TimeoutError as e: pprint(f"[bold red]Failed to fetch /object_info: timed out after {timeout}s[/bold red]") raise typer.Exit(code=1) from e try: return json.loads(body) except json.JSONDecodeError as e: pprint("[bold red]Failed to fetch /object_info: server returned invalid JSON[/bold red]") raise typer.Exit(code=1) from e def execute( workflow: str, host, port, wait=True, verbose=False, local_paths=False, timeout=30, api_key: str | None = None, ): workflow_name = os.path.abspath(os.path.expanduser(workflow)) if not os.path.isfile(workflow): pprint( f"[bold red]Specified workflow file not found: {workflow}[/bold red]", file=sys.stderr, ) raise typer.Exit(code=1) if not check_comfy_server_running(port, host): pprint(f"[bold red]ComfyUI not running on specified address ({host}:{port})[/bold red]") raise typer.Exit(code=1) try: with open(workflow_name, encoding="utf-8") as f: raw_workflow = json.load(f) except OSError as e: pprint(f"[bold red]Unable to read workflow file: {e}[/bold red]") raise typer.Exit(code=1) from e except json.JSONDecodeError as e: pprint(f"[bold red]Specified workflow file is not valid JSON: {e}[/bold red]") raise typer.Exit(code=1) from e if is_ui_workflow(raw_workflow): pprint("[yellow]Detected UI-format workflow, converting to API format...[/yellow]") object_info = fetch_object_info(host, port, timeout) try: workflow = convert_ui_to_api(raw_workflow, object_info) except WorkflowConversionError as e: pprint(f"[bold red]Workflow conversion failed: {e}[/bold red]") raise typer.Exit(code=1) from e except Exception as e: # The converter is experimental; an unexpected crash here is a bug # in our code, not user error. Show a clean message and a pointer. pprint( f"[bold red]Workflow conversion crashed unexpectedly: {type(e).__name__}: {e}[/bold red]\n" "[yellow]The UI-to-API converter is experimental. Please report this at[/yellow]\n" "[yellow] https://github.com/Comfy-Org/comfy-cli/issues[/yellow]\n" "[yellow]and attach the workflow file if possible.[/yellow]" ) if verbose: import traceback traceback.print_exc() raise typer.Exit(code=1) from e if not workflow: pprint("[bold red]Workflow conversion produced no executable nodes[/bold red]") raise typer.Exit(code=1) else: workflow = _validate_api_workflow(raw_workflow) if not workflow: pprint("[bold red]Specified workflow does not appear to be an API workflow json file[/bold red]") raise typer.Exit(code=1) progress = None start = time.time() if wait: pprint(f"Executing workflow: {workflow_name}") progress = ExecutionProgress() progress.start() else: print(f"Queuing workflow: {workflow_name}") execution = WorkflowExecution(workflow, host, port, verbose, progress, local_paths, timeout, api_key=api_key) try: if wait: execution.connect() execution.queue() if wait: execution.watch_execution() end = time.time() progress.stop() progress = None if len(execution.outputs) > 0: pprint("[bold green]\nOutputs:[/bold green]") for f in execution.outputs: pprint(f) elapsed = timedelta(seconds=end - start) pprint(f"[bold green]\nWorkflow execution completed ({elapsed})[/bold green]") else: pprint("[bold green]Workflow queued[/bold green]") except WebSocketTimeoutException: pprint( f"[bold red]Error: WebSocket timed out after {timeout}s waiting for server response.[/bold red]\n" "[yellow]For long-running workflows, increase the timeout: comfy run --workflow --timeout 300[/yellow]" ) raise typer.Exit(code=1) except (WebSocketException, ConnectionError, OSError) as e: pprint(f"[bold red]Error: Lost connection to ComfyUI server: {e}[/bold red]") raise typer.Exit(code=1) finally: if progress: progress.stop() class ExecutionProgress(Progress): def get_renderables(self): table_columns = ( (Column(no_wrap=True) if isinstance(_column, str) else _column.get_table_column().copy()) for _column in self.columns ) for task in self.tasks: percent = "[progress.percentage]{task.percentage:>3.0f}%".format(task=task) # noqa if task.fields.get("progress_type") == "overall": overall_table = Table.grid(*table_columns, padding=(0, 1), expand=self.expand) overall_table.add_row(BarColumn().render(task), percent, TimeElapsedColumn().render(task)) yield overall_table else: yield self.make_tasks_table([task]) class WorkflowExecution: def __init__(self, workflow, host, port, verbose, progress, local_paths, timeout=30, api_key: str | None = None): self.workflow = workflow self.host = host self.port = port self.verbose = verbose self.local_paths = local_paths self.client_id = str(uuid.uuid4()) self.outputs = [] self.progress = progress self.remaining_nodes = set(self.workflow.keys()) self.total_nodes = len(self.remaining_nodes) if progress: self.overall_task = self.progress.add_task("", total=self.total_nodes, progress_type="overall") self.current_node = None self.progress_task = None self.progress_node = None self.prompt_id = None self.ws = None self.timeout = timeout self.api_key = api_key def connect(self): self.ws = WebSocket() self.ws.connect(f"ws://{self.host}:{self.port}/ws?clientId={self.client_id}") def queue(self): data: dict = {"prompt": self.workflow, "client_id": self.client_id} if self.api_key: data["extra_data"] = {"api_key_comfy_org": self.api_key} req = request.Request( f"http://{self.host}:{self.port}/prompt", json.dumps(data).encode("utf-8"), ) try: resp = request.urlopen(req) body = json.loads(resp.read()) self.prompt_id = body["prompt_id"] except urllib.error.HTTPError as e: message = "An unknown error occurred" if e.status == 500: # This is normally just the generic internal server error message = e.read().decode() elif e.status == 400: # Bad Request - workflow failed validation on the server body = json.loads(e.read()) if body["node_errors"].keys(): message = json.dumps(body["node_errors"], indent=2) self.progress.stop() pprint(f"[bold red]Error running workflow\n{message}[/bold red]") raise typer.Exit(code=1) def watch_execution(self): self.ws.settimeout(self.timeout) while True: message = self.ws.recv() if isinstance(message, str): message = json.loads(message) if not self.on_message(message): break def update_overall_progress(self): self.progress.update(self.overall_task, completed=self.total_nodes - len(self.remaining_nodes)) def get_node_title(self, node_id): node = self.workflow.get(node_id) if node is None: return str(node_id) if "_meta" in node and "title" in node["_meta"]: return node["_meta"]["title"] return node["class_type"] def log_node(self, type, node_id): if not self.verbose: return node = self.workflow.get(node_id) if node is None: pprint(f"{type} : [bright_black]({node_id})[/]") return class_type = node["class_type"] title = self.get_node_title(node_id) if title != class_type: title += f"[bright_black] - {class_type}[/]" title += f"[bright_black] ({node_id})[/]" pprint(f"{type} : {title}") def format_image_path(self, img): filename = img["filename"] subfolder = img["subfolder"] if "subfolder" in img else None output_type = img["type"] or "output" if self.local_paths: if subfolder: filename = os.path.join(subfolder, filename) return os.path.join(workspace_manager.get_workspace_path()[0], output_type, filename) query = urllib.parse.urlencode(img) return f"http://{self.host}:{self.port}/view?{query}" def on_message(self, message): data = message["data"] if "data" in message else {} # Skip any messages that aren't about our prompt if "prompt_id" not in data or data["prompt_id"] != self.prompt_id: return True if message["type"] == "executing": return self.on_executing(data) elif message["type"] == "execution_cached": self.on_cached(data) elif message["type"] == "progress": self.on_progress(data) elif message["type"] == "executed": self.on_executed(data) elif message["type"] == "execution_error": self.on_error(data) return True def on_executing(self, data): if self.progress_task: self.progress.remove_task(self.progress_task) self.progress_task = None if data["node"] is None: return False else: if self.current_node: self.remaining_nodes.discard(self.current_node) self.update_overall_progress() self.current_node = data["node"] self.log_node("Executing", data["node"]) return True def on_cached(self, data): nodes = data["nodes"] for n in nodes: self.remaining_nodes.discard(n) self.log_node("Cached", n) self.update_overall_progress() def on_progress(self, data): node = data["node"] if self.progress_node != node: self.progress_node = node if self.progress_task: self.progress.remove_task(self.progress_task) self.progress_task = self.progress.add_task( self.get_node_title(node), total=data["max"], progress_type="node" ) self.progress.update(self.progress_task, completed=data["value"]) def on_executed(self, data): self.remaining_nodes.discard(data["node"]) self.update_overall_progress() if "output" not in data: return output = data["output"] if output is None or "images" not in output: return for img in output["images"]: self.outputs.append(self.format_image_path(img)) def on_error(self, data): pprint(f"[bold red]Error running workflow\n{json.dumps(data, indent=2)}[/bold red]") raise typer.Exit(code=1) ================================================ FILE: comfy_cli/config_manager.py ================================================ import configparser import os from importlib.metadata import version from comfy_cli import constants, logging from comfy_cli.utils import get_os, is_running, singleton @singleton class ConfigManager: def __init__(self): self.config = configparser.ConfigParser() self.background: tuple[str, int, int] | None = None self.load() @staticmethod def get_config_path(): return constants.DEFAULT_CONFIG[get_os()] def get_config_file_path(self): return os.path.join(self.get_config_path(), "config.ini") def write_config(self): config_file_path = os.path.join(self.get_config_path(), "config.ini") dir_path = os.path.dirname(config_file_path) if not os.path.exists(dir_path): os.mkdir(dir_path) with open(config_file_path, "w") as configfile: self.config.write(configfile) def set(self, key, value): """ Set a key-value pair in the config file. """ self.config["DEFAULT"][key] = value self.write_config() # Write changes to file immediately def get(self, key): """ Get a value from the config file. Returns None if the key does not exist. """ return self.config["DEFAULT"].get(key, None) # Returns None if the key does not exist def get_bool(self, key) -> bool | None: """ Get a boolean value from the config file using configparser's built-in getboolean, which accepts: true/false, yes/no, on/off, 1/0 (case-insensitive). Returns None if the key does not exist. """ if not self.config.has_option("DEFAULT", key): return None return self.config.getboolean("DEFAULT", key) def get_or_override(self, env_key: str, config_key: str, set_value: str | None = None) -> str | None: """ Resolves and conditionally stores a config value. The selected value and action is determined by the following priority: 1. Use CLI-provided `--set-*` value (if not None), and save it to config via `set()`. 2. Use process environment variable if exists (empty strings are allowed). 3. Otherwise, use the current config value via `get()`. Returns None if the selected value is an empty string. """ if set_value is not None: self.set(config_key, set_value) return set_value or None elif env_key in os.environ: return os.environ[env_key] or None else: return self.get(config_key) or None def load(self): config_file_path = self.get_config_file_path() if os.path.exists(config_file_path): self.config = configparser.ConfigParser() self.config.read(config_file_path) # TODO: We need a policy for clearing the tmp directory. tmp_path = os.path.join(self.get_config_path(), "tmp") if not os.path.exists(tmp_path): os.makedirs(tmp_path) if constants.CONFIG_KEY_BACKGROUND in self.config["DEFAULT"]: bg_info = self.config["DEFAULT"][constants.CONFIG_KEY_BACKGROUND].strip("()").split(",") bg_info = [item.strip().strip("'") for item in bg_info] self.background = bg_info[0], int(bg_info[1]), int(bg_info[2]) if not is_running(self.background[2]): self.remove_background() def get_env_data(self): """ Get environment data as a list of tuples for display. Returns: List[Tuple[str, str]]: List of (key, value) tuples for environment data. """ data = [] data.append(("Config Path", self.get_config_file_path())) launch_extras = "" if self.config.has_option("DEFAULT", constants.CONFIG_KEY_DEFAULT_WORKSPACE): data.append( ( "Default ComfyUI workspace", self.config["DEFAULT"][constants.CONFIG_KEY_DEFAULT_WORKSPACE], ) ) launch_extras = self.config["DEFAULT"].get(constants.CONFIG_KEY_DEFAULT_LAUNCH_EXTRAS, "") else: data.append(("Default ComfyUI workspace", "No default ComfyUI workspace")) if launch_extras == "": launch_extras = "[bold red]None[/bold red]" data.append(("Default ComfyUI launch extra options", launch_extras)) if self.config.has_option("DEFAULT", constants.CONFIG_KEY_RECENT_WORKSPACE): data.append( ( "Recent ComfyUI workspace", self.config["DEFAULT"][constants.CONFIG_KEY_RECENT_WORKSPACE], ) ) else: data.append(("Recent ComfyUI workspace", "No recent run")) tracking = self.get_bool(constants.CONFIG_KEY_ENABLE_TRACKING) if tracking is not None: data.append( ( "Tracking Analytics", "Enabled" if tracking else "Disabled", ) ) if self.config.has_option("DEFAULT", constants.CONFIG_KEY_BACKGROUND): bg_info = self.background if bg_info: data.append( ( "Background ComfyUI", f"http://{bg_info[0]}:{bg_info[1]} (pid={bg_info[2]})", ) ) else: data.append(("Background ComfyUI", "[bold red]No[/bold red]")) return data def remove_background(self): del self.config["DEFAULT"][constants.CONFIG_KEY_BACKGROUND] self.write_config() self.background = None def get_cli_version(self): # Note: this approach should work for users installing the CLI via # PyPi and Homebrew (e.g., pip install comfy-cli) try: return version("comfy-cli") except Exception as e: logging.debug(f"Error occurred while retrieving CLI version using importlib.metadata: {e}") return "0.0.0" ================================================ FILE: comfy_cli/constants.py ================================================ import os from enum import Enum class OS(str, Enum): WINDOWS = "windows" MACOS = "macos" LINUX = "linux" class PROC(str, Enum): X86_64 = "x86_64" ARM = "arm" COMFY_GITHUB_URL = "https://github.com/comfyanonymous/ComfyUI" MANAGER_REQUIREMENTS_FILE = "manager_requirements.txt" DEFAULT_COMFY_MODEL_PATH = "models" DEFAULT_COMFY_WORKSPACE = { OS.WINDOWS: os.path.join(os.path.expanduser("~"), "Documents", "comfy", "ComfyUI"), OS.MACOS: os.path.join(os.path.expanduser("~"), "Documents", "comfy", "ComfyUI"), OS.LINUX: os.path.join(os.path.expanduser("~"), "comfy", "ComfyUI"), } DEFAULT_CONFIG = { OS.WINDOWS: os.path.join(os.path.expanduser("~"), "AppData", "Local", "comfy-cli"), OS.MACOS: os.path.join(os.path.expanduser("~"), "Library", "Application Support", "comfy-cli"), OS.LINUX: os.path.join(os.path.expanduser("~"), ".config", "comfy-cli"), } CONTEXT_KEY_WORKSPACE = "workspace" CONTEXT_KEY_RECENT = "recent" CONTEXT_KEY_HERE = "here" CONFIG_KEY_DEFAULT_WORKSPACE = "default_workspace" CONFIG_KEY_DEFAULT_LAUNCH_EXTRAS = "default_launch_extras" CONFIG_KEY_RECENT_WORKSPACE = "recent_workspace" CONFIG_KEY_ENABLE_TRACKING = "enable_tracking" CONFIG_KEY_USER_ID = "user_id" CONFIG_KEY_INSTALL_EVENT_TRIGGERED = "install_event_triggered" CONFIG_KEY_BACKGROUND = "background" CONFIG_KEY_MANAGER_GUI_ENABLED = "manager_gui_enabled" # Legacy, kept for backward compatibility CONFIG_KEY_MANAGER_GUI_MODE = "manager_gui_mode" # Valid: "disable", "enable-gui", "disable-gui", "enable-legacy-gui" CONFIG_KEY_UV_COMPILE_DEFAULT = "uv_compile_default" CIVITAI_API_TOKEN_KEY = "civitai_api_token" CIVITAI_API_TOKEN_ENV_KEY = "CIVITAI_API_TOKEN" CIVITAI_ALLOWED_HOSTS: tuple[str, ...] = ("civitai.com", "civitai.red") HF_API_TOKEN_KEY = "hf_api_token" HF_API_TOKEN_ENV_KEY = "HF_API_TOKEN" ARIA2_SERVER_ENV_KEY = "COMFYUI_MANAGER_ARIA2_SERVER" ARIA2_SECRET_ENV_KEY = "COMFYUI_MANAGER_ARIA2_SECRET" CONFIG_KEY_DEFAULT_DOWNLOADER = "default_downloader" DEFAULT_TRACKING_VALUE = True COMFY_LOCK_YAML_FILE = "comfy.lock.yaml" # TODO: figure out a better way to check if this is a comfy repo COMFY_ORIGIN_URL_CHOICES = { "git@github.com:Comfy-Org/ComfyUI.git", "git@github.com:comfyanonymous/ComfyUI.git", "git@github.com:drip-art/comfy.git", "git@github.com:ltdrdata/ComfyUI.git", "https://github.com/Comfy-Org/ComfyUI.git", "https://github.com/comfyanonymous/ComfyUI.git", "https://github.com/drip-art/ComfyUI.git", "https://github.com/ltdrdata/ComfyUI.git", "https://github.com/Comfy-Org/ComfyUI", "https://github.com/comfyanonymous/ComfyUI", "https://github.com/drip-art/ComfyUI", "https://github.com/ltdrdata/ComfyUI", } class CUDAVersion(str, Enum): v13_0 = "13.0" v12_9 = "12.9" v12_8 = "12.8" v12_6 = "12.6" v12_4 = "12.4" v12_1 = "12.1" v11_8 = "11.8" class ROCmVersion(str, Enum): v7_1 = "7.1" v7_0 = "7.0" v6_3 = "6.3" v6_2 = "6.2" v6_1 = "6.1" class GPU_OPTION(str, Enum): CPU = None NVIDIA = "nvidia" AMD = "amd" INTEL_ARC = "intel_arc" MAC_M_SERIES = "mac_m_series" MAC_INTEL = "mac_intel" # Referencing supported pt extension from ComfyUI # https://github.com/comfyanonymous/ComfyUI/blob/a88b0ebc2d2f933c94e42aa689c42e836eedaf3c/folder_paths.py#L5 SUPPORTED_PT_EXTENSIONS = (".ckpt", ".pt", ".bin", ".pth", ".safetensors") NODE_ZIP_FILENAME = "node.zip" # The default minor version series to download from python-build-standalone. # The exact patch version is resolved dynamically from the release metadata. DEFAULT_STANDALONE_PYTHON_MINOR_VERSION = "3.12" ================================================ FILE: comfy_cli/cuda_detect.py ================================================ """Auto-detect CUDA driver version and resolve the best PyTorch wheel suffix.""" from __future__ import annotations import ctypes import logging import os import platform import re import subprocess logger = logging.getLogger(__name__) PYTORCH_CUDA_WHEELS: list[str] = [ "cu130", "cu129", "cu128", "cu126", "cu124", "cu121", "cu118", ] DEFAULT_CUDA_TAG = "cu126" def _load_libcuda() -> ctypes.CDLL: """Load the NVIDIA CUDA driver library. Raises OSError when the library cannot be found on any known path. """ system = platform.system() if system == "Windows": candidates = ["nvcuda.dll"] else: candidates = [ "libcuda.so.1", "/usr/lib/wsl/lib/libcuda.so.1", "/usr/lib64/nvidia/libcuda.so.1", "/usr/lib/x86_64-linux-gnu/libcuda.so.1", ] for path in candidates: try: return ctypes.CDLL(path) except OSError: continue raise OSError("Could not load CUDA driver library from any known path") def _detect_via_ctypes() -> int | None: """Return the raw driver version int from cuDriverGetVersion, or None.""" try: libcuda = _load_libcuda() except OSError: logger.debug("Failed to load libcuda") return None try: ret = libcuda.cuInit(0) if ret != 0: logger.debug("cuInit returned %d", ret) return None version = ctypes.c_int() ret = libcuda.cuDriverGetVersion(ctypes.byref(version)) if ret != 0: logger.debug("cuDriverGetVersion returned %d", ret) return None return version.value except Exception: logger.debug("ctypes CUDA call failed", exc_info=True) return None def _detect_via_nvidia_smi() -> tuple[int, int] | None: """Parse CUDA version from nvidia-smi output, or return None.""" try: output = subprocess.check_output( ["nvidia-smi"], text=True, timeout=10, stderr=subprocess.DEVNULL, ) except (FileNotFoundError, subprocess.SubprocessError): return None match = re.search(r"CUDA Version:\s*(\d+)\.(\d+)", output) if not match: return None return int(match.group(1)), int(match.group(2)) def detect_cuda_driver_version() -> tuple[int, int] | None: """Detect the CUDA driver version. Tries ctypes (cuDriverGetVersion) first, then falls back to nvidia-smi. Returns (major, minor) or None if detection fails entirely. """ saved = os.environ.get("CUDA_VISIBLE_DEVICES") try: if saved is not None: os.environ.pop("CUDA_VISIBLE_DEVICES", None) raw = _detect_via_ctypes() if raw is not None: major = raw // 1000 minor = (raw % 1000) // 10 return major, minor return _detect_via_nvidia_smi() finally: if saved is not None: os.environ["CUDA_VISIBLE_DEVICES"] = saved def resolve_cuda_wheel(driver_version: tuple[int, int]) -> str | None: """Map a driver CUDA version to the best PyTorch wheel suffix. Picks the highest wheel tag whose CUDA version <= the driver version. Returns None if the driver is too old for any known wheel. """ drv_major, drv_minor = driver_version for tag in PYTORCH_CUDA_WHEELS: digits = tag[2:] whl_major = int(digits[:2]) whl_minor = int(digits[2:]) if (whl_major, whl_minor) <= (drv_major, drv_minor): return tag return None ================================================ FILE: comfy_cli/env_checker.py ================================================ """ Module for checking various env and state conditions. """ import os import sys import requests from rich.console import Console from comfy_cli.config_manager import ConfigManager from comfy_cli.utils import singleton console = Console() def format_python_version(version_info): """ Formats the Python version string to display the major and minor version numbers. If the minor version is greater than 8, the version is displayed in normal text. If the minor version is 8 or less, the version is displayed in bold red text to indicate an older version. Args: version_info (sys.version_info): The Python version information Returns: str: The formatted Python version string. """ if version_info.major == 3 and version_info.minor > 8: return f"{version_info.major}.{version_info.minor}.{version_info.micro}" return f"[bold red]{version_info.major}.{version_info.minor}.{version_info.micro}[/bold red]" def check_comfy_server_running(port=8188, host="localhost"): """ Checks if the Comfy server is running by making a GET request to the /history endpoint. Returns: bool: True if the Comfy server is running, False otherwise. """ try: response = requests.get(f"http://{host}:{port}/history") return response.status_code == 200 except requests.exceptions.RequestException: return False @singleton class EnvChecker: """ Provides an `EnvChecker` class to check the current environment and print information about it. - `virtualenv_path`: The path to the current virtualenv, or "Not Used" if not in a virtualenv. - `conda_env`: The name of the current conda environment, or "Not Used" if not in a conda environment. - `python_version`: The version information for the current Python installation. - `currently_in_comfy_repo`: A boolean indicating whether the current directory is part of the Comfy repository. The `EnvChecker` class is a singleton that checks the current environment and stores information about the Python version, virtualenv path, conda environment, and whether the current directory is part of the Comfy repository. The `print()` method of the `EnvChecker` class displays the collected environment information in a formatted table. """ def __init__(self): self.virtualenv_path = None self.conda_env = None self.python_version = sys.version_info self.check() def is_isolated_env(self): return self.virtualenv_path or self.conda_env def get_isolated_env(self): if self.virtualenv_path: return self.virtualenv_path if self.conda_env: return self.conda_env return None def check(self): self.virtualenv_path = os.environ.get("VIRTUAL_ENV") if os.environ.get("VIRTUAL_ENV") else None self.conda_env = os.environ.get("CONDA_DEFAULT_ENV") if os.environ.get("CONDA_DEFAULT_ENV") else None def fill_print_table(self): data = [] data.append(("Python Version", format_python_version(sys.version_info))) data.append(("Python Executable", sys.executable)) data.append( ( "Virtualenv Path", self.virtualenv_path if self.virtualenv_path else "Not Used", ) ) data.append(("Conda Env", self.conda_env if self.conda_env else "Not Used")) config_data = ConfigManager().get_env_data() data.extend(config_data) if check_comfy_server_running(): data.append( ( "Comfy Server Running", "[bold green]Yes[/bold green]\nhttp://localhost:8188", ) ) else: data.append(("Comfy Server Running", "[bold red]No[/bold red]")) return data ================================================ FILE: comfy_cli/file_utils.py ================================================ import json import os import pathlib import subprocess import time import zipfile from http import HTTPStatus import httpx import requests from pathspec import PathSpec from comfy_cli import constants, ui class DownloadException(Exception): pass def guess_status_code_reason(status_code: int, message: str) -> str: if status_code == 401: def parse_json(input_data): try: # Check if the input is a byte string if isinstance(input_data, bytes): # Decode the byte string to a regular string input_data = input_data.decode("utf-8") # Parse the string as JSON return json.loads(input_data) except json.JSONDecodeError as e: # Handle JSON decoding error print(f"JSON decoding error: {e}") msg_json = parse_json(message) if msg_json is not None: if "message" in msg_json: return f"Unauthorized download ({status_code}).\n{msg_json['message']}\nor you can set a CivitAI API token using `comfy model download --set-civitai-api-token` or via the `{constants.CIVITAI_API_TOKEN_ENV_KEY}` environment variable" return f"Unauthorized download ({status_code}), you might need to manually log into a browser to download this" elif status_code == 403: return f"Forbidden url ({status_code}), you might need to manually log into a browser to download this" elif status_code == 404: return "File not found on server (404)" return f"Unknown error occurred (status code: {status_code})" def check_unauthorized(url: str, headers: dict | None = None) -> bool: """ Perform a GET request to the given URL and check if the response status code is 401 (Unauthorized). Args: url (str): The URL to send the GET request to. headers (Optional[dict]): Optional headers to include in the request. Returns: bool: True if the response status code is 401, False otherwise. """ try: response = requests.get(url, headers=headers, allow_redirects=True, stream=True) return response.status_code == 401 except requests.RequestException: # If there's an error making the request, we can't determine if it's unauthorized return False def _poll_aria2_download(download) -> None: """Poll an aria2 download until completion, showing progress.""" import time from rich.progress import ( BarColumn, DownloadColumn, Progress, TimeRemainingColumn, TransferSpeedColumn, ) with Progress( "[progress.description]{task.description}", BarColumn(), DownloadColumn(), TransferSpeedColumn(), TimeRemainingColumn(), transient=True, ) as progress: task = progress.add_task("Downloading...", total=None) while True: try: download.update() except Exception as e: raise DownloadException(f"Lost connection to aria2 RPC server: {e}") from e if download.total_length > 0: progress.update(task, total=download.total_length, completed=download.completed_length) if download.is_complete: if download.total_length > 0: progress.update(task, completed=download.total_length) break elif download.has_failed: raise DownloadException( f"aria2 download failed: {download.error_message} (code: {download.error_code})" ) elif download.is_removed: raise DownloadException("aria2 download was removed before completion") time.sleep(0.5) def _download_file_aria2(url: str, local_filepath: pathlib.Path, headers: dict | None = None) -> None: """Download a file using aria2 RPC.""" try: import aria2p except ImportError: raise DownloadException( "aria2p is required for aria2 downloads. Install it with: pip install aria2p\n" "You also need a running aria2c daemon. See: https://aria2.github.io/" ) from None server = os.environ.get(constants.ARIA2_SERVER_ENV_KEY) if not server: raise DownloadException( f"aria2 downloader selected but {constants.ARIA2_SERVER_ENV_KEY} environment variable is not set.\n" f"Set it to your aria2 RPC server URL, e.g.: export {constants.ARIA2_SERVER_ENV_KEY}=http://localhost:6800" ) secret = os.environ.get(constants.ARIA2_SECRET_ENV_KEY, "") from urllib.parse import urlparse if "://" not in server: server = f"http://{server}" parsed = urlparse(server) if not parsed.hostname: raise DownloadException(f"Invalid aria2 server URL (cannot parse hostname): {server}") host = f"{parsed.scheme}://{parsed.hostname}" port = parsed.port or 6800 try: api = aria2p.API(aria2p.Client(host=host, port=port, secret=secret)) except Exception as e: raise DownloadException(f"Failed to connect to aria2 RPC server at {server}: {e}") from e options = { "dir": str(local_filepath.parent), "out": local_filepath.name, } if headers: options["header"] = [f"{k}: {v}" for k, v in headers.items()] try: download = api.add_uris([url], options=options) except Exception as e: raise DownloadException(f"Failed to add download to aria2: {e}") from e _poll_aria2_download(download) if not local_filepath.exists(): raise DownloadException(f"aria2 download completed but file not found at expected path: {local_filepath}") _VALID_DOWNLOADERS = {"httpx", "aria2"} _DOWNLOAD_MAX_RETRIES = 3 _DOWNLOAD_RETRY_BACKOFF = 2 # seconds multiplier _DOWNLOAD_TIMEOUT = httpx.Timeout(10.0, read=300.0) _TRANSIENT_EXCEPTIONS = ( httpx.TimeoutException, httpx.NetworkError, httpx.ProtocolError, httpx.ProxyError, ) # HTTP statuses that typically indicate a transient server-side or rate-limit # problem worth retrying with backoff. Auth/not-found/redirect statuses stay # out of this set so they fail fast. _RETRIABLE_STATUSES = frozenset({408, 429, 500, 502, 503, 504}) class _TransientHTTPStatusError(Exception): """Retriable HTTP status returned by the server (e.g. 500/503/429).""" def __init__(self, status_code: int, reason: str): self.status_code = status_code self.reason = reason super().__init__(f"HTTP {status_code}: {reason}") _RETRIABLE_EXCEPTIONS = _TRANSIENT_EXCEPTIONS + (_TransientHTTPStatusError,) def _cleanup_partial(filepath: pathlib.Path) -> None: """Remove a partially downloaded file if it exists.""" try: filepath.unlink(missing_ok=True) except OSError: pass def _friendly_network_error(exc: Exception) -> str: """Return a user-friendly description of a network error.""" if isinstance(exc, _TransientHTTPStatusError): try: phrase = HTTPStatus(exc.status_code).phrase return f"the server returned HTTP {exc.status_code} {phrase}" except ValueError: return f"the server returned HTTP {exc.status_code}" if isinstance(exc, httpx.InvalidURL): return f"invalid URL ({exc})" if isinstance(exc, httpx.ReadTimeout): return "the server stopped sending data (read timeout)" if isinstance(exc, httpx.ConnectTimeout): return "could not connect to the server (connect timeout)" if isinstance(exc, httpx.TimeoutException): return f"the operation timed out ({type(exc).__name__})" if isinstance(exc, httpx.NetworkError): return f"a network error occurred ({type(exc).__name__}: {exc})" if isinstance(exc, httpx.ProtocolError): return f"a protocol error occurred ({type(exc).__name__}: {exc})" if isinstance(exc, httpx.ProxyError): return f"a proxy error occurred ({type(exc).__name__}: {exc})" return str(exc) def _download_file_httpx( url: str, local_filepath: pathlib.Path, headers: dict | None = None, *, state: dict | None = None, ) -> None: """Download a file using httpx streaming. Raises on HTTP or network errors. If ``state`` is provided, ``state["file_opened"]`` is set to True immediately after the output file is opened for writing. Callers use this to distinguish failures raised *before* the destination was touched (HTTP errors, ConnectError, etc.) from failures raised *after* writing started (mid-stream ReadTimeout), so they can avoid deleting an unrelated pre-existing file at the destination. """ with httpx.stream("GET", url, follow_redirects=True, headers=headers, timeout=_DOWNLOAD_TIMEOUT) as response: if response.status_code != 200: try: error_body = response.read() except _TRANSIENT_EXCEPTIONS: error_body = "" status_reason = guess_status_code_reason(response.status_code, error_body) if response.status_code in _RETRIABLE_STATUSES: raise _TransientHTTPStatusError(response.status_code, status_reason) raise DownloadException(f"Failed to download file.\n{status_reason}") content_length = response.headers.get("Content-Length") total = int(content_length) if content_length is not None else None if total is not None: description = f"Downloading {total // 1024 // 1024} MB" else: description = "Downloading..." with open(local_filepath, "wb") as f: if state is not None: state["file_opened"] = True for data in ui.show_progress( response.iter_bytes(), total, description=description, ): f.write(data) def download_file(url: str, local_filepath: pathlib.Path, headers: dict | None = None, downloader: str = "httpx"): """Helper function to download a file.""" if downloader not in _VALID_DOWNLOADERS: raise DownloadException( f"Unknown downloader: {downloader!r}. Valid options: {', '.join(sorted(_VALID_DOWNLOADERS))}" ) local_filepath.parent.mkdir(parents=True, exist_ok=True) if downloader == "aria2": return _download_file_aria2(url, local_filepath, headers) last_exc: Exception | None = None state: dict = {"file_opened": False} for attempt in range(_DOWNLOAD_MAX_RETRIES): state["file_opened"] = False try: _download_file_httpx(url, local_filepath, headers, state=state) return except _RETRIABLE_EXCEPTIONS as exc: last_exc = exc # Only clean up if _download_file_httpx actually opened the destination — # otherwise we'd delete an unrelated pre-existing file at the same path. if state["file_opened"]: _cleanup_partial(local_filepath) if attempt < _DOWNLOAD_MAX_RETRIES - 1: wait = _DOWNLOAD_RETRY_BACKOFF * (attempt + 1) print(f"Download error (attempt {attempt + 1}/{_DOWNLOAD_MAX_RETRIES}): {_friendly_network_error(exc)}") print(f"Retrying in {wait}s...") time.sleep(wait) except (httpx.HTTPError, httpx.InvalidURL) as exc: # Non-retriable httpx errors (e.g. UnsupportedProtocol, TooManyRedirects, # DecodingError, InvalidURL). Fail fast and convert to DownloadException # so callers only need to handle one error type. # InvalidURL inherits directly from Exception (not HTTPError), hence the # explicit inclusion. if state["file_opened"]: _cleanup_partial(local_filepath) raise DownloadException(f"Download failed: {_friendly_network_error(exc)}") from exc except KeyboardInterrupt: # Only prompt/cleanup if we actually opened the destination this attempt. # If the interrupt arrived during connection setup, there is no partial # file and the destination may hold an unrelated pre-existing file. if state["file_opened"]: delete_eh = ui.prompt_confirm_action("Download interrupted, cleanup files?", True) if delete_eh: _cleanup_partial(local_filepath) raise raise DownloadException( f"Download failed after {_DOWNLOAD_MAX_RETRIES} attempts: " f"{_friendly_network_error(last_exc)}\n" f"Please try again later." ) from last_exc def _load_comfyignore_spec(ignore_filename: str = ".comfyignore") -> PathSpec | None: if not os.path.exists(ignore_filename): return None try: with open(ignore_filename, encoding="utf-8") as ignore_file: patterns = [line.strip() for line in ignore_file if line.strip() and not line.lstrip().startswith("#")] except OSError: return None if not patterns: return None return PathSpec.from_lines("gitwildmatch", patterns) def list_git_tracked_files(base_path: str | os.PathLike = ".") -> list[str]: try: result = subprocess.check_output( ["git", "-C", os.fspath(base_path), "ls-files"], text=True, ) except (subprocess.SubprocessError, FileNotFoundError): return [] return [line for line in result.splitlines() if line.strip()] def _normalize_path(path: str) -> str: rel_path = os.path.relpath(path, start=".") if rel_path == ".": return "" return rel_path.replace("\\", "/") def _is_force_included(rel_path: str, include_prefixes: list[str]) -> bool: return any(rel_path == prefix or rel_path.startswith(prefix + "/") for prefix in include_prefixes if prefix) def zip_files(zip_filename, includes=None): """Zip git-tracked files respecting optional .comfyignore patterns.""" includes = includes or [] include_prefixes: list[str] = [_normalize_path(os.path.normpath(include.lstrip("/"))) for include in includes] included_paths: set[str] = set() git_files: list[str] = [] ignore_spec = _load_comfyignore_spec() def should_ignore(rel_path: str) -> bool: if not ignore_spec: return False if _is_force_included(rel_path, include_prefixes): return False return ignore_spec.match_file(rel_path) zip_target = os.fspath(zip_filename) zip_abs_path = os.path.abspath(zip_target) zip_basename = os.path.basename(zip_abs_path) git_files = list_git_tracked_files(".") if not git_files: print("Warning: Not in a git repository or git not installed. Zipping all files.") with zipfile.ZipFile(zip_target, "w", zipfile.ZIP_DEFLATED) as zipf: if git_files: for file_path in git_files: if file_path == zip_basename: continue rel_path = _normalize_path(file_path) if should_ignore(rel_path): continue actual_path = os.path.normpath(file_path) if os.path.abspath(actual_path) == zip_abs_path: continue if os.path.exists(actual_path): arcname = rel_path or os.path.basename(actual_path) zipf.write(actual_path, arcname) included_paths.add(rel_path) else: print(f"File not found. Not including in zip: {file_path}") else: for root, dirs, files in os.walk("."): if ".git" in dirs: dirs.remove(".git") dirs[:] = [d for d in dirs if not should_ignore(_normalize_path(os.path.join(root, d)))] for file in files: file_path = os.path.join(root, file) rel_path = _normalize_path(file_path) if ( os.path.abspath(file_path) == zip_abs_path or rel_path in included_paths or should_ignore(rel_path) ): continue arcname = rel_path or file_path zipf.write(file_path, arcname) included_paths.add(rel_path) for include_dir in includes: include_dir = os.path.normpath(include_dir.lstrip("/")) rel_include = _normalize_path(include_dir) if os.path.isfile(include_dir): if not should_ignore(rel_include) and rel_include not in included_paths: arcname = rel_include or include_dir zipf.write(include_dir, arcname) included_paths.add(rel_include) continue if not os.path.exists(include_dir): print(f"Warning: Included directory '{include_dir}' does not exist, creating empty directory") arcname = rel_include or include_dir if not arcname.endswith("/"): arcname = arcname + "/" zipf.writestr(arcname, "") continue for root, dirs, files in os.walk(include_dir): dirs[:] = [d for d in dirs if not should_ignore(_normalize_path(os.path.join(root, d)))] for file in files: file_path = os.path.join(root, file) rel_path = _normalize_path(file_path) if ( os.path.abspath(file_path) == zip_abs_path or rel_path in included_paths or should_ignore(rel_path) ): continue arcname = rel_path or file_path zipf.write(file_path, arcname) included_paths.add(rel_path) def upload_file_to_signed_url(signed_url: str, file_path: str): with open(file_path, "rb") as f: headers = {"Content-Type": "application/zip"} response = requests.put(signed_url, data=f, headers=headers) if response.status_code == 200: print("Upload successful.") else: raise Exception(f"Upload failed with status code: {response.status_code}. Error: {response.text}") def extract_package_as_zip(file_path: pathlib.Path, extract_path: pathlib.Path): try: with zipfile.ZipFile(file_path, "r") as zip_ref: zip_ref.extractall(extract_path) print(f"Extracted zip file to {extract_path}") except zipfile.BadZipFile: print("File is not a zip or is corrupted.") ================================================ FILE: comfy_cli/git_utils.py ================================================ import os import subprocess from rich.console import Console from rich.panel import Panel from rich.text import Text from comfy_cli.command.github.pr_info import PRInfo console = Console() def sanitize_for_local_branch(branch_name: str) -> str: if not branch_name: return "unknown" sanitized = branch_name.replace("/", "-") while "--" in sanitized: sanitized = sanitized.replace("--", "-") sanitized = sanitized.strip("-") return sanitized or "unknown" def git_checkout_tag(repo_path: str, tag: str) -> bool: """ Checkout a specific Git tag in the given repository. Skips the network ``git fetch --tags`` when the tag already exists locally. This avoids a redundant round-trip on the happy path (the caller usually just cloned the repo or just ran a fetch via the resolver) and lets offline installs proceed when the tag is already cached. Only when the tag is absent locally do we attempt to fetch — and a failed fetch in that case is a real, unrecoverable error (``check=True`` surfaces it as before). :param repo_path: Path to the Git repository :param tag: The tag to checkout :return: True if the checkout succeeds, False if any git command failed. """ original_dir = os.getcwd() try: # Change to the repository directory os.chdir(repo_path) # Skip the network fetch when the tag is already present locally. tag_present_locally = ( subprocess.run( ["git", "rev-parse", "--verify", f"refs/tags/{tag}"], capture_output=True, text=True, check=False, ).returncode == 0 ) if not tag_present_locally: subprocess.run(["git", "fetch", "--tags"], check=True, capture_output=True, text=True) # Checkout the specified tag subprocess.run(["git", "checkout", tag], check=True, capture_output=True, text=True) console.print(f"[bold green]Successfully checked out tag: [cyan]{tag}[/cyan][/bold green]") return True except subprocess.CalledProcessError as e: error_message = Text() error_message.append("Git Checkout Error", style="bold red on white") error_message.append("\n\nFailed to checkout tag: ", style="bold yellow") error_message.append(f"[cyan]{tag}[/cyan]") error_message.append("\n\nError details:", style="bold red") error_message.append(f"\n{str(e)}", style="italic") if e.stderr: error_message.append("\n\nError output:", style="bold red") error_message.append(f"\n{e.stderr}", style="italic yellow") console.print( Panel( error_message, title="[bold white on red]Git Checkout Failed[/bold white on red]", border_style="red", expand=False, ) ) return False finally: # Ensure we always return to the original directory os.chdir(original_dir) def checkout_pr(repo_path: str, pr_info: PRInfo) -> bool: original_dir = os.getcwd() try: os.chdir(repo_path) if pr_info.is_fork: remote_name = f"pr-{pr_info.number}-{pr_info.user}" result = subprocess.run(["git", "remote", "get-url", remote_name], capture_output=True, text=True) if result.returncode != 0: subprocess.run( ["git", "remote", "add", remote_name, pr_info.head_repo_url], check=True, capture_output=True, text=True, ) subprocess.run( ["git", "fetch", remote_name, pr_info.head_branch], check=True, capture_output=True, text=True ) # fix: "feature/add-support" -> "pr-123-feature-add-support" sanitized_branch = sanitize_for_local_branch(pr_info.head_branch) local_branch = f"pr-{pr_info.number}-{sanitized_branch}" subprocess.run( ["git", "checkout", "-B", local_branch, f"{remote_name}/{pr_info.head_branch}"], check=True, capture_output=True, text=True, ) else: subprocess.run(["git", "fetch", "origin", pr_info.head_branch], check=True, capture_output=True, text=True) sanitized_branch = sanitize_for_local_branch(pr_info.head_branch) local_branch = f"pr-{pr_info.number}-{sanitized_branch}" subprocess.run( ["git", "checkout", "-B", local_branch, f"origin/{pr_info.head_branch}"], check=True, capture_output=True, text=True, ) console.print(f"[bold green]Successfully checked out PR #{pr_info.number}: {pr_info.title}[/bold green]") console.print(f"[bold yellow]Local branch:[/bold yellow] {local_branch}") return True except subprocess.CalledProcessError as e: error_message = Text() error_message.append("Git PR Checkout Error", style="bold red on white") error_message.append(f"\n\nFailed to checkout PR #{pr_info.number}", style="bold yellow") error_message.append(f"\nTitle: {pr_info.title}", style="italic") error_message.append(f"\nBranch: {pr_info.head_branch}", style="italic") if e.stderr: error_message.append("\n\nError output:", style="bold red") error_message.append(f"\n{e.stderr}", style="italic yellow") console.print( Panel( error_message, title="[bold white on red]PR Checkout Failed[/bold white on red]", border_style="red", expand=False, ) ) return False finally: os.chdir(original_dir) ================================================ FILE: comfy_cli/logging.py ================================================ """ This module provides logging utilities for the CLI. Note: we could potentially change the logging library or the way we log messages in the future. Therefore, it's a good idea to encapsulate logging-related code in a separate module. """ import logging import os def setup_logging(): # TODO: consider supporting different ways of outputting logs # Note: by default, the log level is set to WARN log_levels = { "DEBUG": logging.DEBUG, "INFO": logging.INFO, "WARNING": logging.WARNING, "ERROR": logging.ERROR, "CRITICAL": logging.CRITICAL, } log_level_key = os.getenv("LOG_LEVEL", "ERROR").upper() logging.basicConfig( level=log_levels.get(log_level_key, logging.WARNING), format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) def debug(message): logging.debug(message) def info(message): logging.info(message) def warning(message): logging.warning(message) def error(message): logging.error(message) # TODO: consider tracking errors to Mixpanel as well. ================================================ FILE: comfy_cli/pr_cache.py ================================================ """PR Cache Management for temporary PR testing. This module provides functionality for caching built frontend PRs to enable quick switching between different PR versions without rebuilding. """ from __future__ import annotations import json import shutil from datetime import datetime, timedelta from pathlib import Path from rich import print as rprint from comfy_cli.config_manager import ConfigManager class PRCache: """Manages cached PR builds for quick switching. This class handles the caching of built frontend PRs, including: - Cache directory management - Cache validity checking with age limits - Automatic cleanup of old/excess cache entries - Human-readable cache information display """ # Default cache settings DEFAULT_MAX_CACHE_AGE_DAYS = 7 # Cache entries older than this are considered stale DEFAULT_MAX_CACHE_ITEMS = 10 # Maximum number of cached PRs to keep def __init__(self) -> None: """Initialize PR cache with default settings.""" self.cache_dir = Path(ConfigManager().get_config_path()) / "pr-cache" self.cache_dir.mkdir(parents=True, exist_ok=True) self.max_cache_age = timedelta(days=self.DEFAULT_MAX_CACHE_AGE_DAYS) self.max_cache_items = self.DEFAULT_MAX_CACHE_ITEMS def get_frontend_cache_path(self, pr_info) -> Path: """Get cache path for a frontend PR""" # Use PR number and repo as cache key cache_key = f"{pr_info.user}-{pr_info.number}-{pr_info.head_branch}" # Sanitize for filesystem cache_key = "".join(c if c.isalnum() or c in "-_" else "_" for c in cache_key) return self.cache_dir / "frontend" / cache_key def get_cache_info_path(self, cache_path: Path) -> Path: """Get path to cache info file""" return cache_path / ".cache-info.json" def is_cache_valid(self, pr_info, cache_path: Path) -> bool: """Check if cached build is still valid""" info_path = self.get_cache_info_path(cache_path) if not info_path.exists(): return False try: with open(info_path, encoding="utf-8") as file: cache_info = json.load(file) # Check if cache metadata matches if not ( cache_info.get("pr_number") == pr_info.number and cache_info.get("head_branch") == pr_info.head_branch and cache_info.get("user") == pr_info.user ): return False # Check if cache is too old cached_at = cache_info.get("cached_at") if cached_at: cache_time = datetime.fromisoformat(cached_at) if datetime.now() - cache_time > self.max_cache_age: return False return True except (json.JSONDecodeError, OSError): return False def save_cache_info(self, pr_info, cache_path: Path) -> None: """Save cache metadata.""" info_path = self.get_cache_info_path(cache_path) cache_info = { "pr_number": pr_info.number, "pr_title": pr_info.title, "user": pr_info.user, "head_branch": pr_info.head_branch, "head_repo_url": pr_info.head_repo_url, "cached_at": datetime.now().isoformat(), } with open(info_path, "w", encoding="utf-8") as file: json.dump(cache_info, file, indent=2) # Enforce cache limits after saving new cache self.enforce_cache_limits() def get_cached_frontend_path(self, pr_info) -> Path | None: """Get path to cached frontend build if valid""" cache_path = self.get_frontend_cache_path(pr_info) dist_path = cache_path / "repo" / "dist" if dist_path.exists() and self.is_cache_valid(pr_info, cache_path): return dist_path return None def _load_cache_info(self, cache_dir: Path) -> dict | None: """Load cache info from a directory.""" info_path = self.get_cache_info_path(cache_dir) if not info_path.exists(): return None try: with open(info_path, encoding="utf-8") as file: return json.load(file) except (json.JSONDecodeError, OSError): return None def _clean_specific_pr_cache(self, frontend_cache: Path, pr_number: int) -> None: """Clean cache for a specific PR number.""" for cache_dir in frontend_cache.iterdir(): if not cache_dir.is_dir(): continue info = self._load_cache_info(cache_dir) if info and info.get("pr_number") == pr_number: rprint(f"[yellow]Removing cache for PR #{pr_number}[/yellow]") shutil.rmtree(cache_dir) break def clean_frontend_cache(self, pr_number: int | None = None) -> None: """Clean frontend cache (specific PR or all).""" frontend_cache = self.cache_dir / "frontend" if not frontend_cache.exists(): return if pr_number: self._clean_specific_pr_cache(frontend_cache, pr_number) else: # Clean all rprint("[yellow]Removing all frontend PR cache[/yellow]") shutil.rmtree(frontend_cache) def _calculate_cache_size_mb(self, cache_dir: Path) -> float: """Calculate the size of a cache directory in MB.""" total_size = sum(f.stat().st_size for f in cache_dir.rglob("*") if f.is_file()) return total_size / (1024 * 1024) def _get_cache_info_with_metadata(self, cache_dir: Path) -> dict | None: """Get cache info with additional metadata like path and size.""" info = self._load_cache_info(cache_dir) if info: info["cache_path"] = str(cache_dir) info["size_mb"] = self._calculate_cache_size_mb(cache_dir) return info def list_cached_frontends(self) -> list[dict]: """List all cached frontend PRs.""" frontend_cache = self.cache_dir / "frontend" if not frontend_cache.exists(): return [] cached_prs = [] for cache_dir in frontend_cache.iterdir(): if not cache_dir.is_dir(): continue info = self._get_cache_info_with_metadata(cache_dir) if info: cached_prs.append(info) return sorted(cached_prs, key=lambda x: x.get("cached_at", ""), reverse=True) def _is_cache_expired(self, cached_at: str) -> bool: """Check if a cache entry is expired based on its timestamp.""" try: cache_time = datetime.fromisoformat(cached_at) return datetime.now() - cache_time > self.max_cache_age except (ValueError, TypeError): return True # Consider invalid timestamps as expired def _get_expired_items(self, cached_items: list[dict]) -> list[dict]: """Get list of expired cache items.""" expired = [] for item in cached_items: cached_at = item.get("cached_at") if cached_at and self._is_cache_expired(cached_at): expired.append(item) return expired def _get_excess_items(self, cached_items: list[dict], expired_items: list[dict]) -> list[dict]: """Get list of items that exceed the maximum cache limit.""" remaining_items = [item for item in cached_items if item not in expired_items] if len(remaining_items) > self.max_cache_items: # Return oldest items that exceed the limit return remaining_items[self.max_cache_items :] return [] def _remove_cache_item(self, item: dict) -> None: """Remove a single cache item.""" cache_path = Path(item["cache_path"]) if cache_path.exists(): pr_info = f"PR #{item.get('pr_number', '?')} ({item.get('pr_title', 'Unknown')[:30]}...)" rprint(f"[yellow]Removing old cache: {pr_info}[/yellow]") shutil.rmtree(cache_path) def enforce_cache_limits(self) -> None: """Remove old and excess cache entries to maintain limits.""" cached_items = self.list_cached_frontends() # Get items to remove expired_items = self._get_expired_items(cached_items) excess_items = self._get_excess_items(cached_items, expired_items) # Remove all identified items items_to_remove = expired_items + excess_items for item in items_to_remove: self._remove_cache_item(item) def get_cache_age(self, cached_at: str) -> str: """Get human-readable age of cache entry""" try: cache_time = datetime.fromisoformat(cached_at) age = datetime.now() - cache_time if age.days > 0: return f"{age.days} day{'s' if age.days != 1 else ''} ago" if age.seconds > 3600: hours = age.seconds // 3600 return f"{hours} hour{'s' if hours != 1 else ''} ago" if age.seconds > 60: minutes = age.seconds // 60 return f"{minutes} minute{'s' if minutes != 1 else ''} ago" return "just now" except (json.JSONDecodeError, OSError): return "unknown" ================================================ FILE: comfy_cli/registry/__init__.py ================================================ from .api import RegistryAPI from .config_parser import extract_node_configuration, initialize_project_config from .types import Node, NodeVersion, PublishNodeVersionResponse, PyProjectConfig __all__ = [ "RegistryAPI", "extract_node_configuration", "PyProjectConfig", "PublishNodeVersionResponse", "NodeVersion", "Node", "initialize_project_config", ] ================================================ FILE: comfy_cli/registry/api.py ================================================ import json import logging import os import requests # Reduced global imports from comfy_cli.registry from comfy_cli.registry.types import ( License, Node, NodeVersion, PublishNodeVersionResponse, PyProjectConfig, ) class RegistryAPI: def __init__(self): self.base_url = self.determine_base_url() def determine_base_url(self): env = os.getenv("ENVIRONMENT") if env == "dev": return "http://localhost:8080" elif env == "staging": return "https://stagingapi.comfy.org" else: return "https://api.comfy.org" def publish_node_version(self, node_config: PyProjectConfig, token) -> PublishNodeVersionResponse: """ Publishes a new version of a node. Args: node_config (PyProjectConfig): The node configuration. token (str): The token to authenticate with the API server. Returns: PublishNodeVersionResponse: The response object from the API server. """ # Local import to prevent circular dependency if not node_config.tool_comfy.publisher_id: raise Exception("Publisher ID is required in pyproject.toml to publish a node version") if not node_config.project.name: raise Exception("Project name is required in pyproject.toml to publish a node version") license_json = serialize_license(node_config.project.license) request_body = { "personal_access_token": token, "node": { "id": node_config.project.name, "description": node_config.project.description, "icon": node_config.tool_comfy.icon, "name": node_config.tool_comfy.display_name, "license": license_json, "repository": node_config.project.urls.repository, "banner_url": node_config.tool_comfy.banner_url, "supported_os": node_config.project.supported_os, "supported_accelerators": node_config.project.supported_accelerators, "supported_comfyui_version": node_config.project.supported_comfyui_version, "supported_comfyui_frontend_version": node_config.project.supported_comfyui_frontend_version, }, "node_version": { "version": node_config.project.version, "dependencies": node_config.project.dependencies, "supported_os": node_config.project.supported_os, "supported_accelerators": node_config.project.supported_accelerators, "supported_comfyui_version": node_config.project.supported_comfyui_version, "supported_comfyui_frontend_version": node_config.project.supported_comfyui_frontend_version, }, } print(request_body) url = f"{self.base_url}/publishers/{node_config.tool_comfy.publisher_id}/nodes/{node_config.project.name}/versions" headers = {"Content-Type": "application/json"} body = request_body response = requests.post(url, headers=headers, data=json.dumps(body)) if response.status_code == 201: data = response.json() return PublishNodeVersionResponse( node_version=map_node_version(data["node_version"]), signedUrl=data["signedUrl"], ) else: raise Exception(f"Failed to publish node version: {response.status_code} {response.text}") def list_all_nodes(self): """ Retrieves a list of all nodes and maps them to Node dataclass instances. Returns: list: A list of Node instances. """ url = f"{self.base_url}/nodes" response = requests.get(url) if response.status_code == 200: raw_nodes = response.json()["nodes"] return [map_node_to_node_class(node) for node in raw_nodes] else: raise Exception(f"Failed to retrieve nodes: {response.status_code} - {response.text}") def install_node(self, node_id, version=None): """ Retrieves the node version for installation. Args: node_id (str): The unique identifier of the node. version (str, optional): Specific version of the node to retrieve. If omitted, the latest version is returned. Returns: NodeVersion: Node version data or error message. """ if version is None: url = f"{self.base_url}/nodes/{node_id}/install" else: url = f"{self.base_url}/nodes/{node_id}/install?version={version}" response = requests.get(url) if response.status_code == 200: # Convert the API response to a NodeVersion object logging.debug(f"RegistryAPI install_node response: {response.json()}") return map_node_version(response.json()) else: raise Exception(f"Failed to install node: {response.status_code} - {response.text}") def map_node_version(api_node_version): """ Maps node version data from API response to NodeVersion dataclass. Args: api_data (dict): The 'node_version' part of the API response. Returns: NodeVersion: An instance of NodeVersion dataclass populated with data from the API. """ return NodeVersion( changelog=api_node_version.get("changelog", ""), # Provide a default value if 'changelog' is missing dependencies=api_node_version.get( "dependencies", [] ), # Provide a default empty list if 'dependencies' is missing deprecated=api_node_version.get("deprecated", False), # Assume False if 'deprecated' is not specified id=api_node_version["id"], # 'id' should be mandatory; raise KeyError if missing version=api_node_version["version"], # 'version' should be mandatory; raise KeyError if missing download_url=api_node_version.get("downloadUrl", ""), # Provide a default value if 'downloadUrl' is missing ) def map_node_to_node_class(api_node_data): """ Maps node data from API response to Node dataclass. Args: api_node_data (dict): The node data from the API. Returns: Node: An instance of Node dataclass populated with API data. """ return Node( id=api_node_data["id"], name=api_node_data["name"], description=api_node_data["description"], author=api_node_data.get("author"), license=api_node_data.get("license"), icon=api_node_data.get("icon"), repository=api_node_data.get("repository"), tags=api_node_data.get("tags", []), latest_version=( map_node_version(api_node_data["latest_version"]) if "latest_version" in api_node_data else None ), ) def serialize_license(license: License) -> str: if license.file: return json.dumps({"file": license.file}) if license.text: return json.dumps({"text": license.text}) return "{}" ================================================ FILE: comfy_cli/registry/config_parser.py ================================================ import os import pathlib import re import subprocess from urllib.parse import urlparse, urlunparse import tomlkit import tomlkit.exceptions import typer from comfy_cli import ui from comfy_cli.registry.types import ( ComfyConfig, License, Model, ProjectConfig, PyProjectConfig, URLs, ) # Mirrors pip's requirements-file comment rule: `#` only starts a comment when # preceded by whitespace, so VCS URL fragments (`#subdirectory=`, `#egg=`) and # direct-URL hashes (`#sha256=`) survive. _inline_comment_re: re.Pattern[str] = re.compile(r"(^|\s+)#.*$") # For `dynamic = ["version"]`: match a top-level `__version__` or `VERSION` assignment in a source file. Anchored # to start-of-line (MULTILINE) so single-line comments are skipped. Horizontal whitespace only — no cross-line # matching. Supports an optional PEP 526 type annotation. Straight quotes only. Backslash is excluded from the # value class — escape sequences (`\n`, `\t`, `\"`, ...) cause the regex to fail to match, surfacing as a # "could not find" warning rather than being silently misinterpreted. PEP 440 versions are ASCII-only so this # is a clean fail-closed contract; users with auto-generated `__version__` containing escapes must clean up # their source. The single-alternative character class also makes catastrophic backtracking impossible. # # Recommended user layout: a dedicated `_version.py` / `__version__.py` (NOT the package's `__init__.py`), so # nothing in the file — module docstrings, assignments referenced in text, etc. — can collide with this regex. # This matches hatch/setuptools convention for dynamic-version source files. _VERSION_RE: re.Pattern[str] = re.compile( r"""^(?P__version__|VERSION) (?:[\t ]*:[\t ]*[^=\n]+)? [\t ]*=[\t ]* (?:"(?P[^"\\\n]*)"|'(?P[^'\\\n]*)') """, re.MULTILINE | re.VERBOSE, ) def create_comfynode_config(): # Create the initial structure of the TOML document document = tomlkit.document() project = tomlkit.table() project["name"] = "" project["description"] = "" project["version"] = "1.0.0" project["dependencies"] = tomlkit.aot() project["license"] = "MIT" urls = tomlkit.table() urls["Repository"] = "" project.add("urls", urls) document.add("project", project) # Create the tool table tool = tomlkit.table() document.add(tomlkit.comment(" Used by Comfy Registry https://registry.comfy.org")) comfy = tomlkit.table() comfy["PublisherId"] = "" comfy["DisplayName"] = "ComfyUI-AIT" comfy["Icon"] = "" comfy["includes"] = tomlkit.array() # Add uncommentable hint for ComfyUI version compatibility, below of "[tool.comfy].includes" field. comfy["includes"].comment(""" # "requires-comfyui" = ">=1.0.0" # ComfyUI version compatibility """) tool.add("comfy", comfy) document.add("tool", tool) # Add the default model # models = tomlkit.array() # model = tomlkit.inline_table() # model["location"] = "/checkpoints/model.safetensor" # model["model_url"] = "https://example.com/model.zip" # models.append(model) # comfy["Models"] = models # Write the TOML document to a file try: with open("pyproject.toml", "w") as toml_file: toml_file.write(tomlkit.dumps(document)) except OSError as e: raise Exception("Failed to write 'pyproject.toml'") from e def sanitize_node_name(name: str) -> str: """Remove common ComfyUI-related prefixes from a string. Args: name: The string to process Returns: The string with any ComfyUI-related prefix removed """ name = name.lower() prefixes = [ "comfyui-", "comfyui_", "comfy-", "comfy_", "comfy", "comfyui", ] for prefix in prefixes: name = name.removeprefix(prefix) return name def validate_and_extract_os_classifiers(classifiers: list) -> list: os_classifiers = [c for c in classifiers if c.startswith("Operating System :: ")] if not os_classifiers: return [] os_values = [c[len("Operating System :: ") :] for c in os_classifiers] valid_os_prefixes = {"Microsoft", "POSIX", "MacOS", "OS Independent"} for os_value in os_values: if not any(os_value.startswith(prefix) for prefix in valid_os_prefixes): typer.echo( 'Warning: Invalid Operating System classifier found. Operating System classifiers must start with one of: "Microsoft", "POSIX", "MacOS", "OS Independent". ' 'Examples: "Operating System :: Microsoft :: Windows", "Operating System :: POSIX :: Linux", "Operating System :: MacOS", "Operating System :: OS Independent". ' "No OS information will be populated." ) return [] return os_values def validate_and_extract_accelerator_classifiers(classifiers: list) -> list: accelerator_classifiers = [c for c in classifiers if c.startswith("Environment ::")] if not accelerator_classifiers: return [] accelerator_values = [c[len("Environment :: ") :] for c in accelerator_classifiers] valid_accelerators = { "GPU :: NVIDIA CUDA", "GPU :: AMD ROCm", "GPU :: Intel Arc", "NPU :: Huawei Ascend", "GPU :: Apple Metal", } for accelerator_value in accelerator_values: if accelerator_value not in valid_accelerators: typer.echo( "Warning: Invalid Environment classifier found. Environment classifiers must be one of: " '"Environment :: GPU :: NVIDIA CUDA", "Environment :: GPU :: AMD ROCm", "Environment :: GPU :: Intel Arc", ' '"Environment :: NPU :: Huawei Ascend", "Environment :: GPU :: Apple Metal". ' "No accelerator information will be populated." ) return [] return accelerator_values def validate_version(version: str, field_name: str) -> str: if not version: return version version_pattern = r"^(?:(==|>=|<=|!=|~=|>|<|<>|=)\s*)?(\d+\.\d+\.\d+(?:-[a-zA-Z0-9]+)?)?$" version_parts = [part.strip() for part in version.split(",")] for part in version_parts: if not re.match(version_pattern, part): typer.echo( f'Warning: Invalid {field_name} format: "{version}". ' f"Each version part must follow the pattern: [operator][version] where operator is optional (==, >=, <=, !=, ~=, >, <, <>, =) " f"and version is in format major.minor.patch[-suffix]. " f"Multiple versions can be comma-separated. " f'Examples: ">=1.0.0", "==2.1.0-beta", "1.5.2", ">=1.0.0,<2.0.0". ' f"No {field_name} will be populated." ) return "" return version def _strip_url_credentials(url: str) -> str: parsed = urlparse(url) if parsed.scheme in ("http", "https") and (parsed.username or parsed.password): netloc = parsed.hostname or "" if ":" in netloc: netloc = f"[{netloc}]" if parsed.port: netloc += f":{parsed.port}" return urlunparse(parsed._replace(netloc=netloc)) return url def initialize_project_config(): create_comfynode_config() with open("pyproject.toml") as file: document = tomlkit.parse(file.read()) # Get the current git remote URL try: git_remote_url = subprocess.check_output(["git", "remote", "get-url", "origin"]).decode().strip() git_remote_url = _strip_url_credentials(git_remote_url) except subprocess.CalledProcessError as e: raise Exception("Could not retrieve Git remote URL. Are you in a Git repository?") from e # Convert SSH URL to HTTPS if needed if git_remote_url.startswith("git@github.com:"): git_remote_url = git_remote_url.replace("git@github.com:", "https://github.com/") # Ensure the URL ends with `.git` and remove it to obtain the plain URL repo_name = git_remote_url.rsplit("/", maxsplit=1)[-1].replace(".git", "") git_remote_url = git_remote_url.replace(".git", "") project = document.get("project", tomlkit.table()) urls = project.get("urls", tomlkit.table()) urls["Repository"] = git_remote_url urls["Documentation"] = git_remote_url + "/wiki" urls["Bug Tracker"] = git_remote_url + "/issues" project["urls"] = urls project["name"] = sanitize_node_name(repo_name) project["description"] = "" project["version"] = "1.0.0" # Use PEP 639 SPDX license identifier project["license"] = "MIT" # [project].classifiers Classifiers uncommentable hint for OS/GPU support # Attach classifiers comments to the project, below of "license" field. # will generate a comment like this: # # [project] # ... # license = "MIT" # # classifiers = [ # # # For OS-independent nodes (works on all operating systems) # ... project["license"].comment(""" # classifiers = [ # # For OS-independent nodes (works on all operating systems) # "Operating System :: OS Independent", # # # OR for OS-specific nodes, specify the supported systems: # "Operating System :: Microsoft :: Windows", # Windows specific # "Operating System :: POSIX :: Linux", # Linux specific # "Operating System :: MacOS", # macOS specific # # # GPU Accelerator support. Pick the ones that are supported by your extension. # "Environment :: GPU :: NVIDIA CUDA", # NVIDIA CUDA support # "Environment :: GPU :: AMD ROCm", # AMD ROCm support # "Environment :: GPU :: Intel Arc", # Intel Arc support # "Environment :: NPU :: Huawei Ascend", # Huawei Ascend support # "Environment :: GPU :: Apple Metal", # Apple Metal support # ] """) tool = document.get("tool", tomlkit.table()) comfy = tool.get("comfy", tomlkit.table()) comfy["DisplayName"] = repo_name tool["comfy"] = comfy document["tool"] = tool # Handle dependencies if os.path.exists("requirements.txt"): with open("requirements.txt") as req_file: dependencies: list[str] = [] for raw in req_file: # Strip inline/full-line comments, then skip pip-requirements-file # options (-r, -e, -c, --index-url, ...) which are not valid # PEP 508 deps and would break downstream build tooling. line = _inline_comment_re.sub("", raw).strip() if not line: continue if line.startswith("-"): print( f"Warning: skipping pip-only option from requirements.txt (not valid as PEP 508 dep): {line!r}" ) continue dependencies.append(line) project["dependencies"] = dependencies else: print("Warning: 'requirements.txt' not found. No dependencies will be added.") # Write the updated config to a new file in the current directory try: with open("pyproject.toml", "w") as toml_file: toml_file.write(tomlkit.dumps(document)) print("pyproject.toml has been created successfully in the current directory.") except OSError as e: raise OSError("Failed to write 'pyproject.toml'") from e def _resolve_dynamic_version(pyproject_dir: pathlib.Path, rel_path: str) -> str: """Read a version from a source file referenced by `[tool.comfy.version].path`. No Python execution — just text I/O and a regex, matching the contract agreed in issue #294. Returns empty string on any failure and emits a user-visible warning so scanning contexts degrade gracefully. """ # Reject paths that are absolute under either POSIX or Windows rules — # `pathlib.Path.is_absolute()` alone is OS-specific (e.g., `/etc/foo` is # not considered absolute on Windows because it has no drive), and we # want identical rejection behavior regardless of the host OS. if pathlib.PurePosixPath(rel_path).is_absolute() or pathlib.PureWindowsPath(rel_path).is_absolute(): typer.echo( f"Warning: `[tool.comfy.version].path` must be relative to pyproject.toml " f"(got `{rel_path}`). No version will be populated." ) return "" path_obj = pathlib.Path(rel_path) pyproject_dir = pyproject_dir.resolve() resolved = (pyproject_dir / path_obj).resolve() try: resolved.relative_to(pyproject_dir) except ValueError: typer.echo( f"Warning: `[tool.comfy.version].path` must point inside the project directory " f"(got `{rel_path}`). No version will be populated." ) return "" try: # `utf-8-sig` transparently strips a leading BOM — some Windows editors # add one, and it would defeat the `^__version__` anchor otherwise. text = resolved.read_text(encoding="utf-8-sig") except (OSError, UnicodeDecodeError) as e: typer.echo(f"Warning: could not read version file `{rel_path}`: {e}. No version will be populated.") return "" match = _VERSION_RE.search(text) if not match: typer.echo( f"Warning: could not find `__version__` or `VERSION` in `{rel_path}`. " f'The version file must contain a line like `__version__ = "1.2.3"`. ' f"No version will be populated." ) return "" # Exactly one of `dq` / `sq` was consumed by the regex. An empty capture # (`""` / `''`) is a valid match the regex accepts; if followed by another # quote the concat check below intercepts it (this also covers the # triple-quoted `"""..."""` case), otherwise we return "" and the # publish-layer guard surfaces it. raw = match.group("dq") if match.group("dq") is not None else match.group("sq") # Python concatenates adjacent string literals: `__version__ = "1." "2.3"` # (with or without whitespace between, quote styles freely mixed) evaluates # to "1.2.3". The regex captures only the first literal, so silently # returning `"1."` would POST a wrong version. Look ahead on the same line: # if the first non-whitespace char is a quote, reject the concatenation. # `;` (statement separator) and `#` (comment) are preserved because neither # starts with a quote. rest_of_line = text[match.end() :].split("\n", 1)[0] stripped_rest = rest_of_line.lstrip(" \t") if stripped_rest and stripped_rest[0] in ('"', "'"): typer.echo( f"Warning: `{match.group('name')}` in `{rel_path}` uses adjacent-string-literal " f"concatenation, which is not supported. Use a single assignment like " f'`{match.group("name")} = "1.2.3"`. No version will be populated.' ) return "" return raw.strip() def _parse_dynamic_fields(project_data) -> list[str]: """Return the `project.dynamic` field as a list of strings. Warns and returns `[]` if `dynamic` is present but has the wrong shape (e.g. a scalar string — a common PEP 621 misconfiguration). """ dynamic_raw = project_data.get("dynamic", []) # tomlkit.Array inherits from list, so valid arrays (including empty `[]`) # pass through. Everything else is a misconfiguration. if not isinstance(dynamic_raw, (list, tuple)): typer.echo( "Warning: `project.dynamic` must be an array of strings. " 'Use `dynamic = ["version"]` instead. ' "No dynamic fields will be honored." ) return [] return [str(d) for d in dynamic_raw] def _extract_version(project_data, comfy_data, pyproject_dir: pathlib.Path) -> str: """Return the project version, honoring PEP 621 `dynamic = ["version"]`. - Static `project.version` wins if present. - If absent and `"version"` is in `project.dynamic`, resolve via `[tool.comfy.version].path` (text-read + regex, no Python execution). - Otherwise return empty (existing behavior). """ static_version = project_data.get("version", "") dynamic_fields = _parse_dynamic_fields(project_data) # Type-check runs BEFORE the truthy check so falsy non-strings (`version = 0`, # `version = 0.0`, `version = false`, `version = []`, `version = {}`) produce # the same named "must be a string" warning as truthy non-strings (`version = 1`, # `version = ["1","2"]`, `version = { path = "_v.py" }`). With the order reversed, # they would silently fall through to the dynamic branch and the user would # only see the downstream "project version is empty" error at publish time. if not isinstance(static_version, str): typer.echo("Warning: `project.version` must be a string. No version will be populated.") return "" if static_version: # Strip so `version = " 1.0.0 "` doesn't get POSTed with surrounding # whitespace. A whitespace-only `version = " "` becomes "" and the # publish-layer guard surfaces it as "project version is empty". return static_version.strip() if "version" not in dynamic_fields: return "" version_cfg = comfy_data.get("version") if version_cfg is None: typer.echo( 'Warning: `dynamic = ["version"]` declared but `[tool.comfy.version].path` is not set. ' "See https://docs.comfy.org/registry/specifications for dynamic-version setup. " "No version will be populated." ) return "" if not isinstance(version_cfg, dict): # A non-table value under `[tool.comfy].version` — the user likely # wrote `version = "x"` scalar (or any other type) instead of a nested # table. typer.echo( "Warning: `[tool.comfy].version` must be a table with a `path` key. " 'Use `[tool.comfy.version]` with `path = "..."` instead. ' "No version will be populated." ) return "" # Order matters: check type BEFORE falsy-ness so that `path = 0` / `false` # / `[]` / `{}` produce a type warning, not a misleading "not set" warning. path_value = version_cfg.get("path") if path_value is not None and not isinstance(path_value, str): typer.echo("Warning: `[tool.comfy.version].path` must be a string. No version will be populated.") return "" if not path_value: typer.echo( "Warning: `[tool.comfy.version].path` is not set. " "See https://docs.comfy.org/registry/specifications for dynamic-version setup. " "No version will be populated." ) return "" return _resolve_dynamic_version(pyproject_dir, path_value) def extract_node_configuration( path: str = os.path.join(os.getcwd(), "pyproject.toml"), ) -> PyProjectConfig | None: if not os.path.isfile(path): ui.display_error_message("No pyproject.toml file found in the current directory.") return None try: # `utf-8-sig` strips a leading BOM if present — Windows editors sometimes # write one, and tomlkit would otherwise report `Empty key at line 1 col 0`. # `UnicodeDecodeError` must be in the except tuple: it is a `ValueError`, # not an `OSError`, and would otherwise escape and crash the caller. with open(path, encoding="utf-8-sig") as file: data = tomlkit.load(file) except (OSError, UnicodeDecodeError, tomlkit.exceptions.TOMLKitError) as e: ui.display_error_message(f"Could not parse `{path}`: {e}") return None project_data = data.get("project", {}) if not isinstance(project_data, dict): # Degenerate TOML like `project = "hello"` at the root. Keep scanning # contexts alive by treating it as "no project metadata". typer.echo("Warning: `project` in pyproject.toml must be a table. Using defaults.") project_data = {} urls_data = project_data.get("urls", {}) if not isinstance(urls_data, dict): urls_data = {} tool_data = data.get("tool", {}) comfy_data = tool_data.get("comfy", {}) if isinstance(tool_data, dict) else {} if not isinstance(comfy_data, dict): comfy_data = {} dependencies = project_data.get("dependencies", []) supported_comfyui_frontend_version = "" for dep in dependencies: if isinstance(dep, str) and dep.startswith("comfyui-frontend-package"): supported_comfyui_frontend_version = dep.removeprefix("comfyui-frontend-package") break # Remove the ComfyUI-frontend dependency from the dependencies list dependencies = [ dep for dep in dependencies if not (isinstance(dep, str) and dep.startswith("comfyui-frontend-package")) ] supported_comfyui_version = comfy_data.get("requires-comfyui", "") classifiers = project_data.get("classifiers", []) supported_os = validate_and_extract_os_classifiers(classifiers) supported_accelerators = validate_and_extract_accelerator_classifiers(classifiers) supported_comfyui_version = validate_version(supported_comfyui_version, "requires-comfyui") supported_comfyui_frontend_version = validate_version( supported_comfyui_frontend_version, "comfyui-frontend-package" ) license_data = project_data.get("license", {}) if isinstance(license_data, str): license = License(text=license_data) elif isinstance(license_data, dict): if "file" in license_data or "text" in license_data: license = License(file=license_data.get("file", ""), text=license_data.get("text", "")) else: typer.echo( 'Warning: License should be in one of these two formats: license = {file = "LICENSE"} OR license = {text = "MIT License"}. Please check the documentation: https://docs.comfy.org/registry/specifications.' ) license = License() else: license = License() typer.echo( 'Warning: License should be in one of these two formats: license = {file = "LICENSE"} OR license = {text = "MIT License"}. Please check the documentation: https://docs.comfy.org/registry/specifications.' ) pyproject_dir = pathlib.Path(path).parent version = _extract_version(project_data, comfy_data, pyproject_dir) project = ProjectConfig( name=project_data.get("name", ""), description=project_data.get("description", ""), version=version, requires_python=project_data.get("requires-python", ""), dependencies=dependencies, license=license, urls=URLs( homepage=urls_data.get("Homepage", ""), documentation=urls_data.get("Documentation", ""), repository=urls_data.get("Repository", ""), issues=urls_data.get("Issues", ""), ), supported_os=supported_os, supported_accelerators=supported_accelerators, supported_comfyui_version=supported_comfyui_version, supported_comfyui_frontend_version=supported_comfyui_frontend_version, ) comfy = ComfyConfig( publisher_id=comfy_data.get("PublisherId", ""), display_name=comfy_data.get("DisplayName", ""), icon=comfy_data.get("Icon", ""), models=[Model(location=m["location"], model_url=m["model_url"]) for m in comfy_data.get("Models", [])], includes=comfy_data.get("includes", []), banner_url=comfy_data.get("Banner", ""), web=comfy_data.get("web", ""), ) return PyProjectConfig(project=project, tool_comfy=comfy) ================================================ FILE: comfy_cli/registry/types.py ================================================ from dataclasses import dataclass, field @dataclass class NodeVersion: changelog: str dependencies: list[str] deprecated: bool id: str version: str download_url: str @dataclass class Node: id: str name: str description: str author: str | None = None license: str | None = None icon: str | None = None repository: str | None = None tags: list[str] = field(default_factory=list) latest_version: NodeVersion | None = None @dataclass class PublishNodeVersionResponse: node_version: NodeVersion signedUrl: str @dataclass class URLs: homepage: str = "" documentation: str = "" repository: str = "" issues: str = "" @dataclass class Model: location: str model_url: str @dataclass class ComfyConfig: publisher_id: str = "" display_name: str = "" icon: str = "" models: list[Model] = field(default_factory=list) includes: list[str] = field(default_factory=list) banner_url: str = "" web: str | None = None @dataclass class License: file: str = "" text: str = "" @dataclass class ProjectConfig: name: str = "" description: str = "" version: str = "1.0.0" requires_python: str = ">= 3.9" dependencies: list[str] = field(default_factory=list) license: License = field(default_factory=License) urls: URLs = field(default_factory=URLs) supported_os: list[str] = field(default_factory=list) supported_accelerators: list[str] = field(default_factory=list) supported_comfyui_version: str = "" supported_comfyui_frontend_version: str = "" @dataclass class PyProjectConfig: project: ProjectConfig = field(default_factory=ProjectConfig) tool_comfy: ComfyConfig = field(default_factory=ComfyConfig) ================================================ FILE: comfy_cli/resolve_python.py ================================================ from __future__ import annotations import os import platform import subprocess import sys import sysconfig from rich import print as rprint def _get_python_binary(env_path: str) -> str: if platform.system() == "Windows": return os.path.join(env_path, "Scripts", "python.exe") return os.path.join(env_path, "bin", "python") def _is_externally_managed() -> bool: """Detect PEP 668 externally-managed Python (e.g. Ubuntu 24.04 system Python).""" stdlib = sysconfig.get_path("stdlib") return bool(stdlib) and os.path.isfile(os.path.join(stdlib, "EXTERNALLY-MANAGED")) def resolve_workspace_python(workspace_path: str | None = None) -> str: if virtual_env := os.environ.get("VIRTUAL_ENV"): python = _get_python_binary(virtual_env) if os.path.isfile(python): return python if conda_prefix := os.environ.get("CONDA_PREFIX"): python = _get_python_binary(conda_prefix) if os.path.isfile(python): return python if workspace_path is not None: for venv_name in (".venv", "venv"): venv_dir = os.path.join(workspace_path, venv_name) if os.path.isdir(venv_dir): python = _get_python_binary(venv_dir) if os.path.isfile(python): return python return sys.executable def create_workspace_venv(workspace_path: str) -> str: venv_dir = os.path.join(workspace_path, ".venv") rprint(f"Creating workspace virtual environment at [bold]{venv_dir}[/bold]") subprocess.run([sys.executable, "-m", "venv", venv_dir], check=True) python = _get_python_binary(venv_dir) if not os.path.isfile(python): raise RuntimeError(f"Failed to create venv: {python} not found after creation") return python def ensure_workspace_python(workspace_path: str) -> str: if os.environ.get("VIRTUAL_ENV") or os.environ.get("CONDA_PREFIX"): return resolve_workspace_python(workspace_path) for venv_name in (".venv", "venv"): venv_dir = os.path.join(workspace_path, venv_name) if os.path.isdir(venv_dir): python = _get_python_binary(venv_dir) if os.path.isfile(python): return python # Running from the system/global Python (e.g. Docker root installs, global pip installs). if sys.prefix == sys.base_prefix: if _is_externally_managed(): # PEP 668: system Python is locked (e.g. Ubuntu 24.04), need a workspace venv. return create_workspace_venv(workspace_path) return sys.executable # Running from an isolated tool environment (pipx, uv tool, etc.) # Must create a workspace venv to avoid polluting the tool's env return create_workspace_venv(workspace_path) ================================================ FILE: comfy_cli/standalone.py ================================================ import logging import re import shutil import subprocess from pathlib import Path import requests from comfy_cli.constants import DEFAULT_STANDALONE_PYTHON_MINOR_VERSION, OS, PROC from comfy_cli.typing import PathLike from comfy_cli.utils import create_tarball, download_url, extract_tarball, get_os, get_proc from comfy_cli.uv import DependencyCompiler logger = logging.getLogger(__name__) _here = Path(__file__).expanduser().resolve().parent _platform_targets = { (OS.MACOS, PROC.ARM): "aarch64-apple-darwin", (OS.MACOS, PROC.X86_64): "x86_64-apple-darwin", (OS.LINUX, PROC.X86_64): "x86_64_v3-unknown-linux-gnu", # x86_64_v3 assumes AVX256 support, no AVX512 support (OS.WINDOWS, PROC.X86_64): "x86_64-pc-windows-msvc", } _latest_release_json_url = ( "https://raw.githubusercontent.com/astral-sh/python-build-standalone/latest-release/latest-release.json" ) _asset_url_prefix = "https://github.com/astral-sh/python-build-standalone/releases/download/{tag}" def _resolve_python_version(asset_url_prefix: str, minor_version: str) -> str: """Resolve the exact patch version for a minor version series from the release SHA256SUMS. Downloads the SHA256SUMS file (~45 KB) from the release and parses it to find the available patch version for the requested minor series (e.g. "3.12" -> "3.12.13"). """ sha256sums_url = f"{asset_url_prefix.rstrip('/')}/SHA256SUMS" response = requests.get(sha256sums_url) response.raise_for_status() pattern = re.compile(rf"cpython-({re.escape(minor_version)}\.\d+)\+") versions = set() for line in response.text.splitlines(): match = pattern.search(line) if match: versions.add(match.group(1)) if not versions: raise RuntimeError( f"No Python {minor_version}.x found in release. Available versions can be checked at {sha256sums_url}" ) # There should be exactly one patch version per minor series in a release, but pick the highest just in case. resolved = max(versions, key=lambda v: tuple(int(x) for x in v.split("."))) logger.info("Resolved Python %s -> %s", minor_version, resolved) return resolved def download_standalone_python( platform: str | None = None, proc: str | None = None, version: str = DEFAULT_STANDALONE_PYTHON_MINOR_VERSION, tag: str = "latest", flavor: str = "install_only", cwd: PathLike = ".", show_progress: bool = True, ) -> PathLike: """grab a pre-built distro from the python-build-standalone project. See https://gregoryszorc.com/docs/python-build-standalone/main/""" platform = get_os() if platform is None else platform proc = get_proc() if proc is None else proc target = _platform_targets[(platform, proc)] if tag == "latest": # try to fetch json with info about latest release response = requests.get(_latest_release_json_url) if response.status_code != 200: response.raise_for_status() raise RuntimeError(f"Request to {_latest_release_json_url} returned status code {response.status_code}") latest_release = response.json() tag = latest_release["tag"] asset_url_prefix = latest_release["asset_url_prefix"] else: asset_url_prefix = _asset_url_prefix.format(tag=tag) # If version is a minor version (e.g. "3.12"), resolve the exact patch version # from the release metadata. Full versions (e.g. "3.12.13") are used as-is. if version.count(".") == 1: version = _resolve_python_version(asset_url_prefix, version) name = f"cpython-{version}+{tag}-{target}-{flavor}" fname = f"{name}.tar.gz" url = f"{asset_url_prefix.rstrip('/')}/{fname.lstrip('/')}" return download_url(url, fname, cwd=cwd, show_progress=show_progress) class StandalonePython: @staticmethod def FromDistro( platform: str | None = None, proc: str | None = None, version: str = DEFAULT_STANDALONE_PYTHON_MINOR_VERSION, tag: str = "latest", flavor: str = "install_only", cwd: PathLike = ".", name: PathLike = "python", show_progress: bool = True, ) -> "StandalonePython": fpath = download_standalone_python( platform=platform, proc=proc, version=version, tag=tag, flavor=flavor, cwd=cwd, show_progress=show_progress, ) return StandalonePython.FromTarball(fpath, name) @staticmethod def FromTarball(fpath: PathLike, name: PathLike = "python", show_progress: bool = True) -> "StandalonePython": fpath = Path(fpath) rpath = fpath.parent / name extract_tarball(inPath=fpath, outPath=rpath, show_progress=show_progress) return StandalonePython(rpath=rpath) def __init__(self, rpath: PathLike): self.rpath = Path(rpath) self.name = self.rpath.name if get_os() == OS.WINDOWS: self.bin = self.rpath self.executable = self.bin / "python.exe" else: self.bin = self.rpath / "bin" self.executable = self.bin / "python" # paths to store package artifacts self.cache = self.rpath / "cache" self.wheels = self.rpath / "wheels" self.dep_comp = None # upgrade pip if needed, install uv self.pip_install("-U", "pip", "uv") def clean(self): for pycache in self.rpath.glob("**/__pycache__"): shutil.rmtree(pycache) def run_module(self, mod: str, *args: str): cmd: list[str] = [ str(self.executable), "-m", mod, *args, ] subprocess.run(cmd, check=True) def pip_install(self, *args: str): self.run_module("pip", "install", *args) def uv_install(self, *args: str): self.run_module("uv", "pip", "install", *args) def install_comfy_cli(self, dev: bool = False): if dev: self.uv_install(str(_here.parent)) else: self.uv_install("comfy_cli") def run_comfy_cli(self, *args: str): self.run_module("comfy_cli", *args) def install_comfy(self, *args: str, gpu_arg: str = "--nvidia"): self.run_comfy_cli("--here", "--skip-prompt", "install", "--fast-deps", gpu_arg, *args) def dehydrate_comfy_deps( self, comfyDir: PathLike, extraSpecs: list[str] | None = None, packWheels: bool = False, ): self.dep_comp = DependencyCompiler( cwd=comfyDir, executable=self.executable, outDir=self.rpath, extraSpecs=extraSpecs, ) self.dep_comp.compile_deps() if packWheels: skip_uv = get_os() == OS.WINDOWS self.dep_comp.fetch_dep_wheels(skip_uv=skip_uv) def rehydrate_comfy_deps(self, packWheels: bool = False): self.dep_comp = DependencyCompiler( executable=self.executable, outDir=self.rpath, reqFilesCore=[], reqFilesExt=[] ) if packWheels: self.dep_comp.install_wheels_directly() else: self.dep_comp.install_deps() def to_tarball(self, outPath: PathLike | None = None, show_progress: bool = True): # remove any __pycache__ before creating archive self.clean() create_tarball(inPath=self.rpath, outPath=outPath, show_progress=show_progress) ================================================ FILE: comfy_cli/tracking.py ================================================ import functools import logging as logginglib import uuid import typer from mixpanel import Mixpanel from comfy_cli import constants, logging, ui from comfy_cli.config_manager import ConfigManager from comfy_cli.workspace_manager import WorkspaceManager # Ignore logs from urllib3 that Mixpanel uses. logginglib.getLogger("urllib3").setLevel(logginglib.ERROR) MIXPANEL_TOKEN = "93aeab8962b622d431ac19800ccc9f67" mp = Mixpanel(MIXPANEL_TOKEN) if MIXPANEL_TOKEN else None # Kwargs whose values must never reach tracking system. # The key is kept (with a redacted marker) so we can still see whether the option was supplied. SENSITIVE_TRACKING_KEYS = frozenset({"api_key"}) # Generate a unique tracing ID per command. config_manager = ConfigManager() cli_version = config_manager.get_cli_version() # tracking all events for a single user user_id = config_manager.get(constants.CONFIG_KEY_USER_ID) # tracking all events for a single command tracing_id = str(uuid.uuid4()) workspace_manager = WorkspaceManager() app = typer.Typer() @app.command() def enable(): init_tracking(True) typer.echo(f"Tracking is now {'enabled'}.") init_tracking(True) @app.command() def disable(): init_tracking(False) typer.echo(f"Tracking is now {'disabled'}.") def track_event(event_name: str, properties: any = None): if properties is None: properties = {} logging.debug(f"tracking event called with event_name: {event_name} and properties: {properties}") enable_tracking = config_manager.get_bool(constants.CONFIG_KEY_ENABLE_TRACKING) if not enable_tracking: return try: properties["cli_version"] = cli_version properties["tracing_id"] = tracing_id mp.track(distinct_id=user_id, event_name=event_name, properties=properties) except Exception as e: logging.warning(f"Failed to track event: {e}") # Log the error but do not raise def track_command(sub_command: str = None): """ A decorator factory that logs the command function name and selected arguments when it's called. """ def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): command_name = f"{sub_command}:{func.__name__}" if sub_command is not None else func.__name__ # Copy kwargs to avoid mutating original dictionary # Remove context and ctx from the dictionary as they are not needed for tracking and not serializable. filtered_kwargs = { k: ("" if v is not None else None) if k in SENSITIVE_TRACKING_KEYS else v for k, v in kwargs.items() if k != "ctx" and k != "context" } logging.debug(f"Tracking command: {command_name} with arguments: {filtered_kwargs}") track_event(command_name, properties=filtered_kwargs) return func(*args, **kwargs) return wrapper return decorator def prompt_tracking_consent(skip_prompt: bool = False, default_value: bool = False): tracking_enabled = config_manager.get_bool(constants.CONFIG_KEY_ENABLE_TRACKING) if tracking_enabled is not None: return if skip_prompt: init_tracking(default_value) else: enable_tracking = ui.prompt_confirm_action("Do you agree to enable tracking to improve the application?", False) init_tracking(enable_tracking) def init_tracking(enable_tracking: bool): """ Initialize the tracking system by setting the user identifier and tracking enabled status. """ global user_id logging.debug(f"Initializing tracking with enable_tracking: {enable_tracking}") config_manager.set(constants.CONFIG_KEY_ENABLE_TRACKING, str(enable_tracking)) if not enable_tracking: return curr_user_id = config_manager.get(constants.CONFIG_KEY_USER_ID) logging.debug(f'User identifier for tracking user_id found: {curr_user_id}."') if curr_user_id is None: curr_user_id = str(uuid.uuid4()) config_manager.set(constants.CONFIG_KEY_USER_ID, curr_user_id) logging.debug(f'Setting user identifier for tracking user_id: {curr_user_id}."') user_id = curr_user_id # Note: only called once when the user interacts with the CLI for the # first time iff the permission is granted. install_event_triggered = config_manager.get_bool(constants.CONFIG_KEY_INSTALL_EVENT_TRIGGERED) if not install_event_triggered: logging.debug("Tracking install event.") config_manager.set(constants.CONFIG_KEY_INSTALL_EVENT_TRIGGERED, "True") track_event("install") ================================================ FILE: comfy_cli/typing.py ================================================ import os PathLike = os.PathLike[str] | str ================================================ FILE: comfy_cli/ui.py ================================================ from enum import Enum from typing import Any, TypeVar import questionary import typer from questionary import Choice from rich.console import Console from rich.progress import Progress from rich.table import Table from comfy_cli.workspace_manager import WorkspaceManager console = Console() workspace_manager = WorkspaceManager() def show_progress(iterable, total, description="Downloading..."): """ Display progress bar for iterable processes, especially useful for file downloads. Each item in the iterable should be a chunk of data, and the progress bar will advance by the size of each chunk. Args: iterable (Iterable[bytes]): An iterable that yields chunks of data. total (int): The total size of the data (e.g., total number of bytes) to be downloaded. description (str): Description text for the progress bar. Yields: bytes: Chunks of data as they are processed. """ with Progress(transient=True) as progress: task = progress.add_task(description, total=total) for chunk in iterable: yield chunk progress.update(task, advance=len(chunk)) ChoiceType = str | Choice | dict[str, Any] def prompt_autocomplete( question: str, choices: list[ChoiceType], default: ChoiceType = "", force_prompting: bool = False ) -> ChoiceType | None: """ Asks a single select question using questionary and returns the selected response. Args: question (str): The question to display to the user. choices (List[ChoiceType]): A list of choices the user can autocomplete from. default (ChoiceType): Default choice. force_prompting (bool): Whether to force prompting even if skip_prompting is set. Returns: Optional[ChoiceType]: The selected choice from the user, or None if skipping prompts. """ if workspace_manager.skip_prompting and not force_prompting: return None return questionary.autocomplete(question, choices=choices, default=default).ask() def prompt_select( question: str, choices: list[ChoiceType], default: ChoiceType = "", force_prompting: bool = False ) -> ChoiceType | None: """ Asks a single select question using questionary and returns the selected response. Args: question (str): The question to display to the user. choices (List[ChoiceType]): A list of choices for the user to select from. default (ChoiceType): Default choice. force_prompting (bool): Whether to force prompting even if skip_prompting is set. Returns: Optional[ChoiceType]: The selected choice from the user, or None if skipping prompts. """ if workspace_manager.skip_prompting and not force_prompting: return None return questionary.select(question, choices=choices, default=default).ask() E = TypeVar("E", bound=Enum) def prompt_select_enum(question: str, choices: list[E], force_prompting: bool = False) -> E | None: """ Asks a single select question using questionary and returns the selected response. Args: question (str): The question to display to the user. choices (List[E]): A list of Enum choices for the user to select from. force_prompting (bool): Whether to force prompting even if skip_prompting is set. Returns: Optional[E]: The selected Enum choice from the user, or None if skipping prompts. """ if workspace_manager.skip_prompting and not force_prompting: return None choice_map = {choice.value: choice for choice in choices} display_choices = list(choice_map.keys()) selected = questionary.select(question, choices=display_choices).ask() return choice_map[selected] if selected is not None else None def prompt_input(question: str, default: str = "", force_prompting: bool = False) -> str: """ Asks the user for an input using questionary. Args: question (str): The question to display to the user. default (str): The default value for the input. Returns: str: The user's input. Raises: KeyboardInterrupt: If the user interrupts the input. """ if workspace_manager.skip_prompting and not force_prompting: return default return questionary.text(question, default=default).ask() def prompt_multi_select(prompt: str, choices: list[str]) -> list[str]: """ Prompts the user to select multiple items from a list of choices. Args: prompt (str): The message to display to the user. choices (List[str]): A list of choices from which the user can select. Returns: List[str]: A list of the selected items. """ selections = questionary.checkbox(prompt, choices=choices).ask() # returns list of selected items return selections if selections else [] def prompt_confirm_action(prompt: str, default: bool) -> bool: """ Prompts the user for confirmation before proceeding with an action. Args: prompt (str): The confirmation message to display to the user. Returns: bool: True if the user confirms, False otherwise. """ if workspace_manager.skip_prompting: return default return typer.confirm(prompt) def display_table(data: list[tuple], column_names: list[str], title: str = "") -> None: """ Displays a list of tuples in a table format using Rich. Args: data (List[Tuple]): A list of tuples, where each tuple represents a row. column_names (List[str]): A list of column names for the table. title (str): The title of the table. """ table = Table(title=title) for name in column_names: table.add_column(name, overflow="fold") for row in data: table.add_row(*[str(item) for item in row]) console.print(table) def display_error_message(message: str) -> None: """ Displays an error message to the user in red text. Args: message (str): The error message to display. """ # markup=False so a dynamic message containing e.g. "[/]" doesn't raise # MarkupError or silently strip bracketed substrings. console.print(message, style="red", markup=False) ================================================ FILE: comfy_cli/update.py ================================================ import logging import sys from importlib.metadata import metadata import requests from packaging import version from rich.console import Console from rich.panel import Panel logger = logging.getLogger(__name__) console = Console() def check_for_newer_pypi_version(package_name, current_version): url = f"https://pypi.org/pypi/{package_name}/json" try: response = requests.get(url, timeout=5) response.raise_for_status() latest_version = response.json()["info"]["version"] if version.parse(latest_version) > version.parse(current_version): return True, latest_version return False, current_version except requests.RequestException as e: logger.warning(f"Failed to check for updates: {e}") return False, current_version def check_for_updates(): current_version = get_version_from_pyproject() has_newer, newer_version = check_for_newer_pypi_version("comfy-cli", current_version) if has_newer: notify_update(current_version, newer_version) def get_version_from_pyproject(): package_metadata = metadata("comfy-cli") return package_metadata["Version"] def notify_update(current_version: str, newer_version: str): message = ( f":sparkles: Newer version of [bold magenta]comfy-cli[/bold magenta] is available: [bold green]{newer_version}[/bold green].\n" f"Current version: [bold cyan]{current_version}[/bold cyan]\n" f"Update by running: [bold yellow]'pip install --upgrade comfy-cli'[/bold yellow] :arrow_up:" ) if sys.platform == "win32": # windows cannot display emoji characters. bell = "" message = message.replace(":sparkles:", "") message = message.replace(":arrow_up:", "") else: bell = ":bell:" console.print( Panel( message, title=f"[bold red]{bell} Update Available![/bold red]", border_style="bright_blue", ) ) ================================================ FILE: comfy_cli/utils.py ================================================ """ Module for utility functions. """ import functools import platform import shutil import subprocess import tarfile from pathlib import Path import psutil import requests import typer from rich import print, progress from rich.live import Live from rich.table import Table from comfy_cli.constants import DEFAULT_COMFY_WORKSPACE, OS, PROC from comfy_cli.typing import PathLike def singleton(cls): """ Decorator that implements the Singleton pattern for the decorated class. e.g. @singleton class MyClass: pass """ instances = {} def get_instance(*args, **kwargs): if cls not in instances: instances[cls] = cls(*args, **kwargs) return instances[cls] return get_instance def get_os(): platform_system = platform.system().lower() if platform_system == "darwin": return OS.MACOS elif platform_system == "windows": return OS.WINDOWS elif platform_system == "linux": return OS.LINUX else: raise ValueError(f"Running on unsupported os {platform.system()}") def get_proc(): proc = platform.machine() if proc == "x86_64" or proc == "AMD64": return PROC.X86_64 elif "arm" in proc: return PROC.ARM else: raise ValueError def install_conda_package(package_name): try: subprocess.check_call(["conda", "install", "-y", package_name]) print(f"[bold green] Successfully installed {package_name} [/bold green]") except subprocess.CalledProcessError as e: print(f"[bold red] Failed to install {package_name}. Error: {e} [/bold red]") raise typer.Exit(code=1) def get_not_user_set_default_workspace(): return DEFAULT_COMFY_WORKSPACE[get_os()] def kill_all(pid): try: parent = psutil.Process(pid) children = parent.children(recursive=True) for child in children: child.kill() return True except Exception: return False def is_running(pid): try: psutil.Process(pid) return True except psutil.NoSuchProcess: return False def create_choice_completer(opts: list[str]): def f(incomplete: str) -> list[str]: return [opt for opt in opts if opt.startswith(incomplete)] return f def download_url( url: str, fname: PathLike, cwd: PathLike = ".", allow_redirects: bool = True, show_progress: bool = True, ) -> PathLike: """download url to local file fname and show a progress bar. See https://stackoverflow.com/q/37573483""" cwd = Path(cwd).expanduser().resolve() fpath = cwd / fname response = requests.get(url, stream=True, allow_redirects=allow_redirects) if response.status_code != 200: response.raise_for_status() # Will only raise for 4xx codes, so... raise RuntimeError(f"Request to {url} returned status code {response.status_code}") response.raw.read = functools.partial(response.raw.read, decode_content=True) # Decompress if needed with fpath.open("wb") as f: if show_progress: fsize = int(response.headers.get("Content-Length", 0)) desc = f"downloading {fname}..." + ("(Unknown total file size)" if fsize == 0 else "") with progress.wrap_file(response.raw, total=fsize, description=desc) as response_raw: shutil.copyfileobj(response_raw, f) else: shutil.copyfileobj(response.raw, f) return fpath def extract_tarball( inPath: PathLike, outPath: PathLike | None = None, show_progress: bool = True, ): inPath = Path(inPath).expanduser().resolve() outPath = inPath.with_suffix("") if outPath is None else Path(outPath).expanduser().resolve() with tarfile.open(inPath) as tar: info = tar.next() old_name = info.name.split("/")[0] # path to top-level of extraction result extractPath = inPath.with_name(old_name) # clean both the extraction path and the final target path shutil.rmtree(extractPath, ignore_errors=True) shutil.rmtree(outPath, ignore_errors=True) if show_progress: fileSize = inPath.stat().st_size barProg = progress.Progress() barTask = barProg.add_task("[cyan]extracting tarball...", total=fileSize) pathProg = progress.Progress(progress.TextColumn("{task.description}")) pathTask = pathProg.add_task("") progress_table = Table.grid() progress_table.add_row(barProg) progress_table.add_row(pathProg) _size = 0 def _filter(tinfo: tarfile.TarInfo, _path: PathLike): nonlocal _size pathProg.update(pathTask, description=tinfo.path) barProg.advance(barTask, _size) _size = tinfo.size # TODO: ideally we'd use data_filter here, but it's busted: https://github.com/python/cpython/issues/107845 # return tarfile.data_filter(tinfo, _path) return tinfo else: _filter = None with Live(progress_table, refresh_per_second=10): with tarfile.open(inPath) as tar: tar.extractall(filter=_filter) if show_progress: barProg.advance(barTask, _size) pathProg.update(pathTask, description="") shutil.move(extractPath, outPath) def create_tarball( inPath: PathLike, outPath: PathLike | None = None, cwd: PathLike | None = None, show_progress: bool = True, ): cwd = Path("." if cwd is None else cwd).expanduser().resolve() inPath = Path(inPath).expanduser().resolve() outPath = inPath.with_suffix(".tgz") if outPath is None else Path(outPath).expanduser().resolve() # clean the archive target path outPath.unlink(missing_ok=True) if show_progress: fileSize = sum(f.stat().st_size for f in inPath.glob("**/*")) barProg = progress.Progress() barTask = barProg.add_task("[cyan]creating tarball...", total=fileSize) pathProg = progress.Progress(progress.TextColumn("{task.description}")) pathTask = pathProg.add_task("") progress_table = Table.grid() progress_table.add_row(barProg) progress_table.add_row(pathProg) _size = 0 def _filter(tinfo: tarfile.TarInfo): nonlocal _size pathProg.update(pathTask, description=tinfo.path) barProg.advance(barTask, _size) _size = Path(tinfo.path).stat().st_size return tinfo else: _filter = None with Live(progress_table, refresh_per_second=10): with tarfile.open(outPath, "w:gz") as tar: # don't include parent paths in archive tar.add(inPath.relative_to(cwd), filter=_filter) if show_progress: barProg.advance(barTask, _size) pathProg.update(pathTask, description="") ================================================ FILE: comfy_cli/uv.py ================================================ import re import subprocess import sys from importlib import metadata from pathlib import Path from textwrap import dedent from typing import Any, cast from comfy_cli import ui from comfy_cli.constants import GPU_OPTION from comfy_cli.typing import PathLike def _run(cmd: list[str], cwd: PathLike, check: bool = True) -> subprocess.CompletedProcess[Any]: return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, check=check) def _check_call(cmd: list[str], cwd: PathLike | None = None): """uses check_call to run pip, as reccomended by the pip maintainers. see https://pip.pypa.io/en/stable/user_guide/#using-pip-from-your-program""" try: subprocess.check_call(cmd, cwd=cwd) except subprocess.CalledProcessError: if len(cmd) >= 5 and cmd[1:4] == ["-m", "uv", "pip"] and cmd[4] in ("install", "sync"): from rich import print as rprint rprint( "\n[bold yellow]Hint:[/bold yellow] If you are on a network filesystem " "(RunPod, NFS, etc.), this may be caused by a known uv issue.\n" "Try setting one of these environment variables before running comfy:\n" " [green]export UV_LINK_MODE=copy[/green]\n" " [green]export UV_CACHE_DIR=/.cache/uv[/green]\n" "See https://github.com/astral-sh/uv/issues/12036 for details." ) raise _req_name_re: re.Pattern[str] = re.compile(r"require\s([\w-]+)") # Mirrors pip's requirements-file comment rule (pip._internal.req.req_file.COMMENT_RE): # `#` only starts a comment when preceded by whitespace (or starts the line), so # VCS URL fragments like `#subdirectory=pkg` and `#egg=foo` survive. _inline_comment_re: re.Pattern[str] = re.compile(r"(^|\s+)#.*$") def _req_re_closure(name: str) -> re.Pattern[str]: return re.compile(rf"({name}\S+)") def parse_uv_compile_error(err: str) -> tuple[str, list[str]]: """takes in stderr from a run of `uv pip compile` that failed due to requirement conflict and spits out a tuple of (reqiurement_name, [requirement_spec_in_conflict_a, requirement_spec_in_conflict_b]). Will probably fail for stderr produced from other kinds of errors """ if reqNameMatch := _req_name_re.search(err): reqName = reqNameMatch[1] else: raise ValueError reqRe = _req_re_closure(reqName) return reqName, cast(list[str], reqRe.findall(err)) def parse_req_file(rf: PathLike, skips: list[str] | None = None): skips = [] if skips is None else skips reqs: list[str] = [] opts: list[str] = [] with open(rf) as f: for line in f: line = _inline_comment_re.sub("", line).strip() if not line: continue elif "==" in line and line.split("==")[0] in skips: continue elif line.startswith("--"): opts.extend(line.split()) else: reqs.append(line) return opts + reqs class DependencyCompiler: cpuPytorchUrl = "https://download.pytorch.org/whl/cpu" rocmPytorchUrl = "https://download.pytorch.org/whl/rocm6.3" nvidiaPytorchUrl = "https://download.pytorch.org/whl/cu126" cpuTorchBackend = "cpu" rocmTorchBackend = "rocm6.3" nvidiaTorchBackend = "cu126" overrideGpu = dedent( """ # ensure usage of {gpu} version of pytorch torch torchaudio torchsde torchvision """ ).strip() reqNames = ( "requirements.txt", "pyproject.toml", "setup.cfg", "setup.py", ) @staticmethod def Find_Req_Files(*ders: PathLike) -> list[Path]: reqFiles = [] for der in ders: reqFound = False for reqName in DependencyCompiler.reqNames: for file in Path(der).absolute().iterdir(): if file.name == reqName: reqFiles.append(file) reqFound = True break if reqFound: break return reqFiles @staticmethod def Install_Build_Deps(executable: PathLike = sys.executable): """Use pip to install bare minimum requirements for uv to do its thing""" cmd = [str(executable), "-m", "pip", "install", "--upgrade", "pip", "uv"] _check_call(cmd=cmd) @staticmethod def Compile( cwd: PathLike, reqFiles: list[PathLike], emit_index_annotation: bool = True, emit_index_url: bool = True, executable: PathLike = sys.executable, index_strategy: str = "unsafe-best-match", out: PathLike | None = None, override: PathLike | None = None, resolve_strategy: str | None = None, torch_backend: str | None = None, ) -> subprocess.CompletedProcess[Any]: cmd = [ str(executable), "-m", "uv", "pip", "compile", ] for reqFile in reqFiles: cmd.append(str(reqFile)) if emit_index_annotation: cmd.append("--emit-index-annotation") if emit_index_url: cmd.append("--emit-index-url") if torch_backend is not None: cmd.extend(["--torch-backend", torch_backend]) # ensures that eg tqdm is latest version, even though an old tqdm is on the amd url # see https://github.com/astral-sh/uv/blob/main/PIP_COMPATIBILITY.md#packages-that-exist-on-multiple-indexes if index_strategy is not None: cmd.extend(["--index-strategy", "unsafe-best-match"]) if out is not None: cmd.extend(["-o", str(out)]) if override is not None: cmd.extend(["--override", str(override)]) try: return _run(cmd, cwd) except subprocess.CalledProcessError as e: print(e.__class__.__name__) print(e) print(f"STDOUT:\n{e.stdout}") print(f"STDERR:\n{e.stderr}") if resolve_strategy == "ask": name, reqs = parse_uv_compile_error(e.stderr) vers = [req.split(name)[1].strip(",") for req in reqs] ver = ui.prompt_select( "Please pick one of the conflicting version specs (or pick latest):", choices=vers + ["latest"], default=vers[0], ) if ver == "latest": req = name else: req = name + ver e.req = req elif resolve_strategy is not None: # no other resolve_strategy options implemented yet raise ValueError raise e @staticmethod def Install( cwd: PathLike, executable: PathLike = sys.executable, dry: bool = False, extra_index_url: str | None = None, find_links: list[str] | None = None, index_strategy: str | None = "unsafe-best-match", no_deps: bool = False, no_index: bool = False, override: PathLike | None = None, reqs: list[str] | None = None, reqFile: list[PathLike] | None = None, ) -> None: cmd = [ str(executable), "-m", "uv", "pip", "install", ] if dry: cmd.append("--dry-run") if extra_index_url is not None: cmd.extend(["--extra-index-url", extra_index_url]) if find_links is not None: for fl in find_links: cmd.extend(["--find-links", fl]) if index_strategy is not None: cmd.extend(["--index-strategy", "unsafe-best-match"]) if no_deps: cmd.append("--no-deps") if no_index: cmd.append("--no-index") if override is not None: cmd.extend(["--override", str(override)]) if reqs is not None: cmd.extend(reqs) if reqFile is not None: for rf in reqFile: cmd.extend(["--requirement", rf]) return _check_call(cmd, cwd) @staticmethod def Sync( cwd: PathLike, reqFile: list[PathLike], dry: bool = False, executable: PathLike = sys.executable, extraUrl: str | None = None, index_strategy: str = "unsafe-best-match", ) -> None: cmd = [ str(executable), "-m", "uv", "pip", "sync", str(reqFile), ] if index_strategy is not None: cmd.extend(["--index-strategy", "unsafe-best-match"]) if extraUrl is not None: cmd.extend(["--extra-index-url", extraUrl]) if dry: cmd.append("--dry-run") return _check_call(cmd, cwd) @staticmethod def Download( cwd: PathLike, executable: PathLike = sys.executable, extraUrl: str | None = None, noDeps: bool = False, out: PathLike | None = None, reqs: list[str] | None = None, reqFile: list[PathLike] | None = None, ) -> None: """For now, the `download` cmd has no uv support, so use pip""" cmd = [ str(executable), "-m", "pip", "download", ] if extraUrl is not None: cmd.extend(["--extra-index-url", extraUrl]) if noDeps: cmd.append("--no-deps") if out is not None: cmd.extend(["-d", str(out)]) if reqs is not None: cmd.extend(reqs) if reqFile is not None: for rf in reqFile: cmd.extend(["--requirement", rf]) return _check_call(cmd, cwd) @staticmethod def Wheel( cwd: PathLike, executable: PathLike = sys.executable, extraUrl: str | None = None, noDeps: bool = False, out: PathLike | None = None, reqs: list[str] | None = None, reqFile: list[PathLike] | None = None, ) -> None: """For now, the `wheel` cmd has no uv support, so use pip""" cmd = [ str(executable), "-m", "pip", "wheel", ] if extraUrl is not None: cmd.extend(["--extra-index-url", extraUrl]) if noDeps: cmd.append("--no-deps") if out is not None: cmd.extend(["-w", str(out)]) if reqs is not None: cmd.extend(reqs) if reqFile is not None: for rf in reqFile: cmd.extend(["--requirement", rf]) return _check_call(cmd, cwd) @staticmethod def Resolve_Gpu(gpu: GPU_OPTION | None): if gpu is None: try: tver = metadata.version("torch") if "+cu" in tver: return GPU_OPTION.NVIDIA elif "+rocm" in tver: return GPU_OPTION.AMD else: return None except metadata.PackageNotFoundError: return None else: return gpu def __init__( self, cwd: PathLike = ".", executable: PathLike = sys.executable, gpu: GPU_OPTION | None = None, outDir: PathLike = ".", outName: str = "requirements.compiled", reqFilesCore: list[PathLike] | None = None, reqFilesExt: list[PathLike] | None = None, extraSpecs: list[str] | None = None, cuda_version: str | None = None, rocm_version: str | None = None, skip_torch: bool = False, ): """Compiler/installer of Python dependencies based on uv Args: cwd (PathLike): should generally be a comfy workspace dir. Dir that is searched for dependency specification files, and where subprocesses are run in executable (PathLike): path to Python executable used to run uv and other subprocesses gpu (Union[GPU_OPTION, None]): the gpu against which pytorch and any related dependencies should be built against outDir (PathLike): the directory in which to create any output from the compiler itself outName (str): the name of the output file containing the compiled requirements reqFilesCore (Optional[list[PathLike]]): list of core requirement files (requirements.txt, pyproject.toml, etc) to be included in the compilation. Any requirements determined from these files will override all other requirements reqFilesExt (Optional[list[PathLike]]): list of requirement files (requirements.txt, pyproject.toml, etc) to be included in the compilation extraSpecs (Optional[list[str]]): list of extra Python requirement specifiers to be included in the compilation skip_torch (bool): if True, skip torch/torchvision/torchaudio installation and GPU index URLs """ self.cwd = Path(cwd).expanduser().resolve() self.outDir: Path = Path(outDir).expanduser().resolve() # use .absolute since .resolve breaks the softlink-is-interpreter assumption of venvs self.executable = Path(executable).expanduser().absolute() self.gpu = DependencyCompiler.Resolve_Gpu(gpu) self.skip_torch = skip_torch self.reqFiles = [Path(reqFile) for reqFile in reqFilesExt] if reqFilesExt is not None else None self.extraSpecs = [] if extraSpecs is None else extraSpecs if self.skip_torch: self.gpuUrl = None self.torchBackend = None elif self.gpu == GPU_OPTION.NVIDIA: tag = f"cu{cuda_version.replace('.', '')}" if cuda_version else DependencyCompiler.nvidiaTorchBackend self.gpuUrl = f"https://download.pytorch.org/whl/{tag}" self.torchBackend = tag elif self.gpu == GPU_OPTION.AMD: tag = f"rocm{rocm_version}" if rocm_version else DependencyCompiler.rocmTorchBackend self.gpuUrl = f"https://download.pytorch.org/whl/{tag}" self.torchBackend = tag elif self.gpu == GPU_OPTION.CPU: self.gpuUrl = DependencyCompiler.cpuPytorchUrl self.torchBackend = DependencyCompiler.cpuTorchBackend else: self.gpuUrl = None self.torchBackend = None self.out: Path = self.outDir / outName self.override = self.outDir / "override.txt" self.reqFilesCore = reqFilesCore if reqFilesCore is not None else self.find_core_reqs() self.reqFilesExt = reqFilesExt if reqFilesExt is not None else self.find_ext_reqs() def find_core_reqs(self): return DependencyCompiler.Find_Req_Files(self.cwd) def find_ext_reqs(self): extDirs = [d for d in (self.cwd / "custom_nodes").iterdir() if d.is_dir() and d.name != "__pycache__"] return DependencyCompiler.Find_Req_Files(*extDirs) def make_override(self): # clean up self.override.unlink(missing_ok=True) with open(self.override, "w") as f: if self.torchBackend is not None: f.write(DependencyCompiler.overrideGpu.format(gpu=self.gpu)) f.write("\n\n") completed = DependencyCompiler.Compile( cwd=self.cwd, reqFiles=self.reqFilesCore, emit_index_annotation=False, emit_index_url=False, executable=self.executable, override=self.override, torch_backend=self.torchBackend, ) with open(self.override, "a") as f: f.write("# ensure that core comfyui deps take precedence over any 3rd party extension deps\n") for line in completed.stdout.splitlines(keepends=True): # Skip bare cuda-toolkit pins — torch>=2.11 depends on # cuda-toolkit[cublas,cudart,...] and uv --override replaces # the full spec, stripping extras and dropping CUDA runtime # packages (nvidia-cuda-runtime, nvidia-cuda-nvrtc, …). #412 if line.strip().startswith("cuda-toolkit=="): continue f.write(line) f.write("\n") def compile_core_plus_ext(self): reqExtras = self.outDir / "requirements.extra" # clean up reqExtras.unlink(missing_ok=True) self.out.unlink(missing_ok=True) # make the extra specs file if self.extraSpecs: with reqExtras.open("w") as f: for spec in self.extraSpecs: f.write(spec) f.write("\n") while True: try: DependencyCompiler.Compile( cwd=self.cwd, reqFiles=self.reqFilesCore + self.reqFilesExt + ([reqExtras] if self.extraSpecs else []), executable=self.executable, override=self.override, out=self.out, resolve_strategy="ask", torch_backend=self.torchBackend, ) break except subprocess.CalledProcessError as e: if hasattr(e, "req"): with open(self.override, "a") as f: f.write(e.req + "\n") else: raise AttributeError def handle_opencv(self): """as per the opencv docs, you should only have exactly one opencv package. headless is more suitable for comfy than the gui version, so remove gui if headless is present. TODO: add support for contrib pkgs. see: https://github.com/opencv/opencv-python""" with open(self.out) as f: lines = f.readlines() guiFound, headlessFound = False, False for line in lines: if "opencv-python==" in line: guiFound = True elif "opencv-python-headless==" in line: headlessFound = True if headlessFound and guiFound: with open(self.out, "w") as f: for line in lines: if "opencv-python==" not in line: f.write(line) def compile_deps(self): self.make_override() self.compile_core_plus_ext() self.handle_opencv() def install_deps(self): DependencyCompiler.Install( cwd=self.cwd, executable=self.executable, extra_index_url=self.gpuUrl, override=self.override, reqFile=[self.out], ) def install_dists(self): DependencyCompiler.Install( cwd=self.cwd, executable=self.executable, find_links=[self.outDir / "dists"], no_deps=True, no_index=True, reqFile=[self.out], ) def install_wheels(self): DependencyCompiler.Install( cwd=self.cwd, executable=self.executable, find_links=[self.outDir / "wheels"], no_deps=True, no_index=True, reqFile=[self.out], ) def install_wheels_directly(self): DependencyCompiler.Install( cwd=self.cwd, executable=self.executable, no_deps=True, no_index=True, reqs=(self.outDir / "wheels").glob("*.whl"), ) def sync_core_plus_ext(self): DependencyCompiler.Sync( cwd=self.cwd, reqFile=[self.out], executable=self.executable, extraUrl=self.gpuUrl, ) def fetch_dep_dists(self, skip_uv: bool = False): skips = ["uv"] if skip_uv else None reqs = parse_req_file(self.out, skips=skips) extraUrl = None if "--extra-index-url" in reqs else self.gpuUrl DependencyCompiler.Download( cwd=self.cwd, executable=self.executable, extraUrl=extraUrl, noDeps=True, out=self.outDir / "dists", reqs=reqs, ) def fetch_dep_wheels(self, skip_uv: bool = False): skips = ["uv"] if skip_uv else None reqs = parse_req_file(self.out, skips=skips) extraUrl = None if "--extra-index-url" in reqs else self.gpuUrl DependencyCompiler.Wheel( cwd=self.cwd, executable=self.executable, extraUrl=extraUrl, noDeps=True, out=self.outDir / "wheels", reqs=reqs, ) ================================================ FILE: comfy_cli/workflow_to_api.py ================================================ """Convert ComfyUI UI-format workflows to API ("prompt") format. The UI format is what the ComfyUI frontend saves by default — a litegraph dump with `nodes` and `links` arrays. The API format is the flat ``{node_id: {class_type, inputs, _meta}}`` shape that the server's ``/prompt`` endpoint accepts. The conversion needs schema information about each node type (which inputs are widgets vs connections, what their order is, defaults, combo options, etc.). That information is available from the running server's ``/object_info`` endpoint — the same data the frontend uses to render the graph editor. This module is a Python port of Seth A. Robinson's ``comfyui-workflow-to-api-converter-endpoint`` (Unlicense), restructured to take a fetched ``object_info`` dict instead of importing ComfyUI's in-process ``nodes`` module. """ from __future__ import annotations import copy import logging import random import re from typing import Any logger = logging.getLogger(__name__) # C-style comments stripped from dynamic-prompt strings before group parsing. _DYNAMIC_PROMPT_COMMENT_RE = re.compile(r"/\*[\s\S]*?\*/|//.*") _DYNAMIC_PROMPT_UNESCAPE_RE = re.compile(r"\\([{}|])") # Mode values from litegraph: see frontend's LGraphEventMode enum. _MODE_MUTED = 2 # excluded from execution; outputs not produced _MODE_BYPASS = 4 # node skipped; inputs passed through to outputs # Node types that exist only in the UI graph and never appear in API output. # Aligns with cloud-mcp-server's VIRTUAL_NODE_TYPES and the frontend's # isVirtualNode set — every type the frontend's graphToPrompt() skips. _UI_ONLY_NODE_TYPES = frozenset({"Note", "MarkdownNote", "PrimitiveNode", "GetNode", "SetNode", "Reroute"}) # Sentinel IDs litegraph uses inside a subgraph definition for the synthetic # input and output proxy nodes (the boxes the user wires through). _SUBGRAPH_INPUT_NODE_ID = -10 _SUBGRAPH_OUTPUT_NODE_ID = -20 # Cap on recursive subgraph / passthrough resolution to defend against cycles # in malformed inputs. _MAX_RESOLUTION_DEPTH = 100 _MAX_SUBGRAPH_ITERATIONS = 10 # Strings that ComfyUI appends after seed-like INT widgets to control how the # value changes between runs. They're not real inputs and must be stripped from # the widget-value list before mapping to input names. _CONTROL_AFTER_GENERATE_VALUES = frozenset({"fixed", "increment", "decrement", "randomize"}) class WorkflowConversionError(Exception): """Raised when a workflow can't be converted to API format.""" def is_api_format(workflow: Any) -> bool: """Return True if ``workflow`` already looks like an API-format prompt.""" if not isinstance(workflow, dict): return False if "nodes" in workflow and "links" in workflow: return False for key, value in workflow.items(): if key in ("prompt", "extra_data", "client_id"): continue if isinstance(value, dict) and "class_type" in value: return True return False def is_subgraph_uuid(node_type: Any) -> bool: """A subgraph instance's node ``type`` field is the UUID of a subgraph def.""" if not isinstance(node_type, str) or len(node_type) != 36: return False parts = node_type.split("-") if len(parts) != 5: return False return tuple(len(p) for p in parts) == (8, 4, 4, 4, 12) def convert_ui_to_api(workflow: dict, object_info: dict) -> dict: """Convert a UI-format workflow to API format. Args: workflow: UI workflow with ``nodes`` and ``links`` keys. object_info: ``/object_info`` response: ``{node_type: schema}``. Returns: API-format dict: ``{node_id_str: {class_type, inputs, _meta}}``. """ if is_api_format(workflow): return workflow if not isinstance(workflow, dict): raise WorkflowConversionError("Workflow must be a JSON object") if not isinstance(workflow.get("nodes"), list) or not isinstance(workflow.get("links"), list): raise WorkflowConversionError("Workflow is missing 'nodes' or 'links' list") if not isinstance(object_info, dict): raise WorkflowConversionError("object_info must be a JSON object") workflow = copy.deepcopy(workflow) # Discard any non-dict entries up front so the rest of the pipeline doesn't # have to defend against malformed nodes inside the list. nodes = [n for n in workflow["nodes"] if isinstance(n, dict)] links = list(workflow["links"]) subgraph_defs = _collect_subgraph_defs(workflow) nodes, links, subgraph_ctx = _expand_subgraphs(nodes, links, subgraph_defs) links = _rewrite_links_for_subgraphs(links, subgraph_ctx, nodes) link_map = _build_link_map(links) node_by_id = {str(n.get("id")): n for n in nodes} primitive_values = _collect_primitive_values(nodes) bypassed = _collect_bypassed(nodes) nodes_to_exclude = _collect_excluded(nodes) reroute_sources = _collect_reroute_sources(nodes, link_map) set_sources, get_vars = _collect_get_set_mappings(nodes, link_map) tracers = _Tracers( link_map=link_map, nodes=nodes, node_by_id=node_by_id, bypassed=bypassed, reroute_sources=reroute_sources, set_sources=set_sources, get_vars=get_vars, subgraph_ctx=subgraph_ctx, ) if _has_group_nodes(workflow): logger.warning( "Workflow uses legacy 'group nodes' (extra.groupNodes); these aren't " "expanded by this converter. Recreate them as subgraphs in the frontend." ) api_prompt: dict[str, dict] = {} for node in nodes: node_id_str = str(node.get("id")) node_type = node.get("type") if not node_type: continue node_mode = node.get("mode", 0) if node_mode in (_MODE_MUTED, _MODE_BYPASS): continue if node_type in _UI_ONLY_NODE_TYPES: continue if node_id_str in nodes_to_exclude: continue try: api_prompt[node_id_str] = _build_api_node( node=node, node_type=node_type, object_info=object_info, tracers=tracers, primitive_values=primitive_values, bypassed=bypassed, nodes_to_exclude=nodes_to_exclude, ) except Exception: # An individual malformed node should not torpedo the whole prompt. # The executor will fail loudly on missing nodes if this matters. logger.exception("Failed to convert node id=%s type=%s; skipping", node_id_str, node_type) _strip_orphan_link_inputs(api_prompt) return api_prompt def _has_group_nodes(workflow: dict) -> bool: """Legacy 'group nodes' (workflow> types) live under extra.groupNodes.""" extra = workflow.get("extra") if isinstance(extra, dict) and isinstance(extra.get("groupNodes"), dict) and extra["groupNodes"]: return True for node in workflow.get("nodes") or []: if not isinstance(node, dict): continue t = node.get("type") if isinstance(t, str) and (t.startswith("workflow>") or t.startswith("workflow/")): return True return False def _strip_orphan_link_inputs(api_prompt: dict[str, dict]) -> None: """Drop any link inputs that reference a node we didn't emit. Defensive mirror of the frontend's final cleanup pass. We already skip most orphans during emission, but a stray reference can survive if the upstream tracing terminated on a node that later got pruned. """ for node in api_prompt.values(): inputs = node.get("inputs") if not isinstance(inputs, dict): continue for name in list(inputs): value = inputs[name] if isinstance(value, list) and len(value) == 2 and isinstance(value[0], str) and value[0] not in api_prompt: del inputs[name] # --------------------------------------------------------------------------- # Subgraph handling # --------------------------------------------------------------------------- class _SubgraphCtx: """Bookkeeping built during subgraph expansion, used later to rewrite links.""" def __init__(self) -> None: # subgraph_node_id_str -> {subgraph_input_idx: [(internal_node_id, internal_slot), ...]} self.input_targets: dict[str, dict[int, list[tuple[Any, int]]]] = {} # subgraph_node_id_str -> {(internal_node_id, internal_slot): output_slot_idx} self.output_sources: dict[str, dict[tuple[Any, int], int]] = {} # subgraph_node_id_str -> {outer_slot: subgraph_input_idx} (when names differ in order) self.outer_to_input_idx: dict[str, dict[int, int]] = {} def _collect_subgraph_defs(workflow: dict) -> dict[str, dict]: definitions = workflow.get("definitions") if not isinstance(definitions, dict): return {} subgraphs = definitions.get("subgraphs") if not isinstance(subgraphs, list): return {} defs: dict[str, dict] = {} for sg in subgraphs: if not isinstance(sg, dict): continue sg_id = sg.get("id") # sg_id has to be a string both because we use it as a dict key and # because is_subgraph_uuid (used to match instances) only accepts str. if isinstance(sg_id, str) and sg_id: defs[sg_id] = sg return defs def _expand_subgraphs( nodes: list[dict], links: list, subgraph_defs: dict[str, dict] ) -> tuple[list[dict], list, _SubgraphCtx]: """Recursively expand subgraph instances into their constituent nodes.""" ctx = _SubgraphCtx() if not subgraph_defs: return nodes, links, ctx for _iteration in range(_MAX_SUBGRAPH_ITERATIONS): expanded: list[dict] = [] found_any = False for node in nodes: node_type = node.get("type") if is_subgraph_uuid(node_type) and node_type in subgraph_defs: # Frontend semantics (executionUtil.ts): if the subgraph # instance node itself is muted (mode 2) or bypassed (mode 4), # do NOT pull its inner nodes into the prompt. The instance # stays in the node list where the normal mode-check excludes # it from emission; for bypass, downstream consumers route # through ``trace_bypassed`` on the instance's external # inputs, the same way a bypassed regular node is handled. if node.get("mode") in (_MODE_MUTED, _MODE_BYPASS): expanded.append(node) continue found_any = True sg_nodes, sg_links, input_map, output_map = _expand_one_subgraph(node, subgraph_defs[node_type], links) expanded.extend(sg_nodes) links.extend(sg_links) ctx.input_targets[str(node.get("id"))] = input_map ctx.output_sources[str(node.get("id"))] = output_map ctx.outer_to_input_idx[str(node.get("id"))] = _outer_slot_to_input_idx(node, subgraph_defs[node_type]) else: expanded.append(node) nodes = expanded if not found_any: return nodes, links, ctx logger.warning("Subgraph expansion hit iteration cap — possible cyclic reference") return nodes, links, ctx def _outer_slot_to_input_idx(outer_node: dict, sg_def: dict) -> dict[int, int]: """Map the outer node's input slots to subgraph-definition input indices.""" sg_input_names: dict[Any, int] = {} for idx, inp in enumerate(sg_def.get("inputs") or []): if isinstance(inp, dict): sg_input_names[inp.get("name")] = idx mapping: dict[int, int] = {} for outer_idx, outer_input in enumerate(outer_node.get("inputs") or []): if not isinstance(outer_input, dict): continue name = outer_input.get("name") if name in sg_input_names: mapping[outer_idx] = sg_input_names[name] return mapping def _expand_one_subgraph( outer_node: dict, sg_def: dict, existing_links: list ) -> tuple[list[dict], list, dict[int, list[tuple[Any, int]]], dict[tuple[Any, int], int]]: outer_id = outer_node.get("id") internal_nodes = [n for n in (sg_def.get("nodes") or []) if isinstance(n, dict)] internal_links = sg_def.get("links") or [] # Subgraph internal link IDs may collide with the outer workflow's IDs. # Allocate fresh IDs starting above the current maximum. max_link_id = 0 for link in existing_links: if isinstance(link, (list, tuple)) and link: lid = link[0] if isinstance(lid, int) and lid > max_link_id: max_link_id = lid next_id = max_link_id + 1 link_id_remap: dict[int, int] = {} internal_link_map: dict[int, dict] = {} for link in internal_links: if not isinstance(link, dict): continue old_id = link.get("id") # Only int IDs are usable here: link_id_remap[old_id] / internal_link_map[old_id] # need a hashable key, and the wider pipeline later does ``link_id in # link_id_remap`` lookups keyed by int link IDs from the outer workflow. # Skip the entry entirely on a missing/unhashable/wrong-typed id so a # bad apple can't crash the whole subgraph expansion (which runs # before the per-node try/except wrapper). if not isinstance(old_id, int): continue link_id_remap[old_id] = next_id next_id += 1 internal_link_map[old_id] = link input_targets: dict[int, list[tuple[Any, int]]] = {} for idx, in_def in enumerate(sg_def.get("inputs") or []): if not isinstance(in_def, dict): continue targets = [] for lid in in_def.get("linkIds") or []: if not isinstance(lid, int): continue link = internal_link_map.get(lid) if isinstance(link, dict): targets.append((link.get("target_id"), link.get("target_slot"))) if targets: input_targets[idx] = targets output_sources: dict[tuple[Any, int], int] = {} for idx, out_def in enumerate(sg_def.get("outputs") or []): if not isinstance(out_def, dict): continue for lid in out_def.get("linkIds") or []: if not isinstance(lid, int): continue link = internal_link_map.get(lid) if isinstance(link, dict): output_sources[(link.get("origin_id"), link.get("origin_slot"))] = idx expanded_nodes: list[dict] = [] for inner in internal_nodes: expanded = inner.copy() expanded["id"] = f"{outer_id}:{inner.get('id')}" expanded["inputs"] = [ _rewrite_internal_input(inp, internal_link_map, link_id_remap) for inp in inner.get("inputs", []) or [] ] expanded_nodes.append(expanded) expanded_links: list = [] for link in internal_links: if not isinstance(link, dict): continue origin_id = link.get("origin_id") target_id = link.get("target_id") if origin_id in (_SUBGRAPH_INPUT_NODE_ID, _SUBGRAPH_OUTPUT_NODE_ID): continue if target_id in (_SUBGRAPH_INPUT_NODE_ID, _SUBGRAPH_OUTPUT_NODE_ID): continue old_id = link.get("id") if not isinstance(old_id, int): continue new_id = link_id_remap.get(old_id, old_id) expanded_links.append( [ new_id, f"{outer_id}:{origin_id}", link.get("origin_slot"), f"{outer_id}:{target_id}", link.get("target_slot"), link.get("type"), ] ) return expanded_nodes, expanded_links, input_targets, output_sources def _rewrite_internal_input( input_info: dict, internal_link_map: dict[int, dict], link_id_remap: dict[int, int] ) -> dict: input_copy = input_info.copy() link_id = input_info.get("link") if not isinstance(link_id, int): # Both internal_link_map and link_id_remap are keyed by int IDs; an # unhashable (list/dict) link_id would otherwise crash the lookup # and abort the whole subgraph expansion. return input_copy link = internal_link_map.get(link_id) if not isinstance(link, dict): return input_copy if link.get("origin_id") == _SUBGRAPH_INPUT_NODE_ID: # Will be reattached to an external link by _rewrite_links_for_subgraphs. input_copy["link"] = None elif link_id in link_id_remap: input_copy["link"] = link_id_remap[link_id] return input_copy def _rewrite_links_for_subgraphs(links: list, ctx: _SubgraphCtx, nodes: list[dict]) -> list: """Resolve links that cross subgraph boundaries to their internal endpoints.""" if not ctx.output_sources and not ctx.input_targets: return links node_input_updates: dict[str, dict[int, int]] = {} updated: list = [] for link in links: if not isinstance(link, (list, tuple)) or len(link) < 6: updated.append(link) continue link_id, src_id, src_slot, tgt_id, tgt_slot, link_type = link[:6] src_id_str = str(src_id) src_id_out, src_slot_out = _resolve_subgraph_output(src_id_str, src_slot, ctx) tgt_id_str = str(tgt_id) all_targets = _resolve_subgraph_input_all(tgt_id_str, tgt_slot, ctx) # Track input-slot rewrites for ALL targets (one outer input may fan out). for resolved_tgt_id, resolved_tgt_slot in all_targets: if resolved_tgt_id != tgt_id_str: node_input_updates.setdefault(resolved_tgt_id, {})[resolved_tgt_slot] = link_id first_tgt_id, first_tgt_slot = all_targets[0] updated.append([link_id, src_id_out, src_slot_out, first_tgt_id, first_tgt_slot, link_type]) # Apply input updates to the expanded internal nodes. for node in nodes: node_id_str = str(node.get("id")) if node_id_str not in node_input_updates: continue slot_to_link = node_input_updates[node_id_str] for slot_idx, input_info in enumerate(node.get("inputs", []) or []): if slot_idx in slot_to_link: input_info["link"] = slot_to_link[slot_idx] return updated def _resolve_subgraph_output(node_id_str: str, slot: Any, ctx: _SubgraphCtx, depth: int = 0) -> tuple[Any, Any]: if depth > _MAX_RESOLUTION_DEPTH: return node_id_str, slot mapping = ctx.output_sources.get(node_id_str) if not mapping: return node_id_str, slot for (internal_node, internal_slot), out_slot in mapping.items(): if out_slot == slot: new_id = f"{node_id_str}:{internal_node}" return _resolve_subgraph_output(new_id, internal_slot, ctx, depth + 1) return node_id_str, slot def _resolve_subgraph_input_all( node_id_str: str, slot: Any, ctx: _SubgraphCtx, depth: int = 0 ) -> list[tuple[Any, Any]]: if depth > _MAX_RESOLUTION_DEPTH: return [(node_id_str, slot)] mapping = ctx.input_targets.get(node_id_str) if not mapping: return [(node_id_str, slot)] sg_input_idx = slot outer_map = ctx.outer_to_input_idx.get(node_id_str) if outer_map and slot in outer_map: sg_input_idx = outer_map[slot] targets = mapping.get(sg_input_idx) if not targets: return [(node_id_str, slot)] out: list[tuple[Any, Any]] = [] for internal_node, internal_slot in targets: new_id = f"{node_id_str}:{internal_node}" out.extend(_resolve_subgraph_input_all(new_id, internal_slot, ctx, depth + 1)) return out or [(node_id_str, slot)] # --------------------------------------------------------------------------- # Link map + tracing helpers # --------------------------------------------------------------------------- def _is_valid_connection(type_a: Any, type_b: Any) -> bool: """Mirror of LiteGraph.isValidConnection from the frontend. ``*`` and ``""`` wildcards match anything; comma-separated alternatives are expanded; otherwise we case-insensitively compare type names. """ if type_a in (0, "", "*"): type_a = 0 if type_b in (0, "", "*"): type_b = 0 if not type_a or not type_b or type_a == type_b: return True type_a_s = str(type_a).lower() type_b_s = str(type_b).lower() if "," not in type_a_s and "," not in type_b_s: return type_a_s == type_b_s for a in type_a_s.split(","): for b in type_b_s.split(","): if _is_valid_connection(a.strip(), b.strip()): return True return False def _build_link_map(links: list) -> dict[int, dict]: link_map: dict[int, dict] = {} for link in links: if not isinstance(link, (list, tuple)) or len(link) < 6: continue link_id, src_id, src_slot, tgt_id, tgt_slot, link_type = link[:6] link_map[link_id] = { "source_id": src_id, "source_slot": src_slot, "target_id": tgt_id, "target_slot": tgt_slot, "type": link_type, } return link_map def _collect_primitive_values(nodes: list[dict]) -> dict[str, Any]: out: dict[str, Any] = {} for node in nodes: if node.get("type") != "PrimitiveNode": continue widgets = node.get("widgets_values") if isinstance(widgets, list) and widgets: out[str(node.get("id"))] = widgets[0] return out def _collect_bypassed(nodes: list[dict]) -> set[str]: return {str(n.get("id")) for n in nodes if n.get("mode") == _MODE_BYPASS} def _collect_reroute_sources(nodes: list[dict], link_map: dict[int, dict]) -> dict[str, tuple[Any, Any]]: out: dict[str, tuple[Any, Any]] = {} for node in nodes: if node.get("type") != "Reroute": continue inputs = node.get("inputs") if not isinstance(inputs, list) or not inputs or not isinstance(inputs[0], dict): continue link_id = inputs[0].get("link") # ``link_id in link_map`` raises TypeError on unhashable values # (e.g. ``link: []`` in a malformed saved file). _collect_reroute_sources # runs before the per-node try/except wrapper, so a single bad Reroute # would otherwise abort the entire conversion. if not isinstance(link_id, int) or link_id not in link_map: continue ld = link_map[link_id] out[str(node.get("id"))] = (ld["source_id"], ld["source_slot"]) return out def _collect_get_set_mappings( nodes: list[dict], link_map: dict[int, dict] ) -> tuple[dict[str, tuple[Any, Any]], dict[str, str]]: """SetNode publishes a value under a name; GetNode reads it back.""" set_sources: dict[str, tuple[Any, Any]] = {} get_vars: dict[str, str] = {} for node in nodes: node_type = node.get("type") widgets = node.get("widgets_values") if not isinstance(widgets, list) or not widgets: continue var_name = widgets[0] # var_name becomes a dict key (set_sources[var_name]) and is later # checked with ``var_name in set_sources`` inside the tracer. Both # require it to be a non-empty string; reject anything else early. if not isinstance(var_name, str) or not var_name: continue if node_type == "SetNode": for inp in node.get("inputs") or []: if not isinstance(inp, dict): continue lid = inp.get("link") # See _collect_reroute_sources: unhashable lid would crash # the global pre-pass before any per-node guard kicks in. if not isinstance(lid, int) or lid not in link_map: continue ld = link_map[lid] set_sources[var_name] = (ld["source_id"], ld["source_slot"]) break elif node_type == "GetNode": get_vars[str(node.get("id"))] = var_name return set_sources, get_vars def _collect_excluded(nodes: list[dict]) -> set[str]: """Identify nodes that should never appear in the API output. Only ``LoadImageOutput`` is excluded here — it's a UI-only file picker for browsing the output folder, with no Python class behind it. All other UI-only types are filtered by name via ``_UI_ONLY_NODE_TYPES``. Matches the frontend's policy (``executionUtil.ts:graphToPrompt``) and cloud-mcp-server's ``shouldIncludeInOutput`` of emitting every non-virtual, non-muted, non-bypassed node regardless of whether its outputs are wired. The executor only runs nodes reachable from sinks (SaveImage, etc.), so unwired nodes are harmless in the prompt. We previously applied a "dead-branch" heuristic that dropped any node with no downstream consumer; that excluded legitimate sources like an unwired ``LoadAudio`` and caused 20+ cloud-mcp oracle fixtures to lose nodes that the live frontend emits. """ return {str(n.get("id")) for n in nodes if n.get("type") == "LoadImageOutput"} class _Tracers: """Bundle of upstream-resolution helpers used while emitting each API node.""" def __init__( self, *, link_map: dict[int, dict], nodes: list[dict], node_by_id: dict[str, dict], bypassed: set[str], reroute_sources: dict[str, tuple[Any, Any]], set_sources: dict[str, tuple[Any, Any]], get_vars: dict[str, str], subgraph_ctx: _SubgraphCtx, ) -> None: self.link_map = link_map self.nodes = nodes self.node_by_id = node_by_id self.bypassed = bypassed self.reroute_sources = reroute_sources self.set_sources = set_sources self.get_vars = get_vars self.subgraph_ctx = subgraph_ctx def trace_reroute(self, src_id: Any, src_slot: Any) -> tuple[Any, Any]: # Iterative to avoid Python's recursion limit on long chains. The # body matches a tail-recursive version exactly; the seen-set guards # against cyclic ``Reroute -> Reroute -> ...`` loops. seen: set[str] = set() while True: key = str(src_id) if key in seen or key not in self.reroute_sources: return src_id, src_slot seen.add(key) src_id, src_slot = self.reroute_sources[key] def trace_get_set(self, src_id: Any, src_slot: Any) -> tuple[Any, Any]: # Same iterative shape as trace_reroute. Hops through one # GetNode -> SetNode pair per step; the seen-set guards against # cycles via repeated variable names. seen: set[str] = set() while True: key = str(src_id) if key in seen or key not in self.get_vars: return src_id, src_slot seen.add(key) var_name = self.get_vars[key] if var_name not in self.set_sources: return src_id, src_slot src_id, src_slot = self.set_sources[var_name] def trace_bypassed(self, src_id: Any, src_slot: Any) -> tuple[Any, Any]: # Iterative. Each loop iteration corresponds to walking through one # bypassed node; inner calls to trace_get_set / trace_reroute already # iterate over their respective chains (no recursion). seen: set[Any] = set() while True: if src_id in seen: return src_id, src_slot seen.add(src_id) if str(src_id) not in self.bypassed: return src_id, src_slot node = self.node_by_id.get(str(src_id)) if not node: return src_id, src_slot outputs = node.get("outputs") or [] # Guard the slot index — malformed workflows can have non-numeric slots. try: slot_idx = int(src_slot) if src_slot is not None else 0 except (TypeError, ValueError): slot_idx = 0 output_type = ( outputs[slot_idx].get("type") if 0 <= slot_idx < len(outputs) and isinstance(outputs[slot_idx], dict) else None ) # Pick the input we'll forward the output through. We mix the frontend's # strict matcher (ExecutableNodeDTO._getBypassSlotIndex) with the # reference converter's permissive fallback, in order of preference: # 1. Same-slot input if its type connects to the output type # 2. First input whose type matches the output type exactly # 3. First input whose type is connection-compatible (handles ``*`` # and ``,``-separated alternatives via LiteGraph.isValidConnection) # 4. First linked input regardless of type — preserves user intent # when types disagree, matching SethRobinson's reference. The # executor will surface a type mismatch loudly if it matters. inputs = node.get("inputs") or [] chosen_link: int | None = None exact_link: int | None = None compat_link: int | None = None fallback_link: int | None = None same_slot_inp = ( inputs[slot_idx] if 0 <= slot_idx < len(inputs) and isinstance(inputs[slot_idx], dict) else None ) if same_slot_inp: lid = same_slot_inp.get("link") if ( lid is not None and lid in self.link_map and _is_valid_connection(same_slot_inp.get("type"), output_type) ): chosen_link = lid if chosen_link is None: for inp in inputs: if not isinstance(inp, dict): continue lid = inp.get("link") if lid is None or lid not in self.link_map: continue inp_type = inp.get("type") if fallback_link is None: fallback_link = lid if output_type and inp_type == output_type and exact_link is None: exact_link = lid if compat_link is None and _is_valid_connection(inp_type, output_type): compat_link = lid chosen_link = exact_link if exact_link is not None else compat_link if chosen_link is None: chosen_link = fallback_link if chosen_link is None: return src_id, src_slot ld = self.link_map[chosen_link] upstream_id, upstream_slot = ld["source_id"], ld["source_slot"] upstream_id, upstream_slot = self.trace_get_set(upstream_id, upstream_slot) upstream_id, upstream_slot = self.trace_reroute(upstream_id, upstream_slot) src_id, src_slot = upstream_id, upstream_slot # Loop continues with the new src_id/src_slot. # --------------------------------------------------------------------------- # Per-node emission # --------------------------------------------------------------------------- def _wrap_widget_value(value: Any) -> Any: """Wrap list widget values to disambiguate them from [node_id, slot] links. ComfyUI's executor strips the wrapper before passing to the node. See execution.py: ``if "__value__" in val: val = val["__value__"]``. """ if isinstance(value, list): return {"__value__": value} return value def process_dynamic_prompt(value: str) -> str: """Resolve the ``{a|b|c}`` syntax used in CLIPTextEncode text widgets. Port of the frontend's ``processDynamicPrompt`` (``formatUtil.ts``): * Strips ``/* ... */`` and ``// ...`` comments first. * Picks one alternative at random from each top-level ``{a|b|...}`` group. Nested groups are recursed into after a choice is made. * ``\\{``, ``\\}``, ``\\|`` escape their literal characters. Non-deterministic by design — the backend doesn't process the syntax, so a workflow saved with ``{red|blue} hat`` would otherwise tokenize the braces literally and produce a junk image. """ return _resolve_dynamic_prompt(_DYNAMIC_PROMPT_COMMENT_RE.sub("", value)) def _resolve_dynamic_prompt(value: str) -> str: out: list[str] = [] i = 0 n = len(value) while i < n: ch = value[i] i += 1 if ch == "\\" and i < n: # Preserve the escape marker so the unescape pass at the end can # restore the literal character without it being consumed earlier. out.append("\\" + value[i]) i += 1 elif ch == "{": chosen, i = _parse_dynamic_prompt_block(value, i) out.append(_resolve_dynamic_prompt(chosen)) else: out.append(ch) return _DYNAMIC_PROMPT_UNESCAPE_RE.sub(r"\1", "".join(out)) def _parse_dynamic_prompt_block(value: str, i: int) -> tuple[str, int]: """Parse a ``{a|b|...}`` group starting at index ``i`` (just past the ``{``). Returns ``(chosen_option, new_i)``. ``new_i`` points past the closing ``}`` (or past end-of-string if the group is unterminated — the frontend silently degrades on malformed input and we match that). """ options: list[str] = [] choice: list[str] = [] depth = 0 n = len(value) while i < n: ch = value[i] i += 1 if ch == "\\" and i < n: choice.append("\\" + value[i]) i += 1 continue if ch == "{": depth += 1 choice.append(ch) elif ch == "}": if depth == 0: break depth -= 1 choice.append(ch) elif ch == "|" and depth == 0: options.append("".join(choice)) choice = [] else: choice.append(ch) options.append("".join(choice)) return random.choice(options), i def _dynamic_prompt_input_names(node_type: str | None, node: dict | None, object_info: dict) -> set[str]: """Names of inputs whose schema declares ``dynamicPrompts: True``.""" if not node_type or not node: return set() schema = _schema_for(node_type, node, object_info) if not schema: return set() input_def = _schema_input_def(schema) out: set[str] = set() for section in ("required", "optional"): section_def = input_def.get(section) or {} if not isinstance(section_def, dict): continue for input_name, input_spec in section_def.items(): if not isinstance(input_spec, (list, tuple)) or len(input_spec) < 2: continue options = input_spec[1] if isinstance(input_spec[1], dict) else {} if options.get("dynamicPrompts"): out.add(input_name) return out def _build_api_node( *, node: dict, node_type: str, object_info: dict, tracers: _Tracers, primitive_values: dict[str, Any], bypassed: set[str], nodes_to_exclude: set[str], ) -> dict: api_node: dict = {"inputs": {}, "class_type": node_type} # Resolve the schema once via _schema_for so every consumer # (_meta.title, defaults, combo normalization) sees the same thing # as the widget-mapping path, even on nodes that carry a ``Node name # for S&R`` property pointing at a different class. schema = _schema_for(node_type, node, object_info) or {} if "title" in node: api_node["_meta"] = {"title": node["title"]} else: api_node["_meta"] = {"title": schema.get("display_name") or node_type} link_inputs: dict[str, list] = {} primitive_inputs: dict[str, Any] = {} for inp in node.get("inputs") or []: if not isinstance(inp, dict): continue input_name = inp.get("name") link_id = inp.get("link") if not input_name or not isinstance(link_id, int) or link_id not in tracers.link_map: continue ld = tracers.link_map[link_id] actual_id, actual_slot = ld["source_id"], ld["source_slot"] actual_id, actual_slot = tracers.trace_get_set(actual_id, actual_slot) actual_id, actual_slot = tracers.trace_reroute(actual_id, actual_slot) if str(actual_id) in bypassed: actual_id, actual_slot = tracers.trace_bypassed(actual_id, actual_slot) if str(actual_id) in bypassed: # Couldn't find a non-bypassed source — let widget default cover it. continue # Bypassed source may itself have referenced a GetNode or Reroute. actual_id, actual_slot = tracers.trace_get_set(actual_id, actual_slot) actual_id, actual_slot = tracers.trace_reroute(actual_id, actual_slot) # If we crossed a subgraph boundary while tracing, finalize to internal node. actual_id, actual_slot = _resolve_subgraph_output(str(actual_id), actual_slot, tracers.subgraph_ctx) actual_id_str = str(actual_id) if actual_id_str in primitive_values: primitive_inputs[input_name] = _wrap_widget_value(primitive_values[actual_id_str]) elif actual_id_str in nodes_to_exclude: continue elif actual_id_str in bypassed: continue else: link_inputs[input_name] = [actual_id_str, actual_slot] widget_inputs = _collect_widget_inputs(node, node_type, object_info, link_inputs) default_inputs = _collect_default_inputs(schema, widget_inputs, primitive_inputs, link_inputs) ordered = _get_ordered_input_names(node_type, node, object_info) if ordered: # First widget-like values in the declared order, then link inputs. # This matches what ComfyUI's "Save (API)" produces. for name in ordered: if name in widget_inputs: api_node["inputs"][name] = widget_inputs[name] elif name in primitive_inputs: api_node["inputs"][name] = primitive_inputs[name] elif name in default_inputs: api_node["inputs"][name] = default_inputs[name] for name in ordered: if name in link_inputs and name not in api_node["inputs"]: api_node["inputs"][name] = link_inputs[name] # Anything we didn't know an order for is still emitted (preserves data). for source in (widget_inputs, primitive_inputs, default_inputs, link_inputs): for key, value in source.items(): if key not in api_node["inputs"]: api_node["inputs"][key] = value _normalize_combo_values(schema, api_node["inputs"]) return api_node # --------------------------------------------------------------------------- # Widget / input order helpers (driven by /object_info) # --------------------------------------------------------------------------- def _schema_for(node_type: str, node: dict, object_info: dict) -> dict | None: # Some nodes (litegraph subgraphs) store the real class name under properties. properties = node.get("properties") or {} alt_name = properties.get("Node name for S&R") if isinstance(alt_name, str) and alt_name in object_info: return object_info[alt_name] return object_info.get(node_type) if isinstance(node_type, str) else None def _schema_input_def(schema: Any) -> dict: """Return the schema's ``input`` block as a dict, or ``{}`` if absent/malformed. Every helper that walks INPUT_TYPES sections needs this guard: the raw ``schema.get("input") or {}`` pattern returns the value as-is when it's truthy, so a malformed schema with ``"input": [...]`` would later crash on ``.get(section)``. In practice ``/object_info`` never sends a non-dict here, but the rest of the converter follows the same defensive contract. """ if not isinstance(schema, dict): return {} input_def = schema.get("input") return input_def if isinstance(input_def, dict) else {} def _get_ordered_input_names(node_type: str, node: dict, object_info: dict) -> list[str]: schema = _schema_for(node_type, node, object_info) if not schema: return [] input_order = schema.get("input_order") if not isinstance(input_order, dict): input_order = {} out: list[str] = [] for section in ("required", "optional"): section_order = input_order.get(section) if isinstance(section_order, list): out.extend(section_order) if out: return out # Fall back to whatever order is in the input dict itself. input_def = _schema_input_def(schema) for section in ("required", "optional"): section_def = input_def.get(section) or {} if isinstance(section_def, dict): out.extend(section_def.keys()) return out def _is_widget_input(input_spec: Any) -> tuple[bool, bool]: """Return (is_widget, is_dynamic_combo) for an INPUT_TYPES spec.""" if not isinstance(input_spec, (list, tuple)) or not input_spec: return False, False # ``forceInput: True`` (legacy alias: ``defaultInput``) explicitly demotes # a widget-type input to a connection-only slot; the frontend doesn't # render a widget for it and the saved workflow has no value for it in # widgets_values. Treating it as a widget here would consume a value-slot # that doesn't exist and shift every later widget out of position. options = input_spec[1] if len(input_spec) >= 2 and isinstance(input_spec[1], dict) else {} if options.get("forceInput") or options.get("defaultInput"): return False, False input_type = input_spec[0] if isinstance(input_type, (list, tuple)): return True, False # combo of choices if isinstance(input_type, str): # ``*`` and ``""`` are wildcard *connection* types — the frontend # never renders a widget for them. They slipped through the # lowercase fallback below because they have no cased characters # (``"*".isupper()`` returns ``False``), so we have to filter them # out explicitly. PreviewAny.source: ["*", {}] is the canonical # case this used to mis-handle. if input_type in ("", "*"): return False, False if input_type in {"INT", "FLOAT", "STRING", "BOOLEAN", "COMBO"}: return True, False if input_type.startswith("COMFY_") and "COMBO" in input_type: return True, True if not input_type.isupper(): return True, False # custom (lowercase) widget types return False, False def _dynamic_combo_sub_inputs( input_name: str, input_spec: Any, widget_values: list[Any], current_idx: int ) -> list[str]: if not isinstance(input_spec, (list, tuple)) or len(input_spec) < 2: return [] options_meta = input_spec[1] if isinstance(input_spec[1], dict) else {} options = options_meta.get("options") or [] if not options or current_idx >= len(widget_values): return [] selected = widget_values[current_idx] for option in options: if not isinstance(option, dict) or option.get("key") != selected: continue sub_def = option.get("inputs") # The option's ``inputs`` is supposed to mirror an INPUT_TYPES dict # (``{"required": {...}, "optional": {...}}``). Treat anything else # — typically a malformed third-party V3 node — as having no # sub-inputs rather than letting AttributeError escape into the # per-node wrapper and silently dropping the whole node. if not isinstance(sub_def, dict): return [] names: list[str] = [] for section in ("required", "optional"): section_def = sub_def.get(section) or {} if isinstance(section_def, dict): names.extend(f"{input_name}.{sub_name}" for sub_name in section_def.keys()) return names return [] def _get_widget_name_order(node_type: str, node: dict, object_info: dict, widget_values: list[Any]) -> list[str | None]: """Build the widget-name list that maps positionally to ``widgets_values``.""" schema = _schema_for(node_type, node, object_info) if schema: input_def = _schema_input_def(schema) names: list[str | None] = [] widget_idx = 0 for section in ("required", "optional"): section_def = input_def.get(section) or {} if not isinstance(section_def, dict): continue for input_name, input_spec in section_def.items(): is_widget, is_dynamic = _is_widget_input(input_spec) if not is_widget: continue names.append(input_name) if is_dynamic and widget_values: subs = _dynamic_combo_sub_inputs(input_name, input_spec, widget_values, widget_idx) names.extend(subs) widget_idx += 1 + len(subs) else: widget_idx += 1 if names: return names # Fallback: inspect the node's own input list. Some nodes mark widget-flagged inputs. return _fallback_widget_names(node, widget_values) def _fallback_widget_names(node: dict, widget_values: list[Any]) -> list[str | None]: properties = node.get("properties") or {} ue_properties = properties.get("ue_properties") or {} ue_connectable = ue_properties.get("widget_ue_connectable") if isinstance(ue_connectable, dict) and ue_connectable: names = list(ue_connectable.keys()) if len(names) >= len(widget_values): return list(names[: len(widget_values)]) all_inputs: list[str] = [] connected: set[str] = set() widget_flagged: list[str] = [] for inp in node.get("inputs") or []: if not isinstance(inp, dict): continue name = inp.get("name") if not name: continue all_inputs.append(name) if inp.get("link") is not None: connected.add(name) if inp.get("widget"): widget_flagged.append(name) if widget_flagged: if len(widget_values) > len(widget_flagged): extras = [n for n in all_inputs if n not in connected and n not in widget_flagged] return widget_flagged + extras[: len(widget_values) - len(widget_flagged)] return list(widget_flagged) unconnected = [n for n in all_inputs if n not in connected] if len(unconnected) >= len(widget_values): return unconnected[: len(widget_values)] return [] def _filter_control_values( widget_values: list[Any], node_type: str | None = None, node: dict | None = None, object_info: dict | None = None, ) -> list[Any]: """Drop the control_after_generate strings that follow seed-like INT widgets. Schema-aware when a schema is available: only a string immediately following an input that declares ``control_after_generate: True`` is treated as a control marker. This avoids false positives on legitimate STRING/COMBO widget values that happen to equal one of the control keywords (e.g. a combo option literally named ``"fixed"``). Falls back to a positional string-match heuristic when the schema is unavailable — matches SethRobinson's behavior for unknown node types. """ def is_control(v: Any) -> bool: return isinstance(v, str) and v in _CONTROL_AFTER_GENERATE_VALUES schema = _schema_for(node_type, node, object_info) if node_type and node and object_info else None if not schema: out: list[Any] = [] i = 0 while i < len(widget_values): value = widget_values[i] if is_control(value): i += 1 continue if i + 1 < len(widget_values) and is_control(widget_values[i + 1]): out.append(value) i += 2 continue out.append(value) i += 1 return out out = [] vidx = 0 input_def = _schema_input_def(schema) for section in ("required", "optional"): section_def = input_def.get(section) or {} if not isinstance(section_def, dict): continue for input_name, input_spec in section_def.items(): if vidx >= len(widget_values): break is_widget, _is_dynamic = _is_widget_input(input_spec) if not is_widget: continue out.append(widget_values[vidx]) vidx += 1 if vidx < len(widget_values) and _has_control_after_generate_companion( input_name, input_spec, widget_values[vidx] ): vidx += 1 while vidx < len(widget_values): out.append(widget_values[vidx]) vidx += 1 return out def _has_control_after_generate_companion(input_name: str, input_spec: Any, next_value: Any) -> bool: """True if ``next_value`` should be consumed as a control_after_generate marker. Two ways the frontend adds the companion widget: * Explicit: the input spec sets ``control_after_generate: True``. * Implicit: the input is named ``seed`` or ``noise_seed`` and is INT-typed. The frontend's ``useIntWidget`` composable adds the companion in that case regardless of the schema flag. For the implicit path we peek at the next value: older workflows saved before the companion existed don't have the marker string, so we must verify the slot really is a control keyword before consuming it. """ options = input_spec[1] if len(input_spec) >= 2 and isinstance(input_spec[1], dict) else {} if options.get("control_after_generate"): return isinstance(next_value, str) and next_value in _CONTROL_AFTER_GENERATE_VALUES input_type = input_spec[0] if input_spec else None if input_type == "INT" and input_name in ("seed", "noise_seed"): return isinstance(next_value, str) and next_value in _CONTROL_AFTER_GENERATE_VALUES return False def _collect_widget_inputs( node: dict, node_type: str, object_info: dict, link_inputs: dict[str, list] ) -> dict[str, Any]: widget_values = node.get("widgets_values") if widget_values is None: return {} dynamic_prompt_names = _dynamic_prompt_input_names(node_type, node, object_info) def emit(name: str, value: Any) -> Any: if name in dynamic_prompt_names and isinstance(value, str): value = process_dynamic_prompt(value) return _wrap_widget_value(value) out: dict[str, Any] = {} if isinstance(widget_values, dict): # Already self-describing; drop UI-only keys and respect link overrides. for key, value in widget_values.items(): if key in ("videopreview", "preview"): continue if key in link_inputs: continue out[key] = emit(key, value) return out if not isinstance(widget_values, list): return {} if any(isinstance(v, dict) for v in widget_values): _absorb_dict_widget_values(widget_values, out, link_inputs) return out filtered = _filter_control_values(widget_values, node_type, node, object_info) # ``widget_idx`` inside _get_widget_name_order is the position in the # value list it receives, so it must see the *filtered* list — otherwise # a V3 dynamic combo's selector is read from the wrong slot whenever a # control_after_generate marker precedes it (e.g. on the Bria / Kling / # Vidu / Wan2 API nodes that pair a seed with a dynamic combo). names = _get_widget_name_order(node_type, node, object_info, filtered) if not names: if filtered: logger.warning( "Could not map widget_values for unknown node type %r (node %s)", node_type, node.get("id"), ) return out for i, value in enumerate(filtered): if i >= len(names): break name = names[i] if not name or name in link_inputs: continue out[name] = emit(name, value) return out def _absorb_dict_widget_values(widget_values: list[Any], out: dict[str, Any], link_inputs: dict[str, list]) -> None: lora_counter = 0 for value in widget_values: if isinstance(value, dict): if not value: continue if "type" in value: name = value.get("type") if name and name not in link_inputs: out[name] = value elif "lora" in value: lora_counter += 1 name = f"lora_{lora_counter}" if name in link_inputs: continue clean = {k: v for k, v in value.items() if k != "strengthTwo" or v is not None} out[name] = clean elif isinstance(value, str) and value == "": # Frontend's "Add Lora" button serializes as an empty string trailer. out.setdefault("➕ Add Lora", value) def _collect_default_inputs( schema: dict | None, widget_inputs: dict[str, Any], primitive_inputs: dict[str, Any], link_inputs: dict[str, list], ) -> dict[str, Any]: if not schema: return {} input_def = _schema_input_def(schema) defaults: dict[str, Any] = {} for section in ("required", "optional"): section_def = input_def.get(section) or {} if not isinstance(section_def, dict): continue for input_name, input_spec in section_def.items(): if input_name in widget_inputs or input_name in primitive_inputs or input_name in link_inputs: continue default = _extract_default(input_spec) if default is not _MISSING: defaults[input_name] = _wrap_widget_value(default) return defaults _MISSING = object() def _extract_default(input_spec: Any) -> Any: if not isinstance(input_spec, (list, tuple)) or not input_spec: return _MISSING input_type = input_spec[0] options = input_spec[1] if len(input_spec) >= 2 and isinstance(input_spec[1], dict) else {} if "default" in options: return options["default"] if isinstance(input_type, list) and input_type: return input_type[0] if input_type == "COMBO": opts = options.get("options") if isinstance(opts, list) and opts: return opts[0] return _MISSING def _normalize_combo_values(schema: dict | None, inputs: dict[str, Any]) -> None: if not schema: return input_def = _schema_input_def(schema) for section in ("required", "optional"): section_def = input_def.get(section) or {} if not isinstance(section_def, dict): continue for input_name, input_spec in section_def.items(): if input_name not in inputs: continue value = inputs[input_name] if not isinstance(value, str): continue if not isinstance(input_spec, (list, tuple)) or not input_spec: continue allowed = input_spec[0] if not isinstance(allowed, (list, tuple)): continue if value in allowed: continue lower_value = value.lower() for option in allowed: if isinstance(option, str) and option.lower() == lower_value: inputs[input_name] = option break ================================================ FILE: comfy_cli/workspace_manager.py ================================================ import concurrent.futures import os from dataclasses import dataclass, field from datetime import datetime from enum import Enum from pathlib import Path import git import typer import yaml from rich import print from comfy_cli import constants, logging, utils from comfy_cli.config_manager import ConfigManager from comfy_cli.utils import singleton @dataclass class ModelPath: path: str @dataclass class Model: name: str | None = None url: str | None = None paths: list[ModelPath] = field(default_factory=list) hash: str | None = None type: str | None = None @dataclass class Basics: name: str | None = None updated_at: datetime = None @dataclass class CustomNode: # Todo: Add custom node fields for comfy-lock.yaml pass @dataclass class ComfyLockYAMLStruct: basics: Basics models: list[Model] = field(default_factory=list) custom_nodes: list[CustomNode] = field(default_factory=list) def _paths_match(path_a: str, path_b: str) -> bool: try: return os.path.samefile(path_a, path_b) except (FileNotFoundError, OSError): return os.path.realpath(path_a) == os.path.realpath(path_b) def _has_comfyui_markers(path: str) -> bool: """Check for ComfyUI-specific files/directories when git metadata isn't available.""" markers = ["main.py", "comfy", "nodes.py", "comfy_extras", "comfy_api"] return sum(os.path.exists(os.path.join(path, m)) for m in markers) >= 4 def _find_comfyui_root(path: str) -> str | None: """Walk up from *path* looking for a directory with ComfyUI markers.""" cur = os.path.abspath(path) if not os.path.isdir(cur): cur = os.path.dirname(cur) while True: if _has_comfyui_markers(cur): return cur parent = os.path.dirname(cur) if parent == cur: return None cur = parent def check_comfy_repo(path) -> tuple[bool, str | None]: """Check whether *path* is (or is inside) a ComfyUI installation. Returns ``(True, resolved_path)`` on success, ``(False, None)`` otherwise. Git remote-URL matching is tried first; if that fails (no ``.git``, fork, mirror, zip download, portable build) a file-based marker check is used as a fallback. """ if not os.path.exists(path): return False, None try: repo = git.Repo(path, search_parent_directories=True) path_is_comfy_repo = any(remote.url in constants.COMFY_ORIGIN_URL_CHOICES for remote in repo.remotes) # If it's within the custom node repo, lookup from the parent directory. if not path_is_comfy_repo and "custom_nodes" in path: parts = path.split(os.sep) try: index = parts.index("custom_nodes") parent = os.sep.join(parts[:index]) repo = git.Repo(parent, search_parent_directories=True) path_is_comfy_repo = any(remote.url in constants.COMFY_ORIGIN_URL_CHOICES for remote in repo.remotes) except ValueError: pass if path_is_comfy_repo: return True, str(repo.working_dir) # Not in a git repo at all # pylint: disable=E1101 # no-member except git.exc.InvalidGitRepositoryError: pass # Fallback: file-based detection for non-git installations (zip downloads, # portable builds, forks with non-standard remotes, etc.) marker_root = _find_comfyui_root(path) if marker_root is not None: return True, marker_root return False, None # Generate and update this following method using chatGPT # def load_yaml(file_path: str) -> ComfyLockYAMLStruct: # with open(file_path, "r", encoding="utf-8") as file: # data = yaml.safe_load(file) # basics = Basics( # name=data.get("basics", {}).get("name"), # updated_at=( # datetime.fromisoformat(data.get("basics", {}).get("updated_at")) # if data.get("basics", {}).get("updated_at") # else None # ), # ) # models = [ # Model( # name=m.get("model"), # url=m.get("url"), # paths=[ModelPath(path=p.get("path")) for p in m.get("paths", [])], # hash=m.get("hash"), # type=m.get("type"), # ) # for m in data.get("models", []) # ] # custom_nodes = [] # Generate and update this following method using chatGPT def save_yaml(file_path: str, metadata: ComfyLockYAMLStruct): data = { "basics": { "name": metadata.basics.name, "updated_at": metadata.basics.updated_at.isoformat(), }, "models": [ { "model": m.name, "url": m.url, "paths": [{"path": p.path} for p in m.paths], "hash": m.hash, "type": m.type, } for m in metadata.models ], "custom_nodes": [], } with open(file_path, "w", encoding="utf-8") as file: yaml.safe_dump(data, file, default_flow_style=False, allow_unicode=True) # Function to check if the file is config.json def check_file_is_model(path): if path.name.endswith(constants.SUPPORTED_PT_EXTENSIONS): return str(path) class WorkspaceType(Enum): CURRENT_DIR = "current_dir" DEFAULT = "default" SPECIFIED = "specified" RECENT = "recent" @singleton class WorkspaceManager: def __init__( self, ): self.config_manager = ConfigManager() self.metadata = ComfyLockYAMLStruct(basics=Basics(), models=[]) self.specified_workspace = None self.use_here = None self.use_recent = None self.workspace_path = None self.workspace_type = None self.skip_prompting = None def setup_workspace_manager( self, specified_workspace: str | None = None, use_here: bool | None = None, use_recent: bool | None = None, skip_prompting: bool | None = None, ): self.specified_workspace = specified_workspace self.use_here = use_here self.use_recent = use_recent self.workspace_path, self.workspace_type = self.get_workspace_path() self.skip_prompting = skip_prompting def set_recent_workspace(self, path: str): """ Sets the most recent workspace path in the configuration. """ self.config_manager.set(constants.CONFIG_KEY_RECENT_WORKSPACE, os.path.abspath(path)) def set_default_workspace(self, path: str): """ Sets the default workspace path in the configuration. """ self.config_manager.set(constants.CONFIG_KEY_DEFAULT_WORKSPACE, os.path.abspath(path)) def set_default_launch_extras(self, extras: str): """ Sets the default workspace path in the configuration. """ self.config_manager.set(constants.CONFIG_KEY_DEFAULT_LAUNCH_EXTRAS, extras.strip()) def __get_specified_workspace(self) -> str | None: if self.specified_workspace is None: return None return os.path.abspath(os.path.expanduser(self.specified_workspace)) def get_workspace_path(self) -> tuple[str, WorkspaceType]: """ Retrieves a workspace path based on user input and defaults. This function does not validate the existence of a validate ComfyUI workspace. 1. Specified Workspace (--workspace) 2. Most Recent (if --recent is True) 3. Current Directory (if --here is True) 4. Current Directory (if current dir is ComfyUI repo and --no-here is not True; returns DEFAULT if cwd matches the configured default workspace) 5. Default Workspace (if a default workspace has been set using `comfy set-default`) 6. Most Recent Workspace (if --no-recent is not True) 7. Fallback Default Workspace ('~/comfy' for linux or ~/Documents/comfy for windows/macos) """ # Check for explicitly specified workspace first specified_workspace = self.__get_specified_workspace() if specified_workspace: return specified_workspace, WorkspaceType.SPECIFIED # Check for recent workspace if requested if self.use_recent: recent_workspace = self.config_manager.get(constants.CONFIG_KEY_RECENT_WORKSPACE) if recent_workspace: return recent_workspace, WorkspaceType.RECENT else: print( "[bold red]warn: No recent workspace has been set.[/bold red]" ) # If a path has been explicitly specified, cancel the command for safety. raise typer.Exit(code=1) # Check for current workspace if requested if self.use_here is True: current_directory = os.getcwd() found_comfy_repo, comfy_path = check_comfy_repo(current_directory) if found_comfy_repo: return comfy_path, WorkspaceType.CURRENT_DIR else: return ( os.path.join(current_directory, "ComfyUI"), WorkspaceType.CURRENT_DIR, ) # Check the current directory for a ComfyUI if self.use_here is None: current_directory = os.getcwd() found_comfy_repo, comfy_path = check_comfy_repo(os.path.join(current_directory)) # If it's in a sub dir of the ComfyUI repo, get the repo working dir if found_comfy_repo: default_workspace = self.config_manager.get(constants.CONFIG_KEY_DEFAULT_WORKSPACE) if default_workspace and _paths_match(comfy_path, default_workspace): return comfy_path, WorkspaceType.DEFAULT return comfy_path, WorkspaceType.CURRENT_DIR # Check for user-set default workspace default_workspace = self.config_manager.get(constants.CONFIG_KEY_DEFAULT_WORKSPACE) if default_workspace and check_comfy_repo(default_workspace)[0]: return default_workspace, WorkspaceType.DEFAULT # Fallback to the most recent workspace if it exists if self.use_recent is None: recent_workspace = self.config_manager.get(constants.CONFIG_KEY_RECENT_WORKSPACE) if recent_workspace: if check_comfy_repo(recent_workspace)[0]: return recent_workspace, WorkspaceType.RECENT else: self.config_manager.set(constants.CONFIG_KEY_RECENT_WORKSPACE, "") print( f"[bold red]warn: Recent workspace '{recent_workspace}' is not a valid ComfyUI path. Reset.[/bold red]" ) # Check for comfy-cli default workspace default_workspace = utils.get_not_user_set_default_workspace() return default_workspace, WorkspaceType.DEFAULT def scan_dir(self): if not self.workspace_path: return [] logging.info(f"Scanning directory: {self.workspace_path}") model_files = [] for root, _dirs, files in os.walk(self.workspace_path): for file in files: if file.endswith(constants.SUPPORTED_PT_EXTENSIONS): model_files.append(os.path.join(root, file)) return model_files def scan_dir_concur(self): base_path = Path(".") model_files = [] # Use ThreadPoolExecutor to manage concurrency with concurrent.futures.ThreadPoolExecutor() as executor: futures = [executor.submit(check_file_is_model, p) for p in base_path.rglob("*")] for future in concurrent.futures.as_completed(futures): if future.result(): model_files.append(future.result()) return model_files def load_metadata(self): file_path = os.path.join(self.workspace_path, constants.COMFY_LOCK_YAML_FILE) if os.path.exists(file_path): with open(file_path, encoding="utf-8") as file: return yaml.safe_load(file) else: return {} def save_metadata(self): file_path = os.path.join(self.workspace_path, constants.COMFY_LOCK_YAML_FILE) save_yaml(file_path, self.metadata) def fill_print_table(self): # Lazy import to avoid circular dependency from comfy_cli.command.custom_nodes.cm_cli_util import resolve_manager_gui_mode config_manager = ConfigManager() mode = resolve_manager_gui_mode(not_installed_value="not-installed") status_map = { "disable": "[bold red]Disabled[/bold red]", "enable-gui": "[bold green]GUI Enabled[/bold green]", "disable-gui": "[bold yellow]GUI Disabled[/bold yellow]", "enable-legacy-gui": "[bold cyan]Legacy GUI[/bold cyan]", "not-installed": "[dim]Not Installed[/dim]", } manager_status = status_map.get(mode, "[bold green]GUI Enabled[/bold green]") uv_compile_value = config_manager.get(constants.CONFIG_KEY_UV_COMPILE_DEFAULT) if uv_compile_value is not None and str(uv_compile_value).lower() == "true": uv_compile_status = "[bold green]Enabled[/bold green]" else: uv_compile_status = "[dim]Disabled[/dim]" return [ ("Current selected workspace", f"[bold green]→ {self.workspace_path}[/bold green]"), ("Manager", manager_status), ("UV Compile Default", uv_compile_status), ] ================================================ FILE: conda.listing.txt ================================================ Loading channels: ...working... done python 3.8.11 hbdb9e5c_5 ------------------------ file name : python-3.8.11-hbdb9e5c_5.conda name : python version : 3.8.11 build : hbdb9e5c_5 build number: 5 size : 10.1 MB license : Python-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.8.11-hbdb9e5c_5.conda md5 : 2436f07f2fe23409ff5d29057225820c timestamp : 2021-08-16 10:07:56 UTC dependencies: - libcxx >=12.0.0 - libffi >=3.3,<4.0a0 - ncurses >=6.2,<7.0a0 - openssl >=1.1.1j,<1.1.2a - readline >=8.1,<9.0a0 - sqlite >=3.35.4,<4.0a0 - tk >=8.6.10,<8.7.0a0 - xz >=5.2.5,<6.0a0 - zlib >=1.2.11,<1.3.0a0 - pip python 3.8.13 hbdb9e5c_0 ------------------------ file name : python-3.8.13-hbdb9e5c_0.conda name : python version : 3.8.13 build : hbdb9e5c_0 build number: 0 size : 10.6 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.8.13-hbdb9e5c_0.conda md5 : 393886f7e6f2096ccf8bb651d38a6022 timestamp : 2022-03-28 11:17:08 UTC dependencies: - libcxx >=12.0.0 - libffi >=3.3,<4.0a0 - ncurses >=6.3,<7.0a0 - openssl >=1.1.1n,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.38.0,<4.0a0 - tk >=8.6.11,<8.7.0a0 - xz >=5.2.5,<6.0a0 - zlib >=1.2.11,<1.3.0a0 - pip python 3.8.13 hbdb9e5c_1 ------------------------ file name : python-3.8.13-hbdb9e5c_1.conda name : python version : 3.8.13 build : hbdb9e5c_1 build number: 1 size : 10.6 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.8.13-hbdb9e5c_1.conda md5 : 2f0dbf5c6c2fedbfc3775f92cb964b3f timestamp : 2022-10-19 22:56:01 UTC dependencies: - libcxx >=12.0.0 - libffi >=3.3,<4.0a0 - ncurses >=6.3,<7.0a0 - openssl >=1.1.1q,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.39.3,<4.0a0 - tk >=8.6.12,<8.7.0a0 - xz >=5.2.6,<6.0a0 - zlib >=1.2.12,<1.3.0a0 - pip python 3.8.15 h266c4f5_0 ------------------------ file name : python-3.8.15-h266c4f5_0.conda name : python version : 3.8.15 build : h266c4f5_0 build number: 0 size : 12.5 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.8.15-h266c4f5_0.conda md5 : a8a7637c6349662b86bd3805efdc1cb8 timestamp : 2022-11-10 19:18:34 UTC dependencies: - libcxx >=14.0.6 - libffi >=3.3,<4.0a0 - ncurses >=6.3,<7.0a0 - openssl >=1.1.1s,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.39.3,<4.0a0 - tk >=8.6.12,<8.7.0a0 - xz >=5.2.6,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.8.15 hc0d8a6c_2 ------------------------ file name : python-3.8.15-hc0d8a6c_2.conda name : python version : 3.8.15 build : hc0d8a6c_2 build number: 2 size : 12.3 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.8.15-hc0d8a6c_2.conda md5 : cdf4af92159db1d56e48478e03ff47ba timestamp : 2022-11-24 15:05:30 UTC dependencies: - libcxx >=14.0.6 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.3,<7.0a0 - openssl >=1.1.1s,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.40.0,<4.0a0 - tk >=8.6.12,<8.7.0a0 - xz >=5.2.6,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.8.16 hb885b13_4 ------------------------ file name : python-3.8.16-hb885b13_4.conda name : python version : 3.8.16 build : hb885b13_4 build number: 4 size : 12.5 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.8.16-hb885b13_4.conda md5 : c510a6cfe22dac91f424b553e203583a timestamp : 2023-06-12 17:55:35 UTC dependencies: - libcxx >=14.0.6 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=3.0.8,<4.0a0 - readline >=8.1.2,<9.0a0 - sqlite >=3.41.2,<4.0a0 - tk >=8.6.12,<8.7.0a0 - xz >=5.4.2,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.8.16 hc0d8a6c_2 ------------------------ file name : python-3.8.16-hc0d8a6c_2.conda name : python version : 3.8.16 build : hc0d8a6c_2 build number: 2 size : 12.6 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.8.16-hc0d8a6c_2.conda md5 : 69452a039f7a8f8341550c05a4f7bf24 timestamp : 2023-01-17 22:43:16 UTC dependencies: - libcxx >=14.0.6 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.3,<7.0a0 - openssl >=1.1.1s,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.40.1,<4.0a0 - tk >=8.6.12,<8.7.0a0 - xz >=5.2.10,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.8.16 hc0d8a6c_3 ------------------------ file name : python-3.8.16-hc0d8a6c_3.conda name : python version : 3.8.16 build : hc0d8a6c_3 build number: 3 size : 12.6 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.8.16-hc0d8a6c_3.conda md5 : 28dad6a0dbda41b86b592ccc0bcb4a70 timestamp : 2023-03-02 03:22:19 UTC dependencies: - libcxx >=14.0.6 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=1.1.1t,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.40.1,<4.0a0 - tk >=8.6.12,<8.7.0a0 - xz >=5.2.10,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.8.17 hb885b13_0 ------------------------ file name : python-3.8.17-hb885b13_0.conda name : python version : 3.8.17 build : hb885b13_0 build number: 0 size : 14.2 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.8.17-hb885b13_0.conda md5 : 8fe4e692f621f341fb45b67b8c2d97a0 timestamp : 2023-07-05 20:40:10 UTC dependencies: - libcxx >=14.0.6 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=3.0.9,<4.0a0 - readline >=8.1.2,<9.0a0 - sqlite >=3.41.2,<4.0a0 - tk >=8.6.12,<8.7.0a0 - xz >=5.4.2,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.8.17 hc0d8a6c_0 ------------------------ file name : python-3.8.17-hc0d8a6c_0.conda name : python version : 3.8.17 build : hc0d8a6c_0 build number: 0 size : 14.2 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.8.17-hc0d8a6c_0.conda md5 : aabea42c02d4f3bf2cfe289bc84cecd7 timestamp : 2023-07-05 20:48:59 UTC dependencies: - libcxx >=14.0.6 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=1.1.1u,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.41.2,<4.0a0 - tk >=8.6.12,<8.7.0a0 - xz >=5.4.2,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.8.18 hb885b13_0 ------------------------ file name : python-3.8.18-hb885b13_0.conda name : python version : 3.8.18 build : hb885b13_0 build number: 0 size : 14.1 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.8.18-hb885b13_0.conda md5 : 545c4eb85fb1283450428e3fa965c12e timestamp : 2023-09-11 13:20:43 UTC dependencies: - libcxx >=14.0.6 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=3.0.10,<4.0a0 - readline >=8.1.2,<9.0a0 - sqlite >=3.41.2,<4.0a0 - tk >=8.6.12,<8.7.0a0 - xz >=5.4.2,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.8.18 hc0d8a6c_0 ------------------------ file name : python-3.8.18-hc0d8a6c_0.conda name : python version : 3.8.18 build : hc0d8a6c_0 build number: 0 size : 14.1 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.8.18-hc0d8a6c_0.conda md5 : d02a8fed1d0268879da78d359c35f2f6 timestamp : 2023-09-11 13:29:13 UTC dependencies: - libcxx >=14.0.6 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=1.1.1v,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.41.2,<4.0a0 - tk >=8.6.12,<8.7.0a0 - xz >=5.4.2,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.8.19 hb885b13_0 ------------------------ file name : python-3.8.19-hb885b13_0.conda name : python version : 3.8.19 build : hb885b13_0 build number: 0 size : 12.5 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.8.19-hb885b13_0.conda md5 : 839957fef1b8aa661305f34467949a2e timestamp : 2024-03-20 20:31:29 UTC dependencies: - libcxx >=14.0.6 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=3.0.13,<4.0a0 - readline >=8.1.2,<9.0a0 - sqlite >=3.41.2,<4.0a0 - tk >=8.6.12,<8.7.0a0 - xz >=5.4.6,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.9.6 hc70090a_5 ----------------------- file name : python-3.9.6-hc70090a_5.conda name : python version : 3.9.6 build : hc70090a_5 build number: 5 size : 10.0 MB license : Python-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.6-hc70090a_5.conda md5 : bdf8e5b921efe19f13ef6fac45a5564f timestamp : 2021-08-16 10:45:59 UTC dependencies: - expat >=2.4.1,<3.0a0 - libcxx >=12.0.0 - libffi >=3.3,<4.0a0 - libiconv >=1.16,<2.0a0 - ncurses >=6.2,<7.0a0 - openssl >=1.1.1j,<1.1.2a - readline >=8.1,<9.0a0 - sqlite >=3.35.4,<4.0a0 - tk >=8.6.10,<8.7.0a0 - tzdata - xz >=5.2.5,<6.0a0 - zlib >=1.2.11,<1.3.0a0 - pip python 3.9.7 hc70090a_1 ----------------------- file name : python-3.9.7-hc70090a_1.conda name : python version : 3.9.7 build : hc70090a_1 build number: 1 size : 9.6 MB license : Python-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.7-hc70090a_1.conda md5 : 1c300656fadc104a32f159071f3e3648 timestamp : 2021-09-16 21:56:41 UTC dependencies: - expat >=2.4.1,<3.0a0 - libcxx >=12.0.0 - libffi >=3.3,<4.0a0 - libiconv >=1.16,<2.0a0 - ncurses >=6.2,<7.0a0 - openssl >=1.1.1j,<1.1.2a - readline >=8.1,<9.0a0 - sqlite >=3.36.0,<4.0a0 - tk >=8.6.10,<8.7.0a0 - tzdata - xz >=5.2.5,<6.0a0 - zlib >=1.2.11,<1.3.0a0 - pip python 3.9.11 hbdb9e5c_1 ------------------------ file name : python-3.9.11-hbdb9e5c_1.conda name : python version : 3.9.11 build : hbdb9e5c_1 build number: 1 size : 10.2 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.11-hbdb9e5c_1.conda md5 : 7602582b891b47e986c2367038eaa3dd timestamp : 2022-03-28 10:09:25 UTC dependencies: - libcxx >=12.0.0 - libffi >=3.3,<4.0a0 - ncurses >=6.3,<7.0a0 - openssl >=1.1.1n,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.38.0,<4.0a0 - tk >=8.6.11,<8.7.0a0 - tzdata - xz >=5.2.5,<6.0a0 - zlib >=1.2.11,<1.3.0a0 - pip python 3.9.11 hbdb9e5c_2 ------------------------ file name : python-3.9.11-hbdb9e5c_2.conda name : python version : 3.9.11 build : hbdb9e5c_2 build number: 2 size : 10.1 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.11-hbdb9e5c_2.conda md5 : c4e41bae925a2eb489f0c8644bc8f2ee timestamp : 2022-03-29 19:07:49 UTC dependencies: - libcxx >=12.0.0 - libffi >=3.3,<4.0a0 - ncurses >=6.3,<7.0a0 - openssl >=1.1.1n,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.38.0,<4.0a0 - tk >=8.6.11,<8.7.0a0 - tzdata - xz >=5.2.5,<6.0a0 - zlib >=1.2.11,<1.3.0a0 - pip python 3.9.12 hbdb9e5c_0 ------------------------ file name : python-3.9.12-hbdb9e5c_0.conda name : python version : 3.9.12 build : hbdb9e5c_0 build number: 0 size : 10.2 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.12-hbdb9e5c_0.conda md5 : 6d4e471648e21dd0dadd853bf54d8a28 timestamp : 2022-04-05 06:55:50 UTC dependencies: - libcxx >=12.0.0 - libffi >=3.3,<4.0a0 - ncurses >=6.3,<7.0a0 - openssl >=1.1.1n,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.38.2,<4.0a0 - tk >=8.6.11,<8.7.0a0 - tzdata - xz >=5.2.5,<6.0a0 - zlib >=1.2.11,<1.3.0a0 - pip python 3.9.12 hbdb9e5c_1 ------------------------ file name : python-3.9.12-hbdb9e5c_1.conda name : python version : 3.9.12 build : hbdb9e5c_1 build number: 1 size : 10.2 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.12-hbdb9e5c_1.conda md5 : 53fbd267a0fc103b65fc058892b55519 timestamp : 2022-06-01 11:38:07 UTC dependencies: - libcxx >=12.0.0 - libffi >=3.3,<4.0a0 - ncurses >=6.3,<7.0a0 - openssl >=1.1.1o,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.38.3,<4.0a0 - tk >=8.6.11,<8.7.0a0 - tzdata - xz >=5.2.5,<6.0a0 - zlib >=1.2.12,<1.3.0a0 - pip python 3.9.13 hbdb9e5c_1 ------------------------ file name : python-3.9.13-hbdb9e5c_1.conda name : python version : 3.9.13 build : hbdb9e5c_1 build number: 1 size : 10.1 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.13-hbdb9e5c_1.conda md5 : 007c2e16a887d4dc3ab85fb765a5005f timestamp : 2022-08-25 23:31:26 UTC dependencies: - libcxx >=12.0.0 - libffi >=3.3,<4.0a0 - ncurses >=6.3,<7.0a0 - openssl >=1.1.1q,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.39.2,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.2.5,<6.0a0 - zlib >=1.2.12,<1.3.0a0 - pip python 3.9.13 hbdb9e5c_2 ------------------------ file name : python-3.9.13-hbdb9e5c_2.conda name : python version : 3.9.13 build : hbdb9e5c_2 build number: 2 size : 10.2 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.13-hbdb9e5c_2.conda md5 : 185591d7dd14a96bb2e02654dacabba3 timestamp : 2022-10-13 21:15:59 UTC dependencies: - libcxx >=12.0.0 - libffi >=3.3,<4.0a0 - ncurses >=6.3,<7.0a0 - openssl >=1.1.1q,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.39.3,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.2.6,<6.0a0 - zlib >=1.2.12,<1.3.0a0 - pip python 3.9.15 hbdb9e5c_0 ------------------------ file name : python-3.9.15-hbdb9e5c_0.conda name : python version : 3.9.15 build : hbdb9e5c_0 build number: 0 size : 12.5 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.15-hbdb9e5c_0.conda md5 : 552b857a1dcae1f85b375261336927a0 timestamp : 2022-11-04 16:17:09 UTC dependencies: - libcxx >=12.0.0 - libffi >=3.3,<4.0a0 - ncurses >=6.3,<7.0a0 - openssl >=1.1.1q,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.39.3,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.2.6,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.9.15 hc0d8a6c_2 ------------------------ file name : python-3.9.15-hc0d8a6c_2.conda name : python version : 3.9.15 build : hc0d8a6c_2 build number: 2 size : 12.5 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.15-hc0d8a6c_2.conda md5 : e28533a8091cab7be3f75151ba71b335 timestamp : 2022-11-24 14:32:05 UTC dependencies: - libcxx >=14.0.6 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.3,<7.0a0 - openssl >=1.1.1s,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.40.0,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.2.6,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.9.16 hb885b13_3 ------------------------ file name : python-3.9.16-hb885b13_3.conda name : python version : 3.9.16 build : hb885b13_3 build number: 3 size : 12.6 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.16-hb885b13_3.conda md5 : 0ceab7d1b1b5f3c1b65d0ffbf9f20d7e timestamp : 2023-05-16 19:31:39 UTC dependencies: - libcxx >=14.0.6 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=3.0.8,<4.0a0 - readline >=8.1.2,<9.0a0 - sqlite >=3.41.2,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.4.2,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.9.16 hc0d8a6c_0 ------------------------ file name : python-3.9.16-hc0d8a6c_0.conda name : python version : 3.9.16 build : hc0d8a6c_0 build number: 0 size : 12.6 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.16-hc0d8a6c_0.conda md5 : 9dc504b7a7ad7766d0745a267b011682 timestamp : 2023-01-11 16:05:53 UTC dependencies: - libcxx >=14.0.6 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.3,<7.0a0 - openssl >=1.1.1s,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.40.1,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.2.8,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.9.16 hc0d8a6c_1 ------------------------ file name : python-3.9.16-hc0d8a6c_1.conda name : python version : 3.9.16 build : hc0d8a6c_1 build number: 1 size : 12.6 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.16-hc0d8a6c_1.conda md5 : 4ad0c59b65109fcafa9fbabf38f8be25 timestamp : 2023-03-01 18:23:04 UTC dependencies: - libcxx >=14.0.6 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=1.1.1t,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.40.1,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.2.10,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.9.16 hc0d8a6c_2 ------------------------ file name : python-3.9.16-hc0d8a6c_2.conda name : python version : 3.9.16 build : hc0d8a6c_2 build number: 2 size : 12.6 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.16-hc0d8a6c_2.conda md5 : f4f3fae25939c852bc4b3a4e52befddc timestamp : 2023-03-08 10:32:54 UTC dependencies: - libcxx >=14.0.6 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=1.1.1t,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.40.1,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.2.10,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.9.17 hb885b13_0 ------------------------ file name : python-3.9.17-hb885b13_0.conda name : python version : 3.9.17 build : hb885b13_0 build number: 0 size : 12.5 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.17-hb885b13_0.conda md5 : 6e170bd2d83a85df948e2caf2363f104 timestamp : 2023-07-05 20:39:04 UTC dependencies: - libcxx >=14.0.6 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=3.0.9,<4.0a0 - readline >=8.1.2,<9.0a0 - sqlite >=3.41.2,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.4.2,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.9.17 hc0d8a6c_0 ------------------------ file name : python-3.9.17-hc0d8a6c_0.conda name : python version : 3.9.17 build : hc0d8a6c_0 build number: 0 size : 12.5 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.17-hc0d8a6c_0.conda md5 : 8fcd5becc236d71202e70518eac0f55b timestamp : 2023-07-05 20:48:43 UTC dependencies: - libcxx >=14.0.6 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=1.1.1u,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.41.2,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.4.2,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.9.18 hb885b13_0 ------------------------ file name : python-3.9.18-hb885b13_0.conda name : python version : 3.9.18 build : hb885b13_0 build number: 0 size : 11.6 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.18-hb885b13_0.conda md5 : a846e3766c71915409871458683b5409 timestamp : 2023-09-11 13:28:21 UTC dependencies: - libcxx >=14.0.6 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=3.0.10,<4.0a0 - readline >=8.1.2,<9.0a0 - sqlite >=3.41.2,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.4.2,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.9.18 hc0d8a6c_0 ------------------------ file name : python-3.9.18-hc0d8a6c_0.conda name : python version : 3.9.18 build : hc0d8a6c_0 build number: 0 size : 11.6 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.18-hc0d8a6c_0.conda md5 : 673a7b485675b69c2cc3e7499951fe2a timestamp : 2023-09-11 13:19:38 UTC dependencies: - libcxx >=14.0.6 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=1.1.1v,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.41.2,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.4.2,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.9.19 hb885b13_0 ------------------------ file name : python-3.9.19-hb885b13_0.conda name : python version : 3.9.19 build : hb885b13_0 build number: 0 size : 12.5 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.19-hb885b13_0.conda md5 : 57411d22c3599a86c450fb9be9bee488 timestamp : 2024-03-21 17:10:34 UTC dependencies: - libcxx >=14.0.6 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=3.0.13,<4.0a0 - readline >=8.1.2,<9.0a0 - sqlite >=3.41.2,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.4.6,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.10.0 hbdb9e5c_1 ------------------------ file name : python-3.10.0-hbdb9e5c_1.tar.bz2 name : python version : 3.10.0 build : hbdb9e5c_1 build number: 1 size : 12.6 MB license : Python-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.0-hbdb9e5c_1.tar.bz2 md5 : c344f41666ca0e45950b4484d855b6a5 timestamp : 2021-10-07 09:17:07 UTC dependencies: - bzip2 >=1.0.8,<2.0a0 - libffi >=3.3,<4.0a0 - ncurses >=6.2,<7.0a0 - openssl >=1.1.1j,<1.1.2a - readline >=8.1,<9.0a0 - sqlite >=3.36.0,<4.0a0 - tk >=8.6.10,<8.7.0a0 - tzdata - xz >=5.2.5,<6.0a0 - zlib >=1.2.11,<1.3.0a0 - pip python 3.10.0 hbdb9e5c_2 ------------------------ file name : python-3.10.0-hbdb9e5c_2.tar.bz2 name : python version : 3.10.0 build : hbdb9e5c_2 build number: 2 size : 12.6 MB license : Python-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.0-hbdb9e5c_2.tar.bz2 md5 : 96ba09e9aaf624a7246c38efb096fcab timestamp : 2021-11-09 13:59:27 UTC dependencies: - bzip2 >=1.0.8,<2.0a0 - libffi >=3.3,<4.0a0 - ncurses >=6.3,<7.0a0 - openssl >=1.1.1j,<1.1.2a - readline >=8.1,<9.0a0 - sqlite >=3.36.0,<4.0a0 - tk >=8.6.11,<8.7.0a0 - tzdata - xz >=5.2.5,<6.0a0 - zlib >=1.2.11,<1.3.0a0 - pip python 3.10.0 hbdb9e5c_3 ------------------------ file name : python-3.10.0-hbdb9e5c_3.tar.bz2 name : python version : 3.10.0 build : hbdb9e5c_3 build number: 3 size : 12.7 MB license : Python-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.0-hbdb9e5c_3.tar.bz2 md5 : 546107c8688d70d72a64e2c6c4154014 timestamp : 2021-11-10 19:32:03 UTC dependencies: - bzip2 >=1.0.8,<2.0a0 - libffi >=3.3,<4.0a0 - ncurses >=6.3,<7.0a0 - openssl >=1.1.1j,<1.1.2a - readline >=8.1,<9.0a0 - sqlite >=3.36.0,<4.0a0 - tk >=8.6.11,<8.7.0a0 - tzdata - xz >=5.2.5,<6.0a0 - zlib >=1.2.11,<1.3.0a0 - pip python 3.10.0 hbdb9e5c_5 ------------------------ file name : python-3.10.0-hbdb9e5c_5.tar.bz2 name : python version : 3.10.0 build : hbdb9e5c_5 build number: 5 size : 12.6 MB license : Python-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.0-hbdb9e5c_5.tar.bz2 md5 : 7149a001b81f5d740645b1eee26d670d timestamp : 2022-03-03 09:58:25 UTC dependencies: - bzip2 >=1.0.8,<2.0a0 - libffi >=3.3,<4.0a0 - ncurses >=6.3,<7.0a0 - openssl >=1.1.1m,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.37.2,<4.0a0 - tk >=8.6.11,<8.7.0a0 - tzdata - xz >=5.2.5,<6.0a0 - zlib >=1.2.11,<1.3.0a0 - pip python 3.10.3 hbdb9e5c_5 ------------------------ file name : python-3.10.3-hbdb9e5c_5.tar.bz2 name : python version : 3.10.3 build : hbdb9e5c_5 build number: 5 size : 13.2 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.3-hbdb9e5c_5.tar.bz2 md5 : 223945c60e1b472da9c1c3903a6d8ba8 timestamp : 2022-03-28 09:29:24 UTC dependencies: - bzip2 >=1.0.8,<2.0a0 - libffi >=3.3,<4.0a0 - ncurses >=6.3,<7.0a0 - openssl >=1.1.1n,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.38.0,<4.0a0 - tk >=8.6.11,<8.7.0a0 - tzdata - xz >=5.2.5,<6.0a0 - zlib >=1.2.11,<1.3.0a0 - pip python 3.10.4 hbdb9e5c_0 ------------------------ file name : python-3.10.4-hbdb9e5c_0.tar.bz2 name : python version : 3.10.4 build : hbdb9e5c_0 build number: 0 size : 13.1 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.4-hbdb9e5c_0.tar.bz2 md5 : f4aae673df5b9a783bffae7ef1110657 timestamp : 2022-03-31 08:41:25 UTC dependencies: - bzip2 >=1.0.8,<2.0a0 - libffi >=3.3,<4.0a0 - ncurses >=6.3,<7.0a0 - openssl >=1.1.1n,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.38.0,<4.0a0 - tk >=8.6.11,<8.7.0a0 - tzdata - xz >=5.2.5,<6.0a0 - zlib >=1.2.11,<1.3.0a0 - pip python 3.10.6 hbdb9e5c_0 ------------------------ file name : python-3.10.6-hbdb9e5c_0.conda name : python version : 3.10.6 build : hbdb9e5c_0 build number: 0 size : 10.6 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.6-hbdb9e5c_0.conda md5 : 88a21cb05becd5c8e9c400c843bf1e28 timestamp : 2022-10-07 20:22:27 UTC dependencies: - bzip2 >=1.0.8,<2.0a0 - libffi >=3.3,<4.0a0 - ncurses >=6.3,<7.0a0 - openssl >=1.1.1q,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.39.3,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.2.6,<6.0a0 - zlib >=1.2.12,<1.3.0a0 - pip python 3.10.6 hbdb9e5c_1 ------------------------ file name : python-3.10.6-hbdb9e5c_1.conda name : python version : 3.10.6 build : hbdb9e5c_1 build number: 1 size : 10.6 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.6-hbdb9e5c_1.conda md5 : 82661371d0778f010384ed0a062cb3ae timestamp : 2022-10-24 16:08:02 UTC dependencies: - bzip2 >=1.0.8,<2.0a0 - libffi >=3.3,<4.0a0 - ncurses >=6.3,<7.0a0 - openssl >=1.1.1q,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.39.3,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.2.6,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.10.8 hbdb9e5c_0 ------------------------ file name : python-3.10.8-hbdb9e5c_0.conda name : python version : 3.10.8 build : hbdb9e5c_0 build number: 0 size : 13.0 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.8-hbdb9e5c_0.conda md5 : 545b607406621ce61491dde188a2ac24 timestamp : 2022-11-04 13:49:28 UTC dependencies: - bzip2 >=1.0.8,<2.0a0 - libffi >=3.3,<4.0a0 - ncurses >=6.3,<7.0a0 - openssl >=1.1.1q,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.39.3,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.2.6,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.10.8 hc0d8a6c_1 ------------------------ file name : python-3.10.8-hc0d8a6c_1.conda name : python version : 3.10.8 build : hc0d8a6c_1 build number: 1 size : 12.9 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.8-hc0d8a6c_1.conda md5 : 6b0a8c1832a2b63c7bfb0f6de5967601 timestamp : 2022-11-24 14:11:54 UTC dependencies: - bzip2 >=1.0.8,<2.0a0 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.3,<7.0a0 - openssl >=1.1.1s,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.40.0,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.2.6,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.10.9 hc0d8a6c_0 ------------------------ file name : python-3.10.9-hc0d8a6c_0.conda name : python version : 3.10.9 build : hc0d8a6c_0 build number: 0 size : 12.0 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.9-hc0d8a6c_0.conda md5 : f87a2e1f7595344e3d8e0842f0016831 timestamp : 2023-01-11 15:21:53 UTC dependencies: - bzip2 >=1.0.8,<2.0a0 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.3,<7.0a0 - openssl >=1.1.1s,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.40.1,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.2.8,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.10.9 hc0d8a6c_1 ------------------------ file name : python-3.10.9-hc0d8a6c_1.conda name : python version : 3.10.9 build : hc0d8a6c_1 build number: 1 size : 12.9 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.9-hc0d8a6c_1.conda md5 : b49223285e681289fe846def063b8c61 timestamp : 2023-03-01 18:24:33 UTC dependencies: - bzip2 >=1.0.8,<2.0a0 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=1.1.1t,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.40.1,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.2.10,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.10.9 hc0d8a6c_2 ------------------------ file name : python-3.10.9-hc0d8a6c_2.conda name : python version : 3.10.9 build : hc0d8a6c_2 build number: 2 size : 13.0 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.9-hc0d8a6c_2.conda md5 : ede0e4fc40d6eefe7ae13e82831e1578 timestamp : 2023-03-08 10:48:06 UTC dependencies: - bzip2 >=1.0.8,<2.0a0 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=1.1.1t,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.40.1,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.2.10,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.10.10 hc0d8a6c_2 ------------------------- file name : python-3.10.10-hc0d8a6c_2.conda name : python version : 3.10.10 build : hc0d8a6c_2 build number: 2 size : 13.0 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.10-hc0d8a6c_2.conda md5 : bd5ef8b4531bcd58ac2c00393a7d8c3f timestamp : 2023-03-21 18:44:40 UTC dependencies: - bzip2 >=1.0.8,<2.0a0 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=1.1.1t,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.41.1,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.2.10,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.10.11 hb885b13_3 ------------------------- file name : python-3.10.11-hb885b13_3.conda name : python version : 3.10.11 build : hb885b13_3 build number: 3 size : 13.0 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.11-hb885b13_3.conda md5 : 9cfd49419eb60ea5fca2bc416708d152 timestamp : 2023-05-17 19:34:14 UTC dependencies: - bzip2 >=1.0.8,<2.0a0 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=3.0.8,<4.0a0 - readline >=8.1.2,<9.0a0 - sqlite >=3.41.2,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.4.2,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.10.11 hc0d8a6c_2 ------------------------- file name : python-3.10.11-hc0d8a6c_2.conda name : python version : 3.10.11 build : hc0d8a6c_2 build number: 2 size : 13.0 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.11-hc0d8a6c_2.conda md5 : 658853b9ef9b3961212f913f69d93a51 timestamp : 2023-04-20 19:02:14 UTC dependencies: - bzip2 >=1.0.8,<2.0a0 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=1.1.1t,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.41.2,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.2.10,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.10.12 hb885b13_0 ------------------------- file name : python-3.10.12-hb885b13_0.conda name : python version : 3.10.12 build : hb885b13_0 build number: 0 size : 13.0 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.12-hb885b13_0.conda md5 : e48fe886a96893db3aab407ebb7e1878 timestamp : 2023-07-05 20:05:59 UTC dependencies: - bzip2 >=1.0.8,<2.0a0 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=3.0.9,<4.0a0 - readline >=8.1.2,<9.0a0 - sqlite >=3.41.2,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.4.2,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.10.12 hc0d8a6c_0 ------------------------- file name : python-3.10.12-hc0d8a6c_0.conda name : python version : 3.10.12 build : hc0d8a6c_0 build number: 0 size : 13.0 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.12-hc0d8a6c_0.conda md5 : 5c51d1ea23c42b63ad5181e4d0d290d6 timestamp : 2023-07-05 19:56:28 UTC dependencies: - bzip2 >=1.0.8,<2.0a0 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=1.1.1u,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.41.2,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.4.2,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.10.13 hb885b13_0 ------------------------- file name : python-3.10.13-hb885b13_0.conda name : python version : 3.10.13 build : hb885b13_0 build number: 0 size : 13.0 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.13-hb885b13_0.conda md5 : 27eae236674f98a3bbb86231e68d0c83 timestamp : 2023-09-11 13:19:30 UTC dependencies: - bzip2 >=1.0.8,<2.0a0 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=3.0.10,<4.0a0 - readline >=8.1.2,<9.0a0 - sqlite >=3.41.2,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.4.2,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.10.13 hc0d8a6c_0 ------------------------- file name : python-3.10.13-hc0d8a6c_0.conda name : python version : 3.10.13 build : hc0d8a6c_0 build number: 0 size : 13.0 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.13-hc0d8a6c_0.conda md5 : 19df08f8f498cffb70e567ab3e80a07a timestamp : 2023-09-11 13:28:12 UTC dependencies: - bzip2 >=1.0.8,<2.0a0 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=1.1.1v,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.41.2,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.4.2,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.10.14 hb885b13_0 ------------------------- file name : python-3.10.14-hb885b13_0.conda name : python version : 3.10.14 build : hb885b13_0 build number: 0 size : 13.0 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.14-hb885b13_0.conda md5 : 0ad0a3a0082c794a6e34c93153baee3c timestamp : 2024-03-21 16:25:06 UTC dependencies: - bzip2 >=1.0.8,<2.0a0 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=3.0.13,<4.0a0 - readline >=8.1.2,<9.0a0 - sqlite >=3.41.2,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.4.6,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.11.0 hc0d8a6c_2 ------------------------ file name : python-3.11.0-hc0d8a6c_2.conda name : python version : 3.11.0 build : hc0d8a6c_2 build number: 2 size : 15.3 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.11.0-hc0d8a6c_2.conda md5 : 1a7019acdc1637c47a0b2d94fd8ca044 timestamp : 2023-01-16 17:19:20 UTC dependencies: - bzip2 >=1.0.8,<2.0a0 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.3,<7.0a0 - openssl >=1.1.1s,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.40.1,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.2.8,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.11.0 hc0d8a6c_3 ------------------------ file name : python-3.11.0-hc0d8a6c_3.conda name : python version : 3.11.0 build : hc0d8a6c_3 build number: 3 size : 15.3 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.11.0-hc0d8a6c_3.conda md5 : 6b08c690468a632dcfd0ef55e1b26ab0 timestamp : 2023-03-01 18:41:04 UTC dependencies: - bzip2 >=1.0.8,<2.0a0 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=1.1.1t,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.40.1,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.2.10,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.11.2 hc0d8a6c_0 ------------------------ file name : python-3.11.2-hc0d8a6c_0.conda name : python version : 3.11.2 build : hc0d8a6c_0 build number: 0 size : 15.3 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.11.2-hc0d8a6c_0.conda md5 : 2436b0e3116ed03300c05217a4bf1fa6 timestamp : 2023-03-27 23:45:26 UTC dependencies: - bzip2 >=1.0.8,<2.0a0 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=1.1.1t,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.41.1,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.2.10,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.11.3 hb885b13_1 ------------------------ file name : python-3.11.3-hb885b13_1.conda name : python version : 3.11.3 build : hb885b13_1 build number: 1 size : 15.3 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.11.3-hb885b13_1.conda md5 : 23c34a0cd50510828470216735b16180 timestamp : 2023-05-15 23:09:06 UTC dependencies: - bzip2 >=1.0.8,<2.0a0 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=3.0.8,<4.0a0 - readline >=8.1.2,<9.0a0 - sqlite >=3.41.2,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.4.2,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.11.3 hc0d8a6c_0 ------------------------ file name : python-3.11.3-hc0d8a6c_0.conda name : python version : 3.11.3 build : hc0d8a6c_0 build number: 0 size : 15.3 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.11.3-hc0d8a6c_0.conda md5 : 2fde54a2c622f9574e2fb47d20e92502 timestamp : 2023-04-19 23:56:57 UTC dependencies: - bzip2 >=1.0.8,<2.0a0 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=1.1.1t,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.41.2,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.2.10,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.11.4 hb885b13_0 ------------------------ file name : python-3.11.4-hb885b13_0.conda name : python version : 3.11.4 build : hb885b13_0 build number: 0 size : 15.3 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.11.4-hb885b13_0.conda md5 : 3d614a1117ed0a2dc79776b2dc0b98e5 timestamp : 2023-07-05 13:47:27 UTC dependencies: - bzip2 >=1.0.8,<2.0a0 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=3.0.9,<4.0a0 - readline >=8.1.2,<9.0a0 - sqlite >=3.41.2,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.4.2,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.11.4 hc0d8a6c_0 ------------------------ file name : python-3.11.4-hc0d8a6c_0.conda name : python version : 3.11.4 build : hc0d8a6c_0 build number: 0 size : 15.3 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.11.4-hc0d8a6c_0.conda md5 : 2088239ff316e6d47923394f318afbe2 timestamp : 2023-07-05 14:01:09 UTC dependencies: - bzip2 >=1.0.8,<2.0a0 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=1.1.1u,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.41.2,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.4.2,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.11.5 hb885b13_0 ------------------------ file name : python-3.11.5-hb885b13_0.conda name : python version : 3.11.5 build : hb885b13_0 build number: 0 size : 15.4 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.11.5-hb885b13_0.conda md5 : 6f528bdf159139704ab578df329dee70 timestamp : 2023-09-11 13:37:21 UTC dependencies: - bzip2 >=1.0.8,<2.0a0 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=3.0.10,<4.0a0 - readline >=8.1.2,<9.0a0 - sqlite >=3.41.2,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.4.2,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.11.5 hc0d8a6c_0 ------------------------ file name : python-3.11.5-hc0d8a6c_0.conda name : python version : 3.11.5 build : hc0d8a6c_0 build number: 0 size : 15.4 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.11.5-hc0d8a6c_0.conda md5 : 31403c947341ac90236eb4fe77681875 timestamp : 2023-09-11 13:24:13 UTC dependencies: - bzip2 >=1.0.8,<2.0a0 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=1.1.1v,<1.1.2a - readline >=8.1.2,<9.0a0 - sqlite >=3.41.2,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.4.2,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.11.7 hb885b13_0 ------------------------ file name : python-3.11.7-hb885b13_0.conda name : python version : 3.11.7 build : hb885b13_0 build number: 0 size : 15.4 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.11.7-hb885b13_0.conda md5 : adc1a4b7f0736001e3cedf4597b24c0d timestamp : 2023-12-15 18:15:44 UTC dependencies: - bzip2 >=1.0.8,<2.0a0 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=3.0.12,<4.0a0 - readline >=8.1.2,<9.0a0 - sqlite >=3.41.2,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.4.5,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.11.8 hb885b13_0 ------------------------ file name : python-3.11.8-hb885b13_0.conda name : python version : 3.11.8 build : hb885b13_0 build number: 0 size : 15.5 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.11.8-hb885b13_0.conda md5 : dd7e4ebe9abe2f6d7901fb861ef90ca5 timestamp : 2024-02-26 21:43:06 UTC dependencies: - bzip2 >=1.0.8,<2.0a0 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=3.0.13,<4.0a0 - readline >=8.1.2,<9.0a0 - sqlite >=3.41.2,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.4.5,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.11.9 hb885b13_0 ------------------------ file name : python-3.11.9-hb885b13_0.conda name : python version : 3.11.9 build : hb885b13_0 build number: 0 size : 15.5 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.11.9-hb885b13_0.conda md5 : c0fdd4a7e5a6af3681840d6b650cef87 timestamp : 2024-04-19 16:52:24 UTC dependencies: - bzip2 >=1.0.8,<2.0a0 - libffi >=3.4,<3.5 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=3.0.13,<4.0a0 - readline >=8.1.2,<9.0a0 - sqlite >=3.41.2,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.4.6,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.12.0 h99e199e_0 ------------------------ file name : python-3.12.0-h99e199e_0.conda name : python version : 3.12.0 build : h99e199e_0 build number: 0 size : 14.0 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.12.0-h99e199e_0.conda md5 : b5d6ef7d1b8ed9ff778f8d4c69afd723 timestamp : 2023-10-02 17:28:30 UTC dependencies: - bzip2 >=1.0.8,<2.0a0 - expat >=2.5.0,<3.0a0 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=3.0.11,<4.0a0 - readline >=8.1.2,<9.0a0 - sqlite >=3.41.2,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.4.2,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.12.1 h99e199e_0 ------------------------ file name : python-3.12.1-h99e199e_0.conda name : python version : 3.12.1 build : h99e199e_0 build number: 0 size : 14.0 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.12.1-h99e199e_0.conda md5 : 5ec5d4828c2cb7720fd1e80ed2e8548a timestamp : 2024-01-19 15:52:20 UTC dependencies: - bzip2 >=1.0.8,<2.0a0 - expat >=2.5.0,<3.0a0 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=3.0.12,<4.0a0 - readline >=8.1.2,<9.0a0 - sqlite >=3.41.2,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.4.5,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.12.2 h99e199e_0 ------------------------ file name : python-3.12.2-h99e199e_0.conda name : python version : 3.12.2 build : h99e199e_0 build number: 0 size : 14.0 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.12.2-h99e199e_0.conda md5 : ccdb24048e0e912e3a21eee26cdb7306 timestamp : 2024-02-27 19:04:26 UTC dependencies: - bzip2 >=1.0.8,<2.0a0 - expat >=2.5.0,<3.0a0 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=3.0.13,<4.0a0 - readline >=8.1.2,<9.0a0 - sqlite >=3.41.2,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.4.6,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip python 3.12.3 h99e199e_0 ------------------------ file name : python-3.12.3-h99e199e_0.conda name : python version : 3.12.3 build : h99e199e_0 build number: 0 size : 14.0 MB license : PSF-2.0 subdir : osx-arm64 url : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.12.3-h99e199e_0.conda md5 : 7495a2a48f01afd0866fb72d1a6697b7 timestamp : 2024-04-19 16:54:45 UTC dependencies: - bzip2 >=1.0.8,<2.0a0 - expat >=2.6.2,<3.0a0 - libffi >=3.4,<4.0a0 - ncurses >=6.4,<7.0a0 - openssl >=3.0.13,<4.0a0 - readline >=8.1.2,<9.0a0 - sqlite >=3.41.2,<4.0a0 - tk >=8.6.12,<8.7.0a0 - tzdata - xz >=5.4.6,<6.0a0 - zlib >=1.2.13,<1.3.0a0 - pip ================================================ FILE: docs/DESIGN-uv-compile.md ================================================ # DESIGN: Unified Dependency Resolution (--uv-compile) Implementation ## Architecture Decision: Pass-Through cm_cli already fully implements `--uv-compile` and `UnifiedDepResolver`, so comfy-cli adopts a **pass-through** approach. **Rationale:** - Avoids duplicating logic already in cm_cli - Maintains separation of concerns with comfy-cli's `DependencyCompiler` (`--fast-deps`) - No comfy-cli changes needed when cm_cli updates its resolver **Alternative (rejected):** Import `UnifiedDepResolver` directly in comfy-cli — increases coupling between cm_cli and comfy-cli, adds maintenance burden. ## Component Diagram ``` User CLI │ ├─ comfy node install --uv-compile │ │ │ ▼ │ command.py:install() │ │ 1. mutual exclusivity check │ │ 2. _resolve_uv_compile() → effective value │ │ 3. execute_cm_cli(..., uv_compile=True) │ │ │ ▼ │ cm_cli_util.py:execute_cm_cli() │ │ → cmd += ["--uv-compile"] │ │ │ ▼ │ subprocess: python -m cm_cli install --uv-compile │ │ │ ▼ │ cm_cli → UnifiedDepResolver → uv pip compile → pip install │ ├─ comfy manager uv-compile-default true │ │ │ ▼ │ command.py:uv_compile_default() │ │ → ConfigManager.set("uv_compile_default", "True") │ │ → config.ini [DEFAULT] section │ └─ comfy node uv-sync │ ▼ execute_cm_cli(["uv-sync"]) │ ▼ subprocess: python -m cm_cli uv-sync ``` ## File Changes ### 1. `comfy_cli/constants.py` ```python CONFIG_KEY_UV_COMPILE_DEFAULT = "uv_compile_default" ``` INI config key. Stored as `"True"` / `"False"` string in `[DEFAULT]` section. ### 2. `comfy_cli/command/custom_nodes/cm_cli_util.py` Added `uv_compile=False` parameter to `execute_cm_cli()`: ```python def execute_cm_cli(args, channel=None, fast_deps=False, no_deps=False, uv_compile=False, mode=None, raise_on_error=False): ``` Flag pass-through logic (added alongside existing `fast_deps`/`no_deps` branch): ```python if uv_compile: cmd += ["--uv-compile"] elif fast_deps or no_deps: cmd += ["--no-deps"] ``` `uv_compile` takes priority over `fast_deps`/`no_deps`. By the time this function is called, the value is already resolved to a plain `bool` — no `None` handling needed here. ### 3. `comfy_cli/command/custom_nodes/command.py` #### 3.1 Tri-state flag pattern All 7 commands changed `uv_compile` parameter to `bool | None`: ```python uv_compile: Annotated[ bool | None, typer.Option( "--uv-compile/--no-uv-compile", show_default=False, help="After {verb}, batch-resolve all dependencies via uv pip compile ...", ), ] = None, ``` typer's `--flag/--no-flag` pattern: - `--uv-compile` → `True` - `--no-uv-compile` → `False` - not specified → `None` #### 3.2 Resolution helper ```python def _resolve_uv_compile( uv_compile: bool | None, fast_deps: bool = False, no_deps: bool = False, ) -> bool: ``` **Resolution priority:** ``` uv_compile is True → return True (explicit --uv-compile) uv_compile is False → return False (explicit --no-uv-compile) uv_compile is None → check config: config == "True" AND (fast_deps or no_deps) → return False (conflict: explicit flag wins) config == "True" → return True (config default) otherwise → return False (no config) ``` Each command passes the appropriate conflicting flags: | Command | Call | |---------|------| | `install` | `_resolve_uv_compile(uv_compile, fast_deps, no_deps)` | | `reinstall` | `_resolve_uv_compile(uv_compile, fast_deps=fast_deps)` | | Other 5 | `_resolve_uv_compile(uv_compile)` | #### 3.3 Mutual exclusivity validation **install** (3-way): ```python exclusive_flags = [ name for name, val in [("--fast-deps", fast_deps), ("--no-deps", no_deps), ("--uv-compile", uv_compile)] if val ] if len(exclusive_flags) > 1: typer.echo(f"Cannot use {' and '.join(exclusive_flags)} together", err=True) raise typer.Exit(code=1) ``` `uv_compile=None` is falsy, so it is not included in the list. Config-resolved values are not checked here — only the raw flag value — so config defaults never trigger mutual exclusivity errors. **reinstall** (2-way): ```python if fast_deps and uv_compile is True: typer.echo("Cannot use --fast-deps and --uv-compile together", err=True) raise typer.Exit(code=1) ``` `is True` identity check explicitly excludes `None`. #### 3.4 Manager config command ```python @manager_app.command("uv-compile-default") def uv_compile_default( enabled: Annotated[bool, typer.Argument(help="true to enable, false to disable")], ): config_manager = ConfigManager() config_manager.set(constants.CONFIG_KEY_UV_COMPILE_DEFAULT, str(enabled)) ``` typer automatically parses `true`/`false` strings to `bool`. `ConfigManager.set()` writes to `config.ini` immediately. #### 3.5 Standalone command ```python @app.command("uv-sync") def uv_sync(): execute_cm_cli(["uv-sync"]) ``` Independent of config default. Always directly invokes cm_cli's `uv-sync` subcommand. ## Data Flow ### Config storage ```ini # ~/.config/comfy-cli/config.ini [DEFAULT] uv_compile_default = True ``` `ConfigManager.get("uv_compile_default")` → `"True"` | `"False"` | `None` ### Flag resolution flow ``` CLI input │ ├─ --uv-compile → uv_compile = True ├─ --no-uv-compile → uv_compile = False └─ (none) → uv_compile = None │ _resolve_uv_compile() │ ┌──────┴──────┐ │ not None? │ └──────┬──────┘ Yes │ No │ │ return ▼ as-is config.ini │ ┌──────┴──────┐ │ == "True"? │ └──────┬──────┘ Yes │ No │ │ ▼ └→ return False conflicting flags? │ Yes │ No │ │ return return False True ``` ### Subprocess command construction ``` execute_cm_cli(["install", "node-a"], uv_compile=True) → [python, -m, cm_cli, install, node-a, --uv-compile] execute_cm_cli(["install", "node-a"], fast_deps=True) → [python, -m, cm_cli, install, node-a, --no-deps] + DependencyCompiler.compile_deps() / install_deps() execute_cm_cli(["install", "node-a"], uv_compile=False) → [python, -m, cm_cli, install, node-a] (no extra flags — default per-node pip install) ``` ## Test Strategy ### Existing tests (regression) All 207 existing tests pass. Changes to `--uv-compile` do not affect existing behavior. ### Recommended new tests | Test | Verifies | |------|----------| | `test_resolve_uv_compile_explicit_true` | Explicit True → True | | `test_resolve_uv_compile_explicit_false` | Explicit False → False | | `test_resolve_uv_compile_config_true` | None + config True → True | | `test_resolve_uv_compile_config_false` | None + config False → False | | `test_resolve_uv_compile_config_none` | None + config None → False | | `test_resolve_uv_compile_config_with_fast_deps` | None + config True + fast_deps → False | | `test_resolve_uv_compile_config_with_no_deps` | None + config True + no_deps → False | | `test_install_mutual_exclusivity` | --uv-compile + --fast-deps → exit 1 | | `test_install_config_no_exclusivity` | config True + --fast-deps → no error | | `test_manager_uv_compile_default_enable` | Config stores "True" | | `test_manager_uv_compile_default_disable` | Config stores "False" | ## Compatibility Matrix | comfy-cli | ComfyUI-Manager | Behavior | |-----------|-----------------|----------| | This change | v4.1+ | `--uv-compile` works correctly | | This change | v4.0 or older | cm_cli returns unknown flag error | | Previous version | v4.1+ | `--uv-compile` unavailable (no flag) | | Previous version | v4.0 or older | Existing behavior unchanged | ================================================ FILE: docs/PRD-uv-compile.md ================================================ # PRD: Unified Dependency Resolution (--uv-compile) Support ## Overview Add `--uv-compile` flag to comfy-cli to integrate with ComfyUI-Manager v4.1+'s Unified Dependency Resolver. Users can batch-resolve all custom node dependencies via `uv pip compile` after install/update operations. ## Background ### Problem Each ComfyUI custom node ships its own `requirements.txt`. The default approach (`pip install` per node) frequently causes dependency conflicts between nodes. ComfyUI-Manager v4.1+ introduced `UnifiedDepResolver` to solve this, but comfy-cli had no way to invoke it. ### Existing Approaches | Approach | Flag | Implementation | Behavior | |----------|------|----------------|----------| | Default | (none) | cm_cli | Per-node `pip install` | | Fast deps | `--fast-deps` | comfy-cli `DependencyCompiler` | comfy-cli side `uv pip compile` | | No deps | `--no-deps` | cm_cli | Skip dependency installation | | **Unified** | **`--uv-compile`** | **cm_cli `UnifiedDepResolver`** | **cm_cli side batch resolution** | ### Target Users - ComfyUI-Manager v4.1+ users - Users managing many custom nodes - Users experiencing dependency conflicts ## Requirements ### FR-1: Add --uv-compile flag to 7 commands **Target commands:** | # | Command | Existing dep flags | |---|---------|-------------------| | 1 | `comfy node install` | `--fast-deps`, `--no-deps` | | 2 | `comfy node reinstall` | `--fast-deps` | | 3 | `comfy node update` | (none) | | 4 | `comfy node fix` | (none) | | 5 | `comfy node restore-snapshot` | (none) | | 6 | `comfy node restore-dependencies` | (none) | | 7 | `comfy node install-deps` | (none) | **Behavior:** When the flag is passed, append `--uv-compile` to the cm_cli subprocess command. ### FR-2: Standalone uv-sync command ``` comfy node uv-sync ``` Directly invokes cm_cli's `uv-sync` subcommand. Batch-resolves all installed custom node dependencies without requiring a prior install/update operation. ### FR-3: --no-uv-compile flag Add `--no-uv-compile` to all 7 commands so users can explicitly disable the config default on a per-command basis. ### FR-4: Config default setting ``` comfy manager uv-compile-default true # Enable by default comfy manager uv-compile-default false # Disable by default ``` Once enabled, `--uv-compile` is automatically applied to all custom node operations. ### FR-5: Mutual exclusivity `--uv-compile`, `--fast-deps`, and `--no-deps` are mutually exclusive. | Combination | Result | |-------------|--------| | `--uv-compile --fast-deps` | Error | | `--uv-compile --no-deps` | Error | | `--fast-deps --no-deps` | Error | | config default + `--fast-deps` | `--fast-deps` wins (no error) | | config default + `--no-uv-compile` | Disabled | ### NFR-1: Backward compatibility - No impact on existing `--fast-deps` / `--no-deps` behavior - Without flag and without config, behavior is identical to before (per-node pip install) ### NFR-2: Minimum version Requires ComfyUI-Manager v4.1+. On older versions, cm_cli returns its own error for the unknown flag. comfy-cli does not perform version checking. ## Out of Scope - `comfy install` (core ComfyUI installation) — separate dependency system - Modifications to cm_cli's internal UnifiedDepResolver logic - Automatic ComfyUI-Manager version detection ================================================ FILE: docs/TESTING-e2e.md ================================================ # E2E Testing Guide E2E tests perform real `comfy install`, `comfy launch`, and `comfy node` operations. They are **disabled by default** and must be explicitly enabled. ## Environment variables | Variable | Default | Description | |----------|---------|-------------| | `TEST_E2E` | `false` | Set to `true` to enable E2E tests | | `TEST_E2E_COMFY_URL` | *(empty — uses default)* | Custom ComfyUI repo URL. Supports `@branch` syntax | | `TEST_E2E_COMFY_INSTALL_FLAGS` | `--cpu` | Extra flags passed to `comfy install` | | `TEST_E2E_COMFY_LAUNCH_FLAGS_EXTRA` | `--cpu` | Extra flags passed to `comfy launch` | ## Basic usage ```bash TEST_E2E=true pytest tests/e2e/ ``` Installs ComfyUI from the default upstream (`Comfy-Org/ComfyUI`), launches it in the background, runs the test suite, then stops the server. ## Pre-release testing To test features that depend on unreleased ComfyUI changes (e.g. `manager_requirements.txt` not yet merged upstream), point the E2E suite at a fork/branch: ```bash TEST_E2E=true \ TEST_E2E_COMFY_URL="https://github.com/ltdrdata/ComfyUI.git@dr-bump-manager" \ pytest tests/e2e/ -v ``` This clones `ltdrdata/ComfyUI` at the `dr-bump-manager` branch, which contains `manager_requirements.txt` for pip-based Manager v4 installation. ### Full pre-release run (GPU) ```bash TEST_E2E=true \ TEST_E2E_COMFY_URL="https://github.com/ltdrdata/ComfyUI.git@dr-bump-manager" \ TEST_E2E_COMFY_INSTALL_FLAGS="" \ TEST_E2E_COMFY_LAUNCH_FLAGS_EXTRA="" \ pytest tests/e2e/ -v ``` ## Test suites ### `test_e2e.py` — General functionality Covers model download, custom node lifecycle, workflow execution, and basic Manager v4 smoke tests. | Test | Description | |------|-------------| | `test_model` | Download, list, and remove a model | | `test_node` | Install, reinstall, show, update, disable, enable, publish a custom node | | `test_manager_installed` | Verifies `cm_cli` is importable after install | | `test_node_uv_compile` | Installs a node with `--uv-compile` and runs `comfy node uv-sync` | | `test_uv_compile_default_config` | Sets `uv-compile-default`, verifies `comfy env` display | | `test_run` | Downloads a checkpoint and executes a workflow end-to-end | ### `test_e2e_uv_compile.py` — Unified dependency resolution Comprehensive `--uv-compile` E2E suite. **Requires Manager v4.1+** — automatically skipped when `cm_cli` is not importable. #### Test packs Two categories of node packs are used: - **Real packs** (`comfyui-impact-pack`, `comfyui-inspire-pack`) — production node packs for verifying normal installation succeeds without conflicts. - **Conflict fixture packs** (`nodepack-test1-do-not-install`, `nodepack-test2-do-not-install`) — ltdrdata's dedicated test packs that intentionally conflict on ansible versions (`ansible==9.13.0` vs `ansible-core==2.14.0`). Contain no executable code. Supply-chain safety: only node packs from verified authors (ltdrdata, comfyanonymous, Comfy-Org) are used. #### Test scenarios **Normal installation (real packs)** | Test | Scenario | Packs | |------|----------|-------| | `test_real_packs_sequential_no_conflict` | Install two real packs one-by-one with `--uv-compile` — each resolves successfully, no conflicts | impact, inspire | | `test_real_packs_simultaneous_no_conflict` | Install two real packs in a single command with `--uv-compile` — resolves successfully, no conflicts | impact, inspire | **Progressive conflict** | Test | Scenario | Packs | |------|----------|-------| | `test_progressive_conflict` | Install real packs (OK) → add conflict-pack-1 (still OK) → add conflict-pack-2 (conflict detected with attribution) | impact, inspire, test1, test2 | **Command coverage (--uv-compile flag on each command)** | Test | Scenario | Packs | |------|----------|-------| | `test_node_reinstall_uv_compile` | Reinstall an installed pack with `--uv-compile` — resolution runs | test1 | | `test_node_update_uv_compile` | Update an installed pack with `--uv-compile` — resolution runs | test1 | | `test_node_fix_uv_compile` | Fix an installed pack with `--uv-compile` — resolution runs | test1 | | `test_node_restore_deps_uv_compile` | `restore-dependencies --uv-compile` — resolution runs | test1 | **Standalone uv-sync** | Test | Scenario | Packs | |------|----------|-------| | `test_node_uv_sync_standalone` | `comfy node uv-sync` resolves installed pack dependencies | test1 | | `test_node_uv_sync_standalone_conflict` | `comfy node uv-sync` with conflicting packs — shows conflict attribution | test1, test2 | **Config default and overrides** | Test | Scenario | Packs | |------|----------|-------| | `test_uv_compile_config_default` | `uv-compile-default true` → install without flag triggers resolution | test1 | | `test_no_uv_compile_overrides_config` | Config default enabled, `--no-uv-compile` overrides — resolution does not run | test1 | **Mutual exclusivity** | Test | Scenario | Packs | |------|----------|-------| | `test_uv_compile_mutual_exclusivity` | `--uv-compile` with `--fast-deps` or `--no-deps` — rejected with error | test1 | #### Fixtures and isolation - `workspace` (module-scoped): installs ComfyUI, launches server in background, yields workspace path, stops server on teardown. - `comfy_cli`: returns `comfy --workspace {ws}` command prefix. - `_clean_test_packs` (autouse, function-scoped): removes conflict fixture packs before and after each test. Real packs are **not** removed between tests (they persist in the workspace). - Config default tests use `try/finally` to restore the setting after each test. ## Notes - E2E tests create a temporary workspace directory (`comfy-`) in the current working directory. It is **not** automatically cleaned up. - Each test file has its own `workspace` fixture (`module`-scoped) — all tests within a file share a single ComfyUI installation. - Tests that require Manager v4 are automatically skipped when `cm_cli` is not importable. ================================================ FILE: pylock.toml ================================================ # This file was autogenerated by uv via the following command: # uv export --output-file=pylock.toml lock-version = "1.0" created-by = "uv" requires-python = ">=3.10" [[packages]] name = "anyio" version = "4.10.0" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", upload-time = 2025-08-04T08:54:26Z, size = 213252, hashes = { sha256 = "3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6" } } wheels = [{ url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", upload-time = 2025-08-04T08:54:24Z, size = 107213, hashes = { sha256 = "60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1" } }] [[packages]] name = "arrow" version = "1.3.0" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/2e/00/0f6e8fcdb23ea632c866620cc872729ff43ed91d284c866b515c6342b173/arrow-1.3.0.tar.gz", upload-time = 2023-09-30T22:11:18Z, size = 131960, hashes = { sha256 = "d4540617648cb5f895730f1ad8c82a65f2dad0166f57b75f3ca54759c4d67a85" } } wheels = [{ url = "https://files.pythonhosted.org/packages/f8/ed/e97229a566617f2ae958a6b13e7cc0f585470eac730a73e9e82c32a3cdd2/arrow-1.3.0-py3-none-any.whl", upload-time = 2023-09-30T22:11:16Z, size = 66419, hashes = { sha256 = "c728b120ebc00eb84e01882a6f5e7927a53960aa990ce7dd2b10f39005a67f80" } }] [[packages]] name = "binaryornot" version = "0.4.4" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/a7/fe/7ebfec74d49f97fc55cd38240c7a7d08134002b1e14be8c3897c0dd5e49b/binaryornot-0.4.4.tar.gz", upload-time = 2017-08-03T15:55:25Z, size = 371054, hashes = { sha256 = "359501dfc9d40632edc9fac890e19542db1a287bbcfa58175b66658392018061" } } wheels = [{ url = "https://files.pythonhosted.org/packages/24/7e/f7b6f453e6481d1e233540262ccbfcf89adcd43606f44a028d7f5fae5eb2/binaryornot-0.4.4-py2.py3-none-any.whl", upload-time = 2017-08-03T15:55:31Z, size = 9006, hashes = { sha256 = "b8b71173c917bddcd2c16070412e369c3ed7f0528926f70cac18a6c97fd563e4" } }] [[packages]] name = "certifi" version = "2025.8.3" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", upload-time = 2025-08-03T03:07:47Z, size = 162386, hashes = { sha256 = "e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407" } } wheels = [{ url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", upload-time = 2025-08-03T03:07:45Z, size = 161216, hashes = { sha256 = "f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5" } }] [[packages]] name = "chardet" version = "5.2.0" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", upload-time = 2023-08-01T19:23:02Z, size = 2069618, hashes = { sha256 = "1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7" } } wheels = [{ url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", upload-time = 2023-08-01T19:23:00Z, size = 199385, hashes = { sha256 = "e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970" } }] [[packages]] name = "charset-normalizer" version = "3.4.2" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", upload-time = 2025-05-02T08:34:42Z, size = 126367, hashes = { sha256 = "5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63" } } wheels = [ { url = "https://files.pythonhosted.org/packages/95/28/9901804da60055b406e1a1c5ba7aac1276fb77f1dde635aabfc7fd84b8ab/charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", upload-time = 2025-05-02T08:31:46Z, size = 201818, hashes = { sha256 = "7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941" } }, { url = "https://files.pythonhosted.org/packages/d9/9b/892a8c8af9110935e5adcbb06d9c6fe741b6bb02608c6513983048ba1a18/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", upload-time = 2025-05-02T08:31:48Z, size = 144649, hashes = { sha256 = "b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd" } }, { url = "https://files.pythonhosted.org/packages/7b/a5/4179abd063ff6414223575e008593861d62abfc22455b5d1a44995b7c101/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", upload-time = 2025-05-02T08:31:50Z, size = 155045, hashes = { sha256 = "9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6" } }, { url = "https://files.pythonhosted.org/packages/3b/95/bc08c7dfeddd26b4be8c8287b9bb055716f31077c8b0ea1cd09553794665/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", upload-time = 2025-05-02T08:31:52Z, size = 147356, hashes = { sha256 = "18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d" } }, { url = "https://files.pythonhosted.org/packages/a8/2d/7a5b635aa65284bf3eab7653e8b4151ab420ecbae918d3e359d1947b4d61/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", upload-time = 2025-05-02T08:31:56Z, size = 149471, hashes = { sha256 = "8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86" } }, { url = "https://files.pythonhosted.org/packages/ae/38/51fc6ac74251fd331a8cfdb7ec57beba8c23fd5493f1050f71c87ef77ed0/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", upload-time = 2025-05-02T08:31:57Z, size = 151317, hashes = { sha256 = "5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c" } }, { url = "https://files.pythonhosted.org/packages/b7/17/edee1e32215ee6e9e46c3e482645b46575a44a2d72c7dfd49e49f60ce6bf/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", upload-time = 2025-05-02T08:31:59Z, size = 146368, hashes = { sha256 = "7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0" } }, { url = "https://files.pythonhosted.org/packages/26/2c/ea3e66f2b5f21fd00b2825c94cafb8c326ea6240cd80a91eb09e4a285830/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", upload-time = 2025-05-02T08:32:01Z, size = 154491, hashes = { sha256 = "b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef" } }, { url = "https://files.pythonhosted.org/packages/52/47/7be7fa972422ad062e909fd62460d45c3ef4c141805b7078dbab15904ff7/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", upload-time = 2025-05-02T08:32:03Z, size = 157695, hashes = { sha256 = "8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6" } }, { url = "https://files.pythonhosted.org/packages/2f/42/9f02c194da282b2b340f28e5fb60762de1151387a36842a92b533685c61e/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", upload-time = 2025-05-02T08:32:04Z, size = 154849, hashes = { sha256 = "68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366" } }, { url = "https://files.pythonhosted.org/packages/67/44/89cacd6628f31fb0b63201a618049be4be2a7435a31b55b5eb1c3674547a/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", upload-time = 2025-05-02T08:32:06Z, size = 150091, hashes = { sha256 = "21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db" } }, { url = "https://files.pythonhosted.org/packages/1f/79/4b8da9f712bc079c0f16b6d67b099b0b8d808c2292c937f267d816ec5ecc/charset_normalizer-3.4.2-cp310-cp310-win32.whl", upload-time = 2025-05-02T08:32:08Z, size = 98445, hashes = { sha256 = "e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a" } }, { url = "https://files.pythonhosted.org/packages/7d/d7/96970afb4fb66497a40761cdf7bd4f6fca0fc7bafde3a84f836c1f57a926/charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", upload-time = 2025-05-02T08:32:10Z, size = 105782, hashes = { sha256 = "f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509" } }, { url = "https://files.pythonhosted.org/packages/05/85/4c40d00dcc6284a1c1ad5de5e0996b06f39d8232f1031cd23c2f5c07ee86/charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", upload-time = 2025-05-02T08:32:11Z, size = 198794, hashes = { sha256 = "be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2" } }, { url = "https://files.pythonhosted.org/packages/41/d9/7a6c0b9db952598e97e93cbdfcb91bacd89b9b88c7c983250a77c008703c/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", upload-time = 2025-05-02T08:32:13Z, size = 142846, hashes = { sha256 = "aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645" } }, { url = "https://files.pythonhosted.org/packages/66/82/a37989cda2ace7e37f36c1a8ed16c58cf48965a79c2142713244bf945c89/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", upload-time = 2025-05-02T08:32:15Z, size = 153350, hashes = { sha256 = "d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd" } }, { url = "https://files.pythonhosted.org/packages/df/68/a576b31b694d07b53807269d05ec3f6f1093e9545e8607121995ba7a8313/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", upload-time = 2025-05-02T08:32:17Z, size = 145657, hashes = { sha256 = "28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8" } }, { url = "https://files.pythonhosted.org/packages/92/9b/ad67f03d74554bed3aefd56fe836e1623a50780f7c998d00ca128924a499/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", upload-time = 2025-05-02T08:32:18Z, size = 147260, hashes = { sha256 = "fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f" } }, { url = "https://files.pythonhosted.org/packages/a6/e6/8aebae25e328160b20e31a7e9929b1578bbdc7f42e66f46595a432f8539e/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", upload-time = 2025-05-02T08:32:20Z, size = 149164, hashes = { sha256 = "0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7" } }, { url = "https://files.pythonhosted.org/packages/8b/f2/b3c2f07dbcc248805f10e67a0262c93308cfa149a4cd3d1fe01f593e5fd2/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", upload-time = 2025-05-02T08:32:21Z, size = 144571, hashes = { sha256 = "efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9" } }, { url = "https://files.pythonhosted.org/packages/60/5b/c3f3a94bc345bc211622ea59b4bed9ae63c00920e2e8f11824aa5708e8b7/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", upload-time = 2025-05-02T08:32:23Z, size = 151952, hashes = { sha256 = "f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544" } }, { url = "https://files.pythonhosted.org/packages/e2/4d/ff460c8b474122334c2fa394a3f99a04cf11c646da895f81402ae54f5c42/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", upload-time = 2025-05-02T08:32:24Z, size = 155959, hashes = { sha256 = "e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82" } }, { url = "https://files.pythonhosted.org/packages/a2/2b/b964c6a2fda88611a1fe3d4c400d39c66a42d6c169c924818c848f922415/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", upload-time = 2025-05-02T08:32:26Z, size = 153030, hashes = { sha256 = "0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0" } }, { url = "https://files.pythonhosted.org/packages/59/2e/d3b9811db26a5ebf444bc0fa4f4be5aa6d76fc6e1c0fd537b16c14e849b6/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", upload-time = 2025-05-02T08:32:28Z, size = 148015, hashes = { sha256 = "6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5" } }, { url = "https://files.pythonhosted.org/packages/90/07/c5fd7c11eafd561bb51220d600a788f1c8d77c5eef37ee49454cc5c35575/charset_normalizer-3.4.2-cp311-cp311-win32.whl", upload-time = 2025-05-02T08:32:30Z, size = 98106, hashes = { sha256 = "daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a" } }, { url = "https://files.pythonhosted.org/packages/a8/05/5e33dbef7e2f773d672b6d79f10ec633d4a71cd96db6673625838a4fd532/charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", upload-time = 2025-05-02T08:32:32Z, size = 105402, hashes = { sha256 = "e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28" } }, { url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", upload-time = 2025-05-02T08:32:33Z, size = 199936, hashes = { sha256 = "0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7" } }, { url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", upload-time = 2025-05-02T08:32:35Z, size = 143790, hashes = { sha256 = "cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3" } }, { url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", upload-time = 2025-05-02T08:32:37Z, size = 153924, hashes = { sha256 = "fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a" } }, { url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", upload-time = 2025-05-02T08:32:38Z, size = 146626, hashes = { sha256 = "d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214" } }, { url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", upload-time = 2025-05-02T08:32:40Z, size = 148567, hashes = { sha256 = "4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a" } }, { url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", upload-time = 2025-05-02T08:32:41Z, size = 150957, hashes = { sha256 = "cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd" } }, { url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", upload-time = 2025-05-02T08:32:43Z, size = 145408, hashes = { sha256 = "a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981" } }, { url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", upload-time = 2025-05-02T08:32:46Z, size = 153399, hashes = { sha256 = "a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c" } }, { url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", upload-time = 2025-05-02T08:32:48Z, size = 156815, hashes = { sha256 = "7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b" } }, { url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", upload-time = 2025-05-02T08:32:49Z, size = 154537, hashes = { sha256 = "bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d" } }, { url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", upload-time = 2025-05-02T08:32:51Z, size = 149565, hashes = { sha256 = "dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f" } }, { url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", upload-time = 2025-05-02T08:32:53Z, size = 98357, hashes = { sha256 = "db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c" } }, { url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", upload-time = 2025-05-02T08:32:54Z, size = 105776, hashes = { sha256 = "5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e" } }, { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", upload-time = 2025-05-02T08:32:56Z, size = 199622, hashes = { sha256 = "926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0" } }, { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", upload-time = 2025-05-02T08:32:58Z, size = 143435, hashes = { sha256 = "eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf" } }, { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", upload-time = 2025-05-02T08:33:00Z, size = 153653, hashes = { sha256 = "3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e" } }, { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", upload-time = 2025-05-02T08:33:02Z, size = 146231, hashes = { sha256 = "98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1" } }, { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", upload-time = 2025-05-02T08:33:04Z, size = 148243, hashes = { sha256 = "6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c" } }, { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", upload-time = 2025-05-02T08:33:06Z, size = 150442, hashes = { sha256 = "e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691" } }, { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", upload-time = 2025-05-02T08:33:08Z, size = 145147, hashes = { sha256 = "1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0" } }, { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", upload-time = 2025-05-02T08:33:09Z, size = 153057, hashes = { sha256 = "ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b" } }, { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", upload-time = 2025-05-02T08:33:11Z, size = 156454, hashes = { sha256 = "32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff" } }, { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", upload-time = 2025-05-02T08:33:13Z, size = 154174, hashes = { sha256 = "289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b" } }, { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", upload-time = 2025-05-02T08:33:15Z, size = 149166, hashes = { sha256 = "4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148" } }, { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", upload-time = 2025-05-02T08:33:17Z, size = 98064, hashes = { sha256 = "aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7" } }, { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", upload-time = 2025-05-02T08:33:18Z, size = 105641, hashes = { sha256 = "aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980" } }, { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", upload-time = 2025-05-02T08:34:40Z, size = 52626, hashes = { sha256 = "7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0" } }, ] [[packages]] name = "click" version = "8.1.8" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", upload-time = 2024-12-21T18:38:44Z, size = 226593, hashes = { sha256 = "ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a" } } wheels = [{ url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", upload-time = 2024-12-21T18:38:41Z, size = 98188, hashes = { sha256 = "63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2" } }] [[packages]] name = "colorama" version = "0.4.6" marker = "sys_platform == 'win32'" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", upload-time = 2022-10-25T02:36:22Z, size = 27697, hashes = { sha256 = "08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44" } } wheels = [{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", upload-time = 2022-10-25T02:36:20Z, size = 25335, hashes = { sha256 = "4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" } }] [[packages]] name = "comfy-cli" directory = { path = ".", editable = true } [[packages]] name = "cookiecutter" version = "2.6.0" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/52/17/9f2cd228eb949a91915acd38d3eecdc9d8893dde353b603f0db7e9f6be55/cookiecutter-2.6.0.tar.gz", upload-time = 2024-02-21T18:02:41Z, size = 158767, hashes = { sha256 = "db21f8169ea4f4fdc2408d48ca44859349de2647fbe494a9d6c3edfc0542c21c" } } wheels = [{ url = "https://files.pythonhosted.org/packages/b6/d9/0137658a353168ffa9d0fc14b812d3834772040858ddd1cb6eeaf09f7a44/cookiecutter-2.6.0-py3-none-any.whl", upload-time = 2024-02-21T18:02:39Z, size = 39177, hashes = { sha256 = "a54a8e37995e4ed963b3e82831072d1ad4b005af736bb17b99c2cbd9d41b6e2d" } }] [[packages]] name = "exceptiongroup" version = "1.3.0" marker = "python_full_version < '3.11'" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", upload-time = 2025-05-10T17:42:51Z, size = 29749, hashes = { sha256 = "b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88" } } wheels = [{ url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", upload-time = 2025-05-10T17:42:49Z, size = 16674, hashes = { sha256 = "4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10" } }] [[packages]] name = "gitdb" version = "4.0.12" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", upload-time = 2025-01-02T07:20:46Z, size = 394684, hashes = { sha256 = "5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571" } } wheels = [{ url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", upload-time = 2025-01-02T07:20:43Z, size = 62794, hashes = { sha256 = "67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf" } }] [[packages]] name = "gitpython" version = "3.1.45" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/9a/c8/dd58967d119baab745caec2f9d853297cec1989ec1d63f677d3880632b88/gitpython-3.1.45.tar.gz", upload-time = 2025-07-24T03:45:54Z, size = 215076, hashes = { sha256 = "85b0ee964ceddf211c41b9f27a49086010a190fd8132a24e21f362a4b36a791c" } } wheels = [{ url = "https://files.pythonhosted.org/packages/01/61/d4b89fec821f72385526e1b9d9a3a0385dda4a72b206d28049e2c7cd39b8/gitpython-3.1.45-py3-none-any.whl", upload-time = 2025-07-24T03:45:52Z, size = 208168, hashes = { sha256 = "8908cb2e02fb3b93b7eb0f2827125cb699869470432cc885f019b8fd0fccff77" } }] [[packages]] name = "h11" version = "0.16.0" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", upload-time = 2025-04-24T03:35:25Z, size = 101250, hashes = { sha256 = "4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1" } } wheels = [{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", upload-time = 2025-04-24T03:35:24Z, size = 37515, hashes = { sha256 = "63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86" } }] [[packages]] name = "httpcore" version = "1.0.9" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", upload-time = 2025-04-24T22:06:22Z, size = 85484, hashes = { sha256 = "6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8" } } wheels = [{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", upload-time = 2025-04-24T22:06:20Z, size = 78784, hashes = { sha256 = "2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55" } }] [[packages]] name = "httpx" version = "0.28.1" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", upload-time = 2024-12-06T15:37:23Z, size = 141406, hashes = { sha256 = "75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc" } } wheels = [{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", upload-time = 2024-12-06T15:37:21Z, size = 73517, hashes = { sha256 = "d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad" } }] [[packages]] name = "idna" version = "3.10" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", upload-time = 2024-09-15T18:07:39Z, size = 190490, hashes = { sha256 = "12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9" } } wheels = [{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", upload-time = 2024-09-15T18:07:37Z, size = 70442, hashes = { sha256 = "946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3" } }] [[packages]] name = "jinja2" version = "3.1.6" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", upload-time = 2025-03-05T20:05:02Z, size = 245115, hashes = { sha256 = "0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d" } } wheels = [{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", upload-time = 2025-03-05T20:05:00Z, size = 134899, hashes = { sha256 = "85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" } }] [[packages]] name = "markdown-it-py" version = "3.0.0" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", upload-time = 2023-06-03T06:41:14Z, size = 74596, hashes = { sha256 = "e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb" } } wheels = [{ url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", upload-time = 2023-06-03T06:41:11Z, size = 87528, hashes = { sha256 = "355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1" } }] [[packages]] name = "markupsafe" version = "3.0.2" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", upload-time = 2024-10-18T15:21:54Z, size = 20537, hashes = { sha256 = "ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0" } } wheels = [ { name = "markupsafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", upload-time = 2024-10-18T15:20:51Z, size = 14357, hashes = { sha256 = "7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8" } }, { name = "markupsafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", url = "https://files.pythonhosted.org/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", upload-time = 2024-10-18T15:20:52Z, size = 12393, hashes = { sha256 = "9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158" } }, { name = "markupsafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", url = "https://files.pythonhosted.org/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", upload-time = 2024-10-18T15:20:53Z, size = 21732, hashes = { sha256 = "38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579" } }, { name = "markupsafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", url = "https://files.pythonhosted.org/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", upload-time = 2024-10-18T15:20:55Z, size = 20866, hashes = { sha256 = "bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d" } }, { name = "markupsafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", url = "https://files.pythonhosted.org/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", upload-time = 2024-10-18T15:20:55Z, size = 20964, hashes = { sha256 = "57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb" } }, { name = "markupsafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", url = "https://files.pythonhosted.org/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", upload-time = 2024-10-18T15:20:57Z, size = 21977, hashes = { sha256 = "3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b" } }, { name = "markupsafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", url = "https://files.pythonhosted.org/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", upload-time = 2024-10-18T15:20:58Z, size = 21366, hashes = { sha256 = "e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c" } }, { name = "markupsafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", url = "https://files.pythonhosted.org/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", upload-time = 2024-10-18T15:20:59Z, size = 21091, hashes = { sha256 = "b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171" } }, { name = "markupsafe-3.0.2-cp310-cp310-win32.whl", url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", upload-time = 2024-10-18T15:21:00Z, size = 15065, hashes = { sha256 = "fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50" } }, { name = "markupsafe-3.0.2-cp310-cp310-win_amd64.whl", url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", upload-time = 2024-10-18T15:21:01Z, size = 15514, hashes = { sha256 = "6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a" } }, { name = "markupsafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", upload-time = 2024-10-18T15:21:02Z, size = 14353, hashes = { sha256 = "9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d" } }, { name = "markupsafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", upload-time = 2024-10-18T15:21:02Z, size = 12392, hashes = { sha256 = "93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93" } }, { name = "markupsafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", upload-time = 2024-10-18T15:21:03Z, size = 23984, hashes = { sha256 = "2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832" } }, { name = "markupsafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", upload-time = 2024-10-18T15:21:06Z, size = 23120, hashes = { sha256 = "a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84" } }, { name = "markupsafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", upload-time = 2024-10-18T15:21:07Z, size = 23032, hashes = { sha256 = "1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca" } }, { name = "markupsafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", upload-time = 2024-10-18T15:21:08Z, size = 24057, hashes = { sha256 = "d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798" } }, { name = "markupsafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", upload-time = 2024-10-18T15:21:09Z, size = 23359, hashes = { sha256 = "5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e" } }, { name = "markupsafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", upload-time = 2024-10-18T15:21:10Z, size = 23306, hashes = { sha256 = "0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4" } }, { name = "markupsafe-3.0.2-cp311-cp311-win32.whl", url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", upload-time = 2024-10-18T15:21:11Z, size = 15094, hashes = { sha256 = "6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d" } }, { name = "markupsafe-3.0.2-cp311-cp311-win_amd64.whl", url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", upload-time = 2024-10-18T15:21:12Z, size = 15521, hashes = { sha256 = "70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b" } }, { name = "markupsafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", upload-time = 2024-10-18T15:21:13Z, size = 14274, hashes = { sha256 = "9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf" } }, { name = "markupsafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", upload-time = 2024-10-18T15:21:14Z, size = 12348, hashes = { sha256 = "846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225" } }, { name = "markupsafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", upload-time = 2024-10-18T15:21:15Z, size = 24149, hashes = { sha256 = "1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028" } }, { name = "markupsafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", upload-time = 2024-10-18T15:21:17Z, size = 23118, hashes = { sha256 = "e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8" } }, { name = "markupsafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", upload-time = 2024-10-18T15:21:18Z, size = 22993, hashes = { sha256 = "88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c" } }, { name = "markupsafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", upload-time = 2024-10-18T15:21:18Z, size = 24178, hashes = { sha256 = "2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557" } }, { name = "markupsafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", upload-time = 2024-10-18T15:21:19Z, size = 23319, hashes = { sha256 = "52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22" } }, { name = "markupsafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", upload-time = 2024-10-18T15:21:20Z, size = 23352, hashes = { sha256 = "ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48" } }, { name = "markupsafe-3.0.2-cp312-cp312-win32.whl", url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", upload-time = 2024-10-18T15:21:22Z, size = 15097, hashes = { sha256 = "0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30" } }, { name = "markupsafe-3.0.2-cp312-cp312-win_amd64.whl", url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", upload-time = 2024-10-18T15:21:23Z, size = 15601, hashes = { sha256 = "8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87" } }, { name = "markupsafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", upload-time = 2024-10-18T15:21:24Z, size = 14274, hashes = { sha256 = "ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd" } }, { name = "markupsafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", upload-time = 2024-10-18T15:21:25Z, size = 12352, hashes = { sha256 = "f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430" } }, { name = "markupsafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", upload-time = 2024-10-18T15:21:26Z, size = 24122, hashes = { sha256 = "569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094" } }, { name = "markupsafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", upload-time = 2024-10-18T15:21:27Z, size = 23085, hashes = { sha256 = "15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396" } }, { name = "markupsafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", upload-time = 2024-10-18T15:21:27Z, size = 22978, hashes = { sha256 = "f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79" } }, { name = "markupsafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", upload-time = 2024-10-18T15:21:28Z, size = 24208, hashes = { sha256 = "cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a" } }, { name = "markupsafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", upload-time = 2024-10-18T15:21:29Z, size = 23357, hashes = { sha256 = "cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca" } }, { name = "markupsafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", upload-time = 2024-10-18T15:21:30Z, size = 23344, hashes = { sha256 = "444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c" } }, { name = "markupsafe-3.0.2-cp313-cp313-win32.whl", url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", upload-time = 2024-10-18T15:21:31Z, size = 15101, hashes = { sha256 = "bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1" } }, { name = "markupsafe-3.0.2-cp313-cp313-win_amd64.whl", url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", upload-time = 2024-10-18T15:21:32Z, size = 15603, hashes = { sha256 = "e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f" } }, { name = "markupsafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", upload-time = 2024-10-18T15:21:33Z, size = 14510, hashes = { sha256 = "b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c" } }, { name = "markupsafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", upload-time = 2024-10-18T15:21:34Z, size = 12486, hashes = { sha256 = "a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb" } }, { name = "markupsafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", upload-time = 2024-10-18T15:21:35Z, size = 25480, hashes = { sha256 = "4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c" } }, { name = "markupsafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", upload-time = 2024-10-18T15:21:36Z, size = 23914, hashes = { sha256 = "c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d" } }, { name = "markupsafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", upload-time = 2024-10-18T15:21:37Z, size = 23796, hashes = { sha256 = "d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe" } }, { name = "markupsafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", upload-time = 2024-10-18T15:21:37Z, size = 25473, hashes = { sha256 = "6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5" } }, { name = "markupsafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", upload-time = 2024-10-18T15:21:39Z, size = 24114, hashes = { sha256 = "3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a" } }, { name = "markupsafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", upload-time = 2024-10-18T15:21:40Z, size = 24098, hashes = { sha256 = "131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9" } }, { name = "markupsafe-3.0.2-cp313-cp313t-win32.whl", url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", upload-time = 2024-10-18T15:21:41Z, size = 15208, hashes = { sha256 = "ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6" } }, { name = "markupsafe-3.0.2-cp313-cp313t-win_amd64.whl", url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", upload-time = 2024-10-18T15:21:42Z, size = 15739, hashes = { sha256 = "e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f" } }, ] [[packages]] name = "mdurl" version = "0.1.2" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", upload-time = 2022-08-14T12:40:10Z, size = 8729, hashes = { sha256 = "bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" } } wheels = [{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", upload-time = 2022-08-14T12:40:09Z, size = 9979, hashes = { sha256 = "84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" } }] [[packages]] name = "mixpanel" version = "4.10.1" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/bd/a3/9d71562db2107da31be6a988cac88cd1be11364d103b618a98ba92d2487b/mixpanel-4.10.1.tar.gz", upload-time = 2024-03-08T22:51:33Z, size = 9831, hashes = { sha256 = "29a6b5773dd34f05cf8e249f4e1d16e7b6280d6b58894551ce9a5aad7700a115" } } wheels = [{ url = "https://files.pythonhosted.org/packages/72/5a/8048544f73e22ebd27bdeca64ce51578578873e5da9ba3d3f99d692f9034/mixpanel-4.10.1-py2.py3-none-any.whl", upload-time = 2024-03-08T22:51:32Z, size = 8954, hashes = { sha256 = "a7a338b7197327e36356dbc1903086e7626db6d88367ccdd732b3f3c60d3b3ed" } }] [[packages]] name = "packaging" version = "25.0" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", upload-time = 2025-04-19T11:48:59Z, size = 165727, hashes = { sha256 = "d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f" } } wheels = [{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", upload-time = 2025-04-19T11:48:57Z, size = 66469, hashes = { sha256 = "29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484" } }] [[packages]] name = "pathspec" version = "0.12.1" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", upload-time = 2023-12-10T22:30:45Z, size = 51043, hashes = { sha256 = "a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712" } } wheels = [{ url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", upload-time = 2023-12-10T22:30:43Z, size = 31191, hashes = { sha256 = "a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08" } }] [[packages]] name = "prompt-toolkit" version = "3.0.51" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/bb/6e/9d084c929dfe9e3bfe0c6a47e31f78a25c54627d64a66e884a8bf5474f1c/prompt_toolkit-3.0.51.tar.gz", upload-time = 2025-04-15T09:18:47Z, size = 428940, hashes = { sha256 = "931a162e3b27fc90c86f1b48bb1fb2c528c2761475e57c9c06de13311c7b54ed" } } wheels = [{ url = "https://files.pythonhosted.org/packages/ce/4f/5249960887b1fbe561d9ff265496d170b55a735b76724f10ef19f9e40716/prompt_toolkit-3.0.51-py3-none-any.whl", upload-time = 2025-04-15T09:18:44Z, size = 387810, hashes = { sha256 = "52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07" } }] [[packages]] name = "psutil" version = "7.0.0" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/2a/80/336820c1ad9286a4ded7e845b2eccfcb27851ab8ac6abece774a6ff4d3de/psutil-7.0.0.tar.gz", upload-time = 2025-02-13T21:54:07Z, size = 497003, hashes = { sha256 = "7be9c3eba38beccb6495ea33afd982a44074b78f28c434a1f51cc07fd315c456" } } wheels = [ { url = "https://files.pythonhosted.org/packages/ed/e6/2d26234410f8b8abdbf891c9da62bee396583f713fb9f3325a4760875d22/psutil-7.0.0-cp36-abi3-macosx_10_9_x86_64.whl", upload-time = 2025-02-13T21:54:12Z, size = 238051, hashes = { sha256 = "101d71dc322e3cffd7cea0650b09b3d08b8e7c4109dd6809fe452dfd00e58b25" } }, { url = "https://files.pythonhosted.org/packages/04/8b/30f930733afe425e3cbfc0e1468a30a18942350c1a8816acfade80c005c4/psutil-7.0.0-cp36-abi3-macosx_11_0_arm64.whl", upload-time = 2025-02-13T21:54:16Z, size = 239535, hashes = { sha256 = "39db632f6bb862eeccf56660871433e111b6ea58f2caea825571951d4b6aa3da" } }, { url = "https://files.pythonhosted.org/packages/2a/ed/d362e84620dd22876b55389248e522338ed1bf134a5edd3b8231d7207f6d/psutil-7.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", upload-time = 2025-02-13T21:54:18Z, size = 275004, hashes = { sha256 = "1fcee592b4c6f146991ca55919ea3d1f8926497a713ed7faaf8225e174581e91" } }, { url = "https://files.pythonhosted.org/packages/bf/b9/b0eb3f3cbcb734d930fdf839431606844a825b23eaf9a6ab371edac8162c/psutil-7.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", upload-time = 2025-02-13T21:54:21Z, size = 277986, hashes = { sha256 = "4b1388a4f6875d7e2aff5c4ca1cc16c545ed41dd8bb596cefea80111db353a34" } }, { url = "https://files.pythonhosted.org/packages/eb/a2/709e0fe2f093556c17fbafda93ac032257242cabcc7ff3369e2cb76a97aa/psutil-7.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", upload-time = 2025-02-13T21:54:24Z, size = 279544, hashes = { sha256 = "a5f098451abc2828f7dc6b58d44b532b22f2088f4999a937557b603ce72b1993" } }, { url = "https://files.pythonhosted.org/packages/50/e6/eecf58810b9d12e6427369784efe814a1eec0f492084ce8eb8f4d89d6d61/psutil-7.0.0-cp37-abi3-win32.whl", upload-time = 2025-02-13T21:54:34Z, size = 241053, hashes = { sha256 = "ba3fcef7523064a6c9da440fc4d6bd07da93ac726b5733c29027d7dc95b39d99" } }, { url = "https://files.pythonhosted.org/packages/50/1b/6921afe68c74868b4c9fa424dad3be35b095e16687989ebbb50ce4fceb7c/psutil-7.0.0-cp37-abi3-win_amd64.whl", upload-time = 2025-02-13T21:54:37Z, size = 244885, hashes = { sha256 = "4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553" } }, ] [[packages]] name = "pygments" version = "2.19.2" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", upload-time = 2025-06-21T13:39:12Z, size = 4968631, hashes = { sha256 = "636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887" } } wheels = [{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", upload-time = 2025-06-21T13:39:07Z, size = 1225217, hashes = { sha256 = "86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b" } }] [[packages]] name = "python-dateutil" version = "2.9.0.post0" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", upload-time = 2024-03-01T18:36:20Z, size = 342432, hashes = { sha256 = "37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3" } } wheels = [{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", upload-time = 2024-03-01T18:36:18Z, size = 229892, hashes = { sha256 = "a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" } }] [[packages]] name = "python-slugify" version = "8.0.4" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", upload-time = 2024-02-08T18:32:45Z, size = 10921, hashes = { sha256 = "59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856" } } wheels = [{ url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", upload-time = 2024-02-08T18:32:43Z, size = 10051, hashes = { sha256 = "276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8" } }] [[packages]] name = "pyyaml" version = "6.0.2" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", upload-time = 2024-08-06T20:33:50Z, size = 130631, hashes = { sha256 = "d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e" } } wheels = [ { name = "pyyaml-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", upload-time = 2024-08-06T20:31:40Z, size = 184199, hashes = { sha256 = "0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086" } }, { name = "pyyaml-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", upload-time = 2024-08-06T20:31:42Z, size = 171758, hashes = { sha256 = "29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf" } }, { name = "pyyaml-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", upload-time = 2024-08-06T20:31:44Z, size = 718463, hashes = { sha256 = "8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237" } }, { name = "pyyaml-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", upload-time = 2024-08-06T20:31:50Z, size = 719280, hashes = { sha256 = "7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b" } }, { name = "pyyaml-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", upload-time = 2024-08-06T20:31:52Z, size = 751239, hashes = { sha256 = "ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed" } }, { name = "pyyaml-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", upload-time = 2024-08-06T20:31:53Z, size = 695802, hashes = { sha256 = "936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180" } }, { name = "pyyaml-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", upload-time = 2024-08-06T20:31:55Z, size = 720527, hashes = { sha256 = "23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68" } }, { name = "pyyaml-6.0.2-cp310-cp310-win32.whl", url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", upload-time = 2024-08-06T20:31:56Z, size = 144052, hashes = { sha256 = "2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99" } }, { name = "pyyaml-6.0.2-cp310-cp310-win_amd64.whl", url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", upload-time = 2024-08-06T20:31:58Z, size = 161774, hashes = { sha256 = "a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e" } }, { name = "pyyaml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", upload-time = 2024-08-06T20:32:03Z, size = 184612, hashes = { sha256 = "cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774" } }, { name = "pyyaml-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", upload-time = 2024-08-06T20:32:04Z, size = 172040, hashes = { sha256 = "1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee" } }, { name = "pyyaml-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", upload-time = 2024-08-06T20:32:06Z, size = 736829, hashes = { sha256 = "5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c" } }, { name = "pyyaml-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", upload-time = 2024-08-06T20:32:08Z, size = 764167, hashes = { sha256 = "5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317" } }, { name = "pyyaml-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", upload-time = 2024-08-06T20:32:14Z, size = 762952, hashes = { sha256 = "3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85" } }, { name = "pyyaml-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", upload-time = 2024-08-06T20:32:16Z, size = 735301, hashes = { sha256 = "ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4" } }, { name = "pyyaml-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", upload-time = 2024-08-06T20:32:18Z, size = 756638, hashes = { sha256 = "797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e" } }, { name = "pyyaml-6.0.2-cp311-cp311-win32.whl", url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", upload-time = 2024-08-06T20:32:19Z, size = 143850, hashes = { sha256 = "11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5" } }, { name = "pyyaml-6.0.2-cp311-cp311-win_amd64.whl", url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", upload-time = 2024-08-06T20:32:21Z, size = 161980, hashes = { sha256 = "e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44" } }, { name = "pyyaml-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", upload-time = 2024-08-06T20:32:25Z, size = 183873, hashes = { sha256 = "c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab" } }, { name = "pyyaml-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", upload-time = 2024-08-06T20:32:26Z, size = 173302, hashes = { sha256 = "ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725" } }, { name = "pyyaml-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", upload-time = 2024-08-06T20:32:28Z, size = 739154, hashes = { sha256 = "1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5" } }, { name = "pyyaml-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", upload-time = 2024-08-06T20:32:30Z, size = 766223, hashes = { sha256 = "9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425" } }, { name = "pyyaml-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", upload-time = 2024-08-06T20:32:31Z, size = 767542, hashes = { sha256 = "80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476" } }, { name = "pyyaml-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", upload-time = 2024-08-06T20:32:37Z, size = 731164, hashes = { sha256 = "0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48" } }, { name = "pyyaml-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", upload-time = 2024-08-06T20:32:38Z, size = 756611, hashes = { sha256 = "8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b" } }, { name = "pyyaml-6.0.2-cp312-cp312-win32.whl", url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", upload-time = 2024-08-06T20:32:40Z, size = 140591, hashes = { sha256 = "ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4" } }, { name = "pyyaml-6.0.2-cp312-cp312-win_amd64.whl", url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", upload-time = 2024-08-06T20:32:41Z, size = 156338, hashes = { sha256 = "7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8" } }, { name = "pyyaml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", upload-time = 2024-08-06T20:32:43Z, size = 181309, hashes = { sha256 = "efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba" } }, { name = "pyyaml-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", upload-time = 2024-08-06T20:32:44Z, size = 171679, hashes = { sha256 = "50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1" } }, { name = "pyyaml-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", upload-time = 2024-08-06T20:32:46Z, size = 733428, hashes = { sha256 = "0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133" } }, { name = "pyyaml-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", upload-time = 2024-08-06T20:32:51Z, size = 763361, hashes = { sha256 = "17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484" } }, { name = "pyyaml-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", upload-time = 2024-08-06T20:32:53Z, size = 759523, hashes = { sha256 = "70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5" } }, { name = "pyyaml-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", upload-time = 2024-08-06T20:32:54Z, size = 726660, hashes = { sha256 = "41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc" } }, { name = "pyyaml-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", upload-time = 2024-08-06T20:32:56Z, size = 751597, hashes = { sha256 = "68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652" } }, { name = "pyyaml-6.0.2-cp313-cp313-win32.whl", url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", upload-time = 2024-08-06T20:33:03Z, size = 140527, hashes = { sha256 = "bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183" } }, { name = "pyyaml-6.0.2-cp313-cp313-win_amd64.whl", url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", upload-time = 2024-08-06T20:33:04Z, size = 156446, hashes = { sha256 = "8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563" } }, ] [[packages]] name = "questionary" version = "2.1.0" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/a8/b8/d16eb579277f3de9e56e5ad25280fab52fc5774117fb70362e8c2e016559/questionary-2.1.0.tar.gz", upload-time = 2024-12-29T11:49:17Z, size = 26775, hashes = { sha256 = "6302cdd645b19667d8f6e6634774e9538bfcd1aad9be287e743d96cacaf95587" } } wheels = [{ url = "https://files.pythonhosted.org/packages/ad/3f/11dd4cd4f39e05128bfd20138faea57bec56f9ffba6185d276e3107ba5b2/questionary-2.1.0-py3-none-any.whl", upload-time = 2024-12-29T11:49:16Z, size = 36747, hashes = { sha256 = "44174d237b68bc828e4878c763a9ad6790ee61990e0ae72927694ead57bab8ec" } }] [[packages]] name = "requests" version = "2.32.4" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", upload-time = 2025-06-09T16:43:07Z, size = 135258, hashes = { sha256 = "27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422" } } wheels = [{ url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", upload-time = 2025-06-09T16:43:05Z, size = 64847, hashes = { sha256 = "27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c" } }] [[packages]] name = "rich" version = "14.1.0" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", upload-time = 2025-07-25T07:32:58Z, size = 224441, hashes = { sha256 = "e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8" } } wheels = [{ url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", upload-time = 2025-07-25T07:32:56Z, size = 243368, hashes = { sha256 = "536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f" } }] [[packages]] name = "ruff" version = "0.12.7" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/a1/81/0bd3594fa0f690466e41bd033bdcdf86cba8288345ac77ad4afbe5ec743a/ruff-0.12.7.tar.gz", upload-time = 2025-07-29T22:32:35Z, size = 5197814, hashes = { sha256 = "1fc3193f238bc2d7968772c82831a4ff69252f673be371fb49663f0068b7ec71" } } wheels = [ { url = "https://files.pythonhosted.org/packages/e1/d2/6cb35e9c85e7a91e8d22ab32ae07ac39cc34a71f1009a6f9e4a2a019e602/ruff-0.12.7-py3-none-linux_armv6l.whl", upload-time = 2025-07-29T22:31:41Z, size = 11852189, hashes = { sha256 = "76e4f31529899b8c434c3c1dede98c4483b89590e15fb49f2d46183801565303" } }, { url = "https://files.pythonhosted.org/packages/63/5b/a4136b9921aa84638f1a6be7fb086f8cad0fde538ba76bda3682f2599a2f/ruff-0.12.7-py3-none-macosx_10_12_x86_64.whl", upload-time = 2025-07-29T22:31:54Z, size = 12519389, hashes = { sha256 = "789b7a03e72507c54fb3ba6209e4bb36517b90f1a3569ea17084e3fd295500fb" } }, { url = "https://files.pythonhosted.org/packages/a8/c9/3e24a8472484269b6b1821794141f879c54645a111ded4b6f58f9ab0705f/ruff-0.12.7-py3-none-macosx_11_0_arm64.whl", upload-time = 2025-07-29T22:31:59Z, size = 11743384, hashes = { sha256 = "2e1c2a3b8626339bb6369116e7030a4cf194ea48f49b64bb505732a7fce4f4e3" } }, { url = "https://files.pythonhosted.org/packages/26/7c/458dd25deeb3452c43eaee853c0b17a1e84169f8021a26d500ead77964fd/ruff-0.12.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", upload-time = 2025-07-29T22:32:01Z, size = 11943759, hashes = { sha256 = "32dec41817623d388e645612ec70d5757a6d9c035f3744a52c7b195a57e03860" } }, { url = "https://files.pythonhosted.org/packages/7f/8b/658798472ef260ca050e400ab96ef7e85c366c39cf3dfbef4d0a46a528b6/ruff-0.12.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", upload-time = 2025-07-29T22:32:04Z, size = 11654028, hashes = { sha256 = "47ef751f722053a5df5fa48d412dbb54d41ab9b17875c6840a58ec63ff0c247c" } }, { url = "https://files.pythonhosted.org/packages/a8/86/9c2336f13b2a3326d06d39178fd3448dcc7025f82514d1b15816fe42bfe8/ruff-0.12.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", upload-time = 2025-07-29T22:32:06Z, size = 13225209, hashes = { sha256 = "a828a5fc25a3efd3e1ff7b241fd392686c9386f20e5ac90aa9234a5faa12c423" } }, { url = "https://files.pythonhosted.org/packages/76/69/df73f65f53d6c463b19b6b312fd2391dc36425d926ec237a7ed028a90fc1/ruff-0.12.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", upload-time = 2025-07-29T22:32:10Z, size = 14182353, hashes = { sha256 = "5726f59b171111fa6a69d82aef48f00b56598b03a22f0f4170664ff4d8298efb" } }, { url = "https://files.pythonhosted.org/packages/58/1e/de6cda406d99fea84b66811c189b5ea139814b98125b052424b55d28a41c/ruff-0.12.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", upload-time = 2025-07-29T22:32:12Z, size = 13631555, hashes = { sha256 = "74e6f5c04c4dd4aba223f4fe6e7104f79e0eebf7d307e4f9b18c18362124bccd" } }, { url = "https://files.pythonhosted.org/packages/6f/ae/625d46d5164a6cc9261945a5e89df24457dc8262539ace3ac36c40f0b51e/ruff-0.12.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", upload-time = 2025-07-29T22:32:15Z, size = 12667556, hashes = { sha256 = "5d0bfe4e77fba61bf2ccadf8cf005d6133e3ce08793bbe870dd1c734f2699a3e" } }, { url = "https://files.pythonhosted.org/packages/55/bf/9cb1ea5e3066779e42ade8d0cd3d3b0582a5720a814ae1586f85014656b6/ruff-0.12.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", upload-time = 2025-07-29T22:32:17Z, size = 12939784, hashes = { sha256 = "06bfb01e1623bf7f59ea749a841da56f8f653d641bfd046edee32ede7ff6c606" } }, { url = "https://files.pythonhosted.org/packages/55/7f/7ead2663be5627c04be83754c4f3096603bf5e99ed856c7cd29618c691bd/ruff-0.12.7-py3-none-musllinux_1_2_aarch64.whl", upload-time = 2025-07-29T22:32:20Z, size = 11771356, hashes = { sha256 = "e41df94a957d50083fd09b916d6e89e497246698c3f3d5c681c8b3e7b9bb4ac8" } }, { url = "https://files.pythonhosted.org/packages/17/40/a95352ea16edf78cd3a938085dccc55df692a4d8ba1b3af7accbe2c806b0/ruff-0.12.7-py3-none-musllinux_1_2_armv7l.whl", upload-time = 2025-07-29T22:32:22Z, size = 11612124, hashes = { sha256 = "4000623300563c709458d0ce170c3d0d788c23a058912f28bbadc6f905d67afa" } }, { url = "https://files.pythonhosted.org/packages/4d/74/633b04871c669e23b8917877e812376827c06df866e1677f15abfadc95cb/ruff-0.12.7-py3-none-musllinux_1_2_i686.whl", upload-time = 2025-07-29T22:32:24Z, size = 12479945, hashes = { sha256 = "69ffe0e5f9b2cf2b8e289a3f8945b402a1b19eff24ec389f45f23c42a3dd6fb5" } }, { url = "https://files.pythonhosted.org/packages/be/34/c3ef2d7799c9778b835a76189c6f53c179d3bdebc8c65288c29032e03613/ruff-0.12.7-py3-none-musllinux_1_2_x86_64.whl", upload-time = 2025-07-29T22:32:27Z, size = 12998677, hashes = { sha256 = "a07a5c8ffa2611a52732bdc67bf88e243abd84fe2d7f6daef3826b59abbfeda4" } }, { url = "https://files.pythonhosted.org/packages/77/ab/aca2e756ad7b09b3d662a41773f3edcbd262872a4fc81f920dc1ffa44541/ruff-0.12.7-py3-none-win32.whl", upload-time = 2025-07-29T22:32:29Z, size = 11756687, hashes = { sha256 = "c928f1b2ec59fb77dfdf70e0419408898b63998789cc98197e15f560b9e77f77" } }, { url = "https://files.pythonhosted.org/packages/b4/71/26d45a5042bc71db22ddd8252ca9d01e9ca454f230e2996bb04f16d72799/ruff-0.12.7-py3-none-win_amd64.whl", upload-time = 2025-07-29T22:32:31Z, size = 12912365, hashes = { sha256 = "9c18f3d707ee9edf89da76131956aba1270c6348bfee8f6c647de841eac7194f" } }, { url = "https://files.pythonhosted.org/packages/4c/9b/0b8aa09817b63e78d94b4977f18b1fcaead3165a5ee49251c5d5c245bb2d/ruff-0.12.7-py3-none-win_arm64.whl", upload-time = 2025-07-29T22:32:33Z, size = 11982083, hashes = { sha256 = "dfce05101dbd11833a0776716d5d1578641b7fddb537fe7fa956ab85d1769b69" } }, ] [[packages]] name = "semver" version = "3.0.4" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/72/d1/d3159231aec234a59dd7d601e9dd9fe96f3afff15efd33c1070019b26132/semver-3.0.4.tar.gz", upload-time = 2025-01-24T13:19:27Z, size = 269730, hashes = { sha256 = "afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602" } } wheels = [{ url = "https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl", upload-time = 2025-01-24T13:19:24Z, size = 17912, hashes = { sha256 = "9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746" } }] [[packages]] name = "shellingham" version = "1.5.4" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", upload-time = 2023-10-24T04:13:40Z, size = 10310, hashes = { sha256 = "8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de" } } wheels = [{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", upload-time = 2023-10-24T04:13:38Z, size = 9755, hashes = { sha256 = "7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686" } }] [[packages]] name = "six" version = "1.17.0" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", upload-time = 2024-12-04T17:35:28Z, size = 34031, hashes = { sha256 = "ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" } } wheels = [{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", upload-time = 2024-12-04T17:35:26Z, size = 11050, hashes = { sha256 = "4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274" } }] [[packages]] name = "smmap" version = "5.0.2" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", upload-time = 2025-01-02T07:14:40Z, size = 22329, hashes = { sha256 = "26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5" } } wheels = [{ url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", upload-time = 2025-01-02T07:14:38Z, size = 24303, hashes = { sha256 = "b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e" } }] [[packages]] name = "sniffio" version = "1.3.1" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", upload-time = 2024-02-25T23:20:04Z, size = 20372, hashes = { sha256 = "f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc" } } wheels = [{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", upload-time = 2024-02-25T23:20:01Z, size = 10235, hashes = { sha256 = "2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2" } }] [[packages]] name = "text-unidecode" version = "1.3" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", upload-time = 2019-08-30T21:36:45Z, size = 76885, hashes = { sha256 = "bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93" } } wheels = [{ url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", upload-time = 2019-08-30T21:37:03Z, size = 78154, hashes = { sha256 = "1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8" } }] [[packages]] name = "tomlkit" version = "0.13.3" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", upload-time = 2025-06-05T07:13:44Z, size = 185207, hashes = { sha256 = "430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1" } } wheels = [{ url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", upload-time = 2025-06-05T07:13:43Z, size = 38901, hashes = { sha256 = "c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0" } }] [[packages]] name = "typer" version = "0.21.1" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/36/bf/8825b5929afd84d0dabd606c67cd57b8388cb3ec385f7ef19c5cc2202069/typer-0.21.1.tar.gz", upload-time = 2026-01-06T11:21:10Z, size = 110371, hashes = { sha256 = "ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d" } } wheels = [{ url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", upload-time = 2026-01-06T11:21:09Z, size = 47381, hashes = { sha256 = "7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01" } }] [[packages]] name = "types-python-dateutil" version = "2.9.0.20250708" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/c9/95/6bdde7607da2e1e99ec1c1672a759d42f26644bbacf939916e086db34870/types_python_dateutil-2.9.0.20250708.tar.gz", upload-time = 2025-07-08T03:14:03Z, size = 15834, hashes = { sha256 = "ccdbd75dab2d6c9696c350579f34cffe2c281e4c5f27a585b2a2438dd1d5c8ab" } } wheels = [{ url = "https://files.pythonhosted.org/packages/72/52/43e70a8e57fefb172c22a21000b03ebcc15e47e97f5cb8495b9c2832efb4/types_python_dateutil-2.9.0.20250708-py3-none-any.whl", upload-time = 2025-07-08T03:14:02Z, size = 17724, hashes = { sha256 = "4d6d0cc1cc4d24a2dc3816024e502564094497b713f7befda4d5bc7a8e3fd21f" } }] [[packages]] name = "typing-extensions" version = "4.14.1" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", upload-time = 2025-07-04T13:28:34Z, size = 107673, hashes = { sha256 = "38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36" } } wheels = [{ url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", upload-time = 2025-07-04T13:28:32Z, size = 43906, hashes = { sha256 = "d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76" } }] [[packages]] name = "urllib3" version = "2.5.0" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", upload-time = 2025-06-18T14:07:41Z, size = 393185, hashes = { sha256 = "3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760" } } wheels = [{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", upload-time = 2025-06-18T14:07:40Z, size = 129795, hashes = { sha256 = "e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc" } }] [[packages]] name = "uv" version = "0.8.5" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/83/94/e18a40fe6f6d724c1fbf2c9328806359e341710b2fd42dc928a1a8fc636b/uv-0.8.5.tar.gz", upload-time = 2025-08-05T20:50:21Z, size = 3451272, hashes = { sha256 = "078cf2935062d5b61816505f9d6f30b0221943a1433b4a1de8f31a1dfe55736b" } } wheels = [ { url = "https://files.pythonhosted.org/packages/d9/b9/78cde56283b6b9a8a84b0bf9334442ed75a843310229aaf7f1a71fe67818/uv-0.8.5-py3-none-linux_armv6l.whl", upload-time = 2025-08-05T20:49:18Z, size = 18146198, hashes = { sha256 = "e236372a260e312aef5485a0e5819a0ec16c9197af06d162ad5a3e8bd62f9bba" } }, { url = "https://files.pythonhosted.org/packages/ed/83/5deda1a19362ce426da7f9cc4764a0dd57e665ecbaddd9900d4200bc10ab/uv-0.8.5-py3-none-macosx_10_12_x86_64.whl", upload-time = 2025-08-05T20:49:23Z, size = 18242690, hashes = { sha256 = "53a40628329e543a5c5414553f5898131d5c1c6f963708cb0afc2ecf3e8d8167" } }, { url = "https://files.pythonhosted.org/packages/06/6e/80b08ee544728317d9c8003d4c10234007e12f384da1c3dfe579489833c9/uv-0.8.5-py3-none-macosx_11_0_arm64.whl", upload-time = 2025-08-05T20:49:26Z, size = 16913881, hashes = { sha256 = "43a689027696bc9c62e6da3f06900c52eafc4debbf4fba9ecb906196730b34c8" } }, { url = "https://files.pythonhosted.org/packages/34/f6/47a44dabfc25b598ea6f2ab9aa32ebf1cbd87ed8af18ccde6c5d36f35476/uv-0.8.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", upload-time = 2025-08-05T20:49:30Z, size = 17527439, hashes = { sha256 = "a34d783f5cef00f1918357c0cd9226666e22640794e9e3862820abf4ee791141" } }, { url = "https://files.pythonhosted.org/packages/ef/7d/ee7c2514e064412133ee9f01c4c42de20da24617b8c25d81cf7021b774d8/uv-0.8.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", upload-time = 2025-08-05T20:49:33Z, size = 17833275, hashes = { sha256 = "2140383bc25228281090cc34c00500d8e5822877c955f691d69bbf967e8efa73" } }, { url = "https://files.pythonhosted.org/packages/f9/e7/5233cf5cbcca8ea65aa1f1e48bf210dc9773fb86b8104ffbc523be7f6a3f/uv-0.8.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", upload-time = 2025-08-05T20:49:37Z, size = 18568916, hashes = { sha256 = "6b449779ff463b059504dc30316a634f810149e02482ce36ea35daea8f6ce7af" } }, { url = "https://files.pythonhosted.org/packages/d8/54/6cabb2a0347c51c8366ca3bffeeebd7f829a15f6b29ad20f51fd5ca9c4bd/uv-0.8.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", upload-time = 2025-08-05T20:49:40Z, size = 19993334, hashes = { sha256 = "a7f8739d05cc513eee2f1f8a7e6c482a9c1e8860d77cd078d1ea7c3fe36d7a65" } }, { url = "https://files.pythonhosted.org/packages/3c/7a/b84d994d52f20bc56229840c31e77aff4653e5902ea7b7c2616e9381b5b8/uv-0.8.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", upload-time = 2025-08-05T20:49:43Z, size = 19643358, hashes = { sha256 = "62ebbd22f780ba2585690332765caf9e29c9758e48a678148e8b1ea90580cdb9" } }, { url = "https://files.pythonhosted.org/packages/c8/f1/7552f2bea528456d34bc245f2959ce910631e01571c4b7ea421ead9a9fc6/uv-0.8.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", upload-time = 2025-08-05T20:49:47Z, size = 18947757, hashes = { sha256 = "4f8dd0555f05d66ff46fdab551137cc2b1ea9c5363358913e2af175e367f4398" } }, { url = "https://files.pythonhosted.org/packages/57/9b/46aadd186a1e16a23cd0701dda0e640197db49a3add074a47231fed45a4f/uv-0.8.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", upload-time = 2025-08-05T20:49:50Z, size = 18906135, hashes = { sha256 = "38c04408ad5eae7a178a1e3b0e09afeb436d0c97075530a3c82de453b78d0448" } }, { url = "https://files.pythonhosted.org/packages/c0/31/6661adedaba9ebac8bb449ec9901f8cbf124fa25e0db3a9e6cf3053cee88/uv-0.8.5-py3-none-manylinux_2_28_aarch64.whl", upload-time = 2025-08-05T20:49:54Z, size = 17787943, hashes = { sha256 = "73e772caf7310af4b21eaf8c25531b934391f1e84f3afa8e67822d7c432f6dad" } }, { url = "https://files.pythonhosted.org/packages/11/f2/73fb5c3156fdae830b83edec2f430db84cb4bc4b78f61d21694bd59004cb/uv-0.8.5-py3-none-manylinux_2_31_riscv64.whl", upload-time = 2025-08-05T20:49:57Z, size = 18675864, hashes = { sha256 = "3ddd7d8c01073f23ba2a4929ab246adb30d4f8a55c5e007ad7c8341f7bf06978" } }, { url = "https://files.pythonhosted.org/packages/b5/29/774c6f174c53d68ae9a51c2fabf1b09003b93a53c24591a108be0dc338d7/uv-0.8.5-py3-none-musllinux_1_1_armv7l.whl", upload-time = 2025-08-05T20:50:01Z, size = 17808770, hashes = { sha256 = "7d601f021cbc179320ea3a75cd1d91bd49af03d2a630c4d04ebd38ff6b87d419" } }, { url = "https://files.pythonhosted.org/packages/a9/b0/5d164ce84691f5018c5832e9e3371c0196631b1f1025474a179de1d6a70a/uv-0.8.5-py3-none-musllinux_1_1_i686.whl", upload-time = 2025-08-05T20:50:04Z, size = 18076516, hashes = { sha256 = "6ee97b7299990026619c20e30e253972c6c0fb6fba4f5658144e62aa1c07785a" } }, { url = "https://files.pythonhosted.org/packages/d1/73/4d8baefb4f4b07df6a4db7bbd604cb361d4f5215b94d3f66553ea26edfd4/uv-0.8.5-py3-none-musllinux_1_1_x86_64.whl", upload-time = 2025-08-05T20:50:08Z, size = 19031195, hashes = { sha256 = "09804055d6346febf0767767c04bdd2fab7d911535639f9c18de2ea744b2954c" } }, { url = "https://files.pythonhosted.org/packages/44/2a/3d074391df2c16c79fc6bf333e4bde75662e64dac465050a03391c75b289/uv-0.8.5-py3-none-win32.whl", upload-time = 2025-08-05T20:50:11Z, size = 18026273, hashes = { sha256 = "6362a2e1fa535af0e4c0a01f83e666a4d5f9024d808f9e64e3b6ef07c97aff54" } }, { url = "https://files.pythonhosted.org/packages/3c/2f/e850d3e745ccd1125b7a48898421824700fd3e996d27d835139160650124/uv-0.8.5-py3-none-win_amd64.whl", upload-time = 2025-08-05T20:50:15Z, size = 19822158, hashes = { sha256 = "dd89836735860461c3a5563731e77c011d1831f14ada540f94bf1a7011dbea14" } }, { url = "https://files.pythonhosted.org/packages/6f/df/e5565b3faf2c6147a877ab7e96ef31e2333f08c5138a98ce77003b1bf65e/uv-0.8.5-py3-none-win_arm64.whl", upload-time = 2025-08-05T20:50:18Z, size = 18430102, hashes = { sha256 = "37c1a22915392014d8b4ade9e69e157c8e5ccdf32f37070a84f749a708268335" } }, ] [[packages]] name = "wcwidth" version = "0.2.13" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", upload-time = 2024-01-06T02:10:57Z, size = 101301, hashes = { sha256 = "72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5" } } wheels = [{ url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", upload-time = 2024-01-06T02:10:55Z, size = 34166, hashes = { sha256 = "3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859" } }] [[packages]] name = "websocket-client" version = "1.8.0" index = "https://pypi.org/simple" sdist = { url = "https://files.pythonhosted.org/packages/e6/30/fba0d96b4b5fbf5948ed3f4681f7da2f9f64512e1d303f94b4cc174c24a5/websocket_client-1.8.0.tar.gz", upload-time = 2024-04-23T22:16:16Z, size = 54648, hashes = { sha256 = "3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da" } } wheels = [{ url = "https://files.pythonhosted.org/packages/5a/84/44687a29792a70e111c5c477230a72c4b957d88d16141199bf9acb7537a3/websocket_client-1.8.0-py3-none-any.whl", upload-time = 2024-04-23T22:16:14Z, size = 58826, hashes = { sha256 = "17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526" } }] ================================================ FILE: pyproject.toml ================================================ [build-system] build-backend = "setuptools.build_meta" requires = [ "setuptools>=61" ] [project] name = "comfy-cli" version = "0.0.0" # Will be filled in by the CI/CD pipeline. Check publish_package.py. description = "A CLI tool for installing and using ComfyUI." readme = "README.md" keywords = [ "comfyui", "stable diffusion" ] license = { text = "GPL-3.0-only" } maintainers = [ { name = "Yoland Yan", email = "yoland@drip.art" }, { name = "James Kwon", email = "hongilkwon316@gmail.com" }, { name = "Robin Huang", email = "robin@drip.art" }, { name = "Dr.Lt.Data", email = "dr.lt.data@gmail.com" }, ] requires-python = ">=3.10" classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", ] dependencies = [ "charset-normalizer>=3", "cookiecutter", "gitpython", "httpx", "mixpanel", "packaging", "pathspec", "psutil", "pyyaml", "questionary", "requests", "rich", "ruff", "semver~=3.0.2", "tomlkit", "typer>=0.12.5", "typing-extensions>=4.7", "uv>=0.6.9", "websocket-client", ] optional-dependencies.dev = [ "pre-commit", "pytest", "pytest-cov", "ruff" ] urls.Repository = "https://github.com/Comfy-Org/comfy-cli.git" scripts.comfy = "comfy_cli.__main__:main" scripts.comfy-cli = "comfy_cli.__main__:main" scripts.comfycli = "comfy_cli.__main__:main" [tool.setuptools.packages.find] where = [ "." ] include = [ "comfy_cli*" ] [tool.ruff] target-version = "py310" line-length = 120 lint.select = [ "E", # pycodestyle - Error "F", # default "I", # isort-like behavior (import statement sorting) "Q", # flake8-quotes "RET504", # Unnecessary assignment to {name} before return statement "UP", # pyupgrade "W", # pycodestyle - Warning ] lint.extend-ignore = [ "E501", # Line too long ] ================================================ FILE: pyrightconfig.json ================================================ { "pythonPlatform": "All", } ================================================ FILE: tests/comfy_cli/command/generate/__init__.py ================================================ ================================================ FILE: tests/comfy_cli/command/generate/test_adapters.py ================================================ """Tests for the per-endpoint adapters: Gemini (nano-banana) and Seedance.""" from __future__ import annotations import base64 import httpx import pytest from comfy_cli.command.generate import adapters, client, output, schema, spec # ── Gemini / nano-banana ───────────────────────────────────────────────── def test_nano_banana_alias_resolves(): ep = spec.get_endpoint("nano-banana") assert ep.id == "vertexai/gemini/{model}" assert ep.polling is None assert ep.partner == "vertexai" def test_gemini_adapter_overrides_schema_flags(): """Schema-derived flags would be `contents`/`tools`/…; the adapter swaps in a friendlier `prompt`/`image`/`model` triple.""" ep = spec.get_endpoint("nano-banana") names = [f.name for f in schema.flags_for(ep)] assert names == ["prompt", "image", "model"] def test_gemini_build_body_text_only(): body = adapters._gemini_build_body({"prompt": "a fox"}, api_key="k") assert body["contents"][0]["role"] == "user" parts = body["contents"][0]["parts"] assert parts == [{"text": "a fox"}] assert body["generationConfig"]["responseModalities"] == ["IMAGE"] def test_gemini_build_body_inlines_local_image(tmp_path): img = tmp_path / "ref.png" img.write_bytes(b"\x89PNG\r\n\x1a\n-bytes-") body = adapters._gemini_build_body( {"prompt": "add hat", "image": [str(img)]}, api_key="k", ) parts = body["contents"][0]["parts"] assert parts[0] == {"text": "add hat"} inline = parts[1]["inlineData"] assert inline["mimeType"] == "image/png" assert base64.b64decode(inline["data"]) == b"\x89PNG\r\n\x1a\n-bytes-" def test_gemini_build_body_inlines_remote_url(monkeypatch): class FakeClient: def __init__(self, *a, **kw): pass def __enter__(self): return self def __exit__(self, *a): pass def get(self, url): return httpx.Response( 200, content=b"jpeg-bytes", headers={"content-type": "image/jpeg"}, request=httpx.Request("GET", url), ) monkeypatch.setattr(adapters.httpx, "Client", FakeClient) body = adapters._gemini_build_body( {"prompt": "x", "image": ["https://example.com/a.jpg"]}, api_key="k", ) inline = body["contents"][0]["parts"][1]["inlineData"] assert inline["mimeType"] == "image/jpeg" assert base64.b64decode(inline["data"]) == b"jpeg-bytes" def test_gemini_build_body_inlines_data_uri(): blob = base64.b64encode(b"png-bytes").decode("ascii") body = adapters._gemini_build_body( {"prompt": "x", "image": f"data:image/png;base64,{blob}"}, api_key="k", ) inline = body["contents"][0]["parts"][1]["inlineData"] assert inline["mimeType"] == "image/png" assert inline["data"] == blob def test_gemini_inline_image_missing_path_raises(tmp_path): with pytest.raises(client.ApiError, match="not found"): adapters._inline_image(str(tmp_path / "nope.png")) def test_gemini_decode_sync_saves_inline_blobs(tmp_path): blob = base64.b64encode(b"png-payload").decode("ascii") body = {"candidates": [{"content": {"parts": [{"inlineData": {"mimeType": "image/png", "data": blob}}]}}]} out = tmp_path / "out.png" saved = adapters._gemini_decode_sync(body, str(out), "req-1") assert saved == [out] assert out.read_bytes() == b"png-payload" def test_gemini_decode_sync_handles_snake_case_keys(tmp_path): """Gemini responses are sometimes serialized as inline_data/mime_type. With a directory-shorthand template, the mime drives the extension.""" blob = base64.b64encode(b"webp-payload").decode("ascii") body = {"candidates": [{"content": {"parts": [{"inline_data": {"mime_type": "image/webp", "data": blob}}]}}]} saved = adapters._gemini_decode_sync(body, str(tmp_path) + "/", "r") assert len(saved) == 1 assert saved[0].read_bytes() == b"webp-payload" assert saved[0].suffix == ".webp" def test_gemini_decode_sync_returns_empty_when_blocked(tmp_path): body = {"candidates": [{"finishReason": "SAFETY", "content": {"parts": []}}]} saved = adapters._gemini_decode_sync(body, str(tmp_path / "x.png"), "r") assert saved == [] def test_gemini_resolve_path_substitutes_model(): ep = spec.get_endpoint("nano-banana") adapter = adapters.get(ep.id) url = adapters.resolve_path(ep.path, {"model": "gemini-2.5-flash-image"}, adapter) assert url == "/proxy/vertexai/gemini/gemini-2.5-flash-image" def test_gemini_send_request_hits_substituted_path(monkeypatch): captured = {} def fake_post(url, *, json=None, headers=None, timeout=None, **_kw): captured["url"] = url captured["json"] = json return httpx.Response(200, json={"candidates": []}) monkeypatch.setattr(client.httpx, "post", fake_post) ep = spec.get_endpoint("nano-banana") flags = schema.flags_for(ep) client.send_request( ep, {"prompt": "hi", "model": "gemini-2.5-flash-image"}, flags, api_key="comfyui-test", ) assert captured["url"].endswith("/proxy/vertexai/gemini/gemini-2.5-flash-image") assert captured["json"]["contents"][0]["parts"][0]["text"] == "hi" # ── Seedance ────────────────────────────────────────────────────────────── def test_seedance_alias_resolves(): ep = spec.get_endpoint("seedance") assert ep.id == "byteplus/api/v3/contents/generations/tasks" assert ep.polling == "seedance" assert ep.partner == "byteplus" def test_seedance_adapter_overrides_flags(): ep = spec.get_endpoint("seedance") names = [f.name for f in schema.flags_for(ep)] assert "prompt" in names assert "model" in names assert "resolution" in names assert "ratio" in names assert "duration" in names def test_seedance_build_body_text_only(): body = adapters._seedance_build_body({"prompt": "a wave"}, api_key="k") assert body["model"] == "seedance-1-0-pro-250528" assert body["content"] == [{"type": "text", "text": "a wave"}] def test_seedance_build_body_inlines_knobs_into_text(): body = adapters._seedance_build_body( { "prompt": "a boat", "resolution": "720p", "ratio": "16:9", "duration": 5, "fps": 24, "camerafixed": True, }, api_key="k", ) text = body["content"][0]["text"] assert text.startswith("a boat ") assert "--resolution 720p" in text assert "--ratio 16:9" in text assert "--duration 5" in text assert "--fps 24" in text assert "--camerafixed true" in text def test_seedance_build_body_uploads_local_image(monkeypatch, tmp_path): """Local paths get pushed through /customers/storage and replaced with the returned signed URL — we shouldn't see the path appear in the body.""" img = tmp_path / "ref.png" img.write_bytes(b"ref") from comfy_cli.command.generate import upload def fake_upload_path(path, api_key): return upload.UploadResult(url="https://cdn/signed-ref.png", expires_at=None, existing_file=False) monkeypatch.setattr(upload, "upload_path", fake_upload_path) body = adapters._seedance_build_body( {"prompt": "wave", "image": str(img)}, api_key="comfyui-test", ) image_part = body["content"][1] assert image_part == {"type": "image_url", "image_url": {"url": "https://cdn/signed-ref.png"}} def test_seedance_build_body_keeps_remote_url_verbatim(monkeypatch): """Remote URLs and data: URIs are pass-through — no re-upload.""" from comfy_cli.command.generate import upload def boom(*a, **kw): raise AssertionError("upload should not be called for remote URLs") monkeypatch.setattr(upload, "upload_path", boom) body = adapters._seedance_build_body( {"prompt": "x", "image": "https://example.com/a.jpg"}, api_key="k", ) assert body["content"][1]["image_url"]["url"] == "https://example.com/a.jpg" def test_seedance_build_body_includes_audio_flag(): body = adapters._seedance_build_body( {"prompt": "x", "model": "seedance-1-5-pro-251215", "generate_audio": True}, api_key="k", ) assert body["generate_audio"] is True assert body["model"] == "seedance-1-5-pro-251215" def test_seedance_send_request_passes_through_body(monkeypatch): captured = {} def fake_post(url, *, json=None, headers=None, timeout=None, **_kw): captured["url"] = url captured["json"] = json return httpx.Response(200, json={"id": "task-1"}) monkeypatch.setattr(client.httpx, "post", fake_post) ep = spec.get_endpoint("seedance") flags = schema.flags_for(ep) client.send_request(ep, {"prompt": "x"}, flags, api_key="comfyui-test") assert captured["url"].endswith("/proxy/byteplus/api/v3/contents/generations/tasks") assert captured["json"]["model"] assert captured["json"]["content"][0]["type"] == "text" # ── Seedance polling ────────────────────────────────────────────────────── def test_seedance_poll_url_and_success_extraction(monkeypatch): """Driver should hit the task-status endpoint and pluck the video_url.""" from comfy_cli.command.generate import poll monkeypatch.setattr(poll, "_sleep", lambda *_: None) captured = {} def fake_get(url, **_kw): captured["url"] = url return httpx.Response( 200, json={"id": "t1", "status": "succeeded", "content": {"video_url": "https://cdn/v.mp4"}}, ) monkeypatch.setattr("comfy_cli.command.generate.client.get", fake_get) result = poll.get_poller("seedance")({"id": "t1"}, api_key="k") assert captured["url"] == "/proxy/byteplus/api/v3/contents/generations/tasks/t1" assert result.status == "succeeded" assert "https://cdn/v.mp4" in result.image_urls def test_seedance_poll_failure(monkeypatch): from comfy_cli.command.generate import poll monkeypatch.setattr(poll, "_sleep", lambda *_: None) monkeypatch.setattr( "comfy_cli.command.generate.client.get", lambda *a, **kw: httpx.Response(200, json={"id": "t1", "status": "failed", "error": {"code": "x"}}), ) result = poll.get_poller("seedance")({"id": "t1"}, api_key="k") assert result.status == "failed" assert "failed" in (result.error or "") def test_seedance_resume_helper_round_trip(): """`comfy generate resume` reverses the create-response into something the poller can read — make sure that helper knows about seedance.""" from comfy_cli.command.generate import poll body = poll.build_synthetic_initial("seedance", "t-42") assert poll.extract_job_id("seedance", body) == "t-42" # ── Inline blob saving ──────────────────────────────────────────────────── def test_save_inline_blobs_auto_indexes_multi(tmp_path): blobs = [("image/png", b"a"), ("image/png", b"b")] saved = output.save_inline_blobs(blobs, str(tmp_path / "out.png"), "req") assert len(saved) == 2 assert saved[0].name == "out_0.png" assert saved[1].name == "out_1.png" assert saved[0].read_bytes() == b"a" assert saved[1].read_bytes() == b"b" def test_save_inline_blobs_picks_extension_from_mime(tmp_path): saved = output.save_inline_blobs([("image/webp", b"x")], str(tmp_path) + "/", "req") assert saved[0].suffix == ".webp" ================================================ FILE: tests/comfy_cli/command/generate/test_app.py ================================================ """End-to-end tests for ``comfy generate`` via Typer's CliRunner. These cover the dispatch table (list/schema/refresh/resume vs. model alias) and each major run path with httpx mocked at the boundary. """ import base64 from pathlib import Path import httpx import pytest from typer.testing import CliRunner from comfy_cli.cmdline import app as cli_app from comfy_cli.command.generate import app as gen_app @pytest.fixture(autouse=True) def disable_tracking_prompt(monkeypatch): """The mixpanel-consent prompt blocks Typer invocations in CI (no TTY). Existing CLI tests pass --skip-prompt; we do the same here implicitly.""" monkeypatch.setattr("comfy_cli.tracking.prompt_tracking_consent", lambda *a, **kw: None) monkeypatch.setattr("comfy_cli.tracking.track_event", lambda *a, **kw: None) @pytest.fixture def runner(): return CliRunner() @pytest.fixture def api_key(monkeypatch): monkeypatch.setenv("COMFY_API_KEY", "comfyui-test") return "comfyui-test" # ─── Dispatch / top-level help ──────────────────────────────────────────── def test_no_args_prints_top_help(runner): r = runner.invoke(cli_app, ["generate"]) assert r.exit_code == 0 assert "comfy generate" in r.stdout assert "Examples" in r.stdout def test_top_help_via_dash_help(runner): r = runner.invoke(cli_app, ["generate", "--help"]) assert r.exit_code == 0 assert "comfy generate" in r.stdout # ─── list ──────────────────────────────────────────────────────────────── def test_list_shows_aliases(runner): r = runner.invoke(cli_app, ["generate", "list"]) assert r.exit_code == 0 assert "flux-pro" in r.stdout def test_list_partner_filter(runner): r = runner.invoke(cli_app, ["generate", "list", "--partner", "bfl"]) assert r.exit_code == 0 assert "flux-pro" in r.stdout assert "ideogram" not in r.stdout def test_list_partner_eq_form(runner): r = runner.invoke(cli_app, ["generate", "list", "--partner=bfl"]) assert r.exit_code == 0 assert "flux-pro" in r.stdout def test_list_style_filter(runner): r = runner.invoke(cli_app, ["generate", "list", "--style", "image-edit"]) assert r.exit_code == 0 assert "edit" in r.stdout.lower() def test_list_query_filter(runner): r = runner.invoke(cli_app, ["generate", "list", "--query", "ideogram"]) assert r.exit_code == 0 assert "ideogram" in r.stdout def test_list_no_matches(runner): r = runner.invoke(cli_app, ["generate", "list", "--partner", "nonexistent"]) assert r.exit_code == 0 assert "No models" in r.stdout # ─── schema ────────────────────────────────────────────────────────────── def test_schema_alias(runner): r = runner.invoke(cli_app, ["generate", "schema", "flux-pro"]) assert r.exit_code == 0 assert "prompt" in r.stdout assert "Example" in r.stdout def test_schema_full_path(runner): r = runner.invoke(cli_app, ["generate", "schema", "bfl/flux-pro-1.1/generate"]) assert r.exit_code == 0 assert "prompt" in r.stdout def test_schema_missing_arg(runner): r = runner.invoke(cli_app, ["generate", "schema"]) assert r.exit_code == 1 assert "Usage" in r.stdout def test_schema_unknown_model(runner): r = runner.invoke(cli_app, ["generate", "schema", "bogus-model"]) assert r.exit_code == 1 assert "Unknown model" in r.stdout # ─── per-model --help passes through to schema view ───────────────────── def test_per_model_help(runner): r = runner.invoke(cli_app, ["generate", "flux-pro", "--help"]) assert r.exit_code == 0 assert "Model:" in r.stdout assert "prompt" in r.stdout # ─── generate happy / error paths ─────────────────────────────────────── def test_generate_missing_api_key(runner, monkeypatch): monkeypatch.delenv("COMFY_API_KEY", raising=False) r = runner.invoke( cli_app, ["generate", "flux-pro", "--prompt", "x", "--width", "1", "--height", "1"], ) assert r.exit_code == 1 assert "No API key" in r.stdout def test_generate_bad_int_suggests_schema(runner, api_key): r = runner.invoke( cli_app, ["generate", "flux-pro", "--prompt", "x", "--width", "abc", "--height", "1"], ) assert r.exit_code == 1 assert "expected integer" in r.stdout assert "comfy generate schema" in r.stdout def test_generate_unknown_model(runner, api_key): r = runner.invoke(cli_app, ["generate", "bogus-name", "--prompt", "x"]) assert r.exit_code == 1 assert "Unknown model" in r.stdout def test_generate_missing_required(runner, api_key): r = runner.invoke(cli_app, ["generate", "flux-pro", "--prompt", "x"]) assert r.exit_code == 1 assert "Missing required" in r.stdout def test_generate_bad_timeout(runner, api_key, monkeypatch): monkeypatch.setattr( gen_app.client.httpx, "post", lambda *a, **kw: httpx.Response(200, json={"id": "x", "polling_url": "https://x"}), ) r = runner.invoke( cli_app, ["generate", "flux-pro", "--prompt", "x", "--width", "1", "--height", "1", "--timeout", "not-a-num"], ) assert r.exit_code == 1 assert "--timeout" in r.stdout # ─── generate: async polling path (BFL) ───────────────────────────────── def test_generate_async_sync_poll_to_ready(runner, api_key, monkeypatch): submit = httpx.Response(200, json={"id": "job-xyz", "polling_url": "https://x/poll"}) poll_done = httpx.Response( 200, json={ "status": "Ready", "progress": 1.0, "result": {"sample": "https://cdn.example/result.png"}, }, ) monkeypatch.setattr(gen_app.client.httpx, "post", lambda *a, **kw: submit) monkeypatch.setattr("comfy_cli.command.generate.client.get", lambda *a, **kw: poll_done) monkeypatch.setattr("comfy_cli.command.generate.poll._sleep", lambda *_: None) r = runner.invoke(cli_app, ["generate", "flux-pro", "--prompt", "x", "--width", "1", "--height", "1"]) assert r.exit_code == 0, r.stdout assert "https://cdn.example/result.png" in r.stdout def test_generate_async_returns_job_id(runner, api_key, monkeypatch): submit = httpx.Response(200, json={"id": "job-xyz", "polling_url": "https://x/poll"}) monkeypatch.setattr(gen_app.client.httpx, "post", lambda *a, **kw: submit) r = runner.invoke( cli_app, ["generate", "flux-pro", "--prompt", "x", "--width", "1", "--height", "1", "--async"], ) assert r.exit_code == 0 assert "Submitted" in r.stdout assert "job-xyz" in r.stdout assert "comfy generate resume" in r.stdout def test_generate_async_failure_status(runner, api_key, monkeypatch): submit = httpx.Response(200, json={"id": "job-xyz", "polling_url": "https://x/poll"}) poll_fail = httpx.Response(200, json={"status": "Content Moderated", "progress": 0.0}) monkeypatch.setattr(gen_app.client.httpx, "post", lambda *a, **kw: submit) monkeypatch.setattr("comfy_cli.command.generate.client.get", lambda *a, **kw: poll_fail) monkeypatch.setattr("comfy_cli.command.generate.poll._sleep", lambda *_: None) r = runner.invoke(cli_app, ["generate", "flux-pro", "--prompt", "x", "--width", "1", "--height", "1"]) assert r.exit_code == 1 assert "failed" in r.stdout.lower() # ─── generate: sync JSON response with URL outputs ────────────────────── def test_generate_sync_prints_url(runner, api_key, monkeypatch): resp = httpx.Response(200, json={"data": [{"url": "https://cdn.example/a.png"}]}) monkeypatch.setattr(gen_app.client.httpx, "post", lambda *a, **kw: resp) r = runner.invoke(cli_app, ["generate", "dalle", "--prompt", "x"]) assert r.exit_code == 0, r.stdout assert "https://cdn.example/a.png" in r.stdout def test_generate_sync_with_download(runner, api_key, tmp_path, monkeypatch): resp = httpx.Response(200, json={"data": [{"url": "https://cdn.example/a.png"}]}) monkeypatch.setattr(gen_app.client.httpx, "post", lambda *a, **kw: resp) monkeypatch.setattr("comfy_cli.command.generate.client.download_bytes", lambda *a, **kw: b"png-bytes") download = str(tmp_path / "out.png") r = runner.invoke(cli_app, ["generate", "dalle", "--prompt", "x", "--download", download]) assert r.exit_code == 0, r.stdout assert Path(download).exists() assert Path(download).read_bytes() == b"png-bytes" assert "Saved" in r.stdout def test_generate_json_flag(runner, api_key, monkeypatch): resp = httpx.Response(200, json={"data": [{"url": "https://cdn.example/a.png"}]}) monkeypatch.setattr(gen_app.client.httpx, "post", lambda *a, **kw: resp) r = runner.invoke(cli_app, ["generate", "dalle", "--prompt", "x", "--json"]) assert r.exit_code == 0 # Strip newlines/whitespace from output so we can match across rich's line wrapping flat = "".join(r.stdout.split()) assert '"url":"https://cdn.example/a.png"' in flat def test_generate_download_no_urls(runner, api_key, monkeypatch): resp = httpx.Response(200, json={"data": []}) monkeypatch.setattr(gen_app.client.httpx, "post", lambda *a, **kw: resp) r = runner.invoke(cli_app, ["generate", "dalle", "--prompt", "x", "--download", "/tmp/x.png"]) assert r.exit_code == 0 assert "no image urls" in r.stdout.lower() # ─── generate: sync binary response (Stability returns bytes) ──────────── def test_generate_binary_response_with_download(runner, api_key, tmp_path, monkeypatch): resp = httpx.Response(200, content=b"\x89PNGfake", headers={"content-type": "image/png"}) monkeypatch.setattr(gen_app.client.httpx, "post", lambda *a, **kw: resp) download = str(tmp_path / "ultra.png") r = runner.invoke(cli_app, ["generate", "stability-ultra", "--prompt", "x", "--download", download]) assert r.exit_code == 0, r.stdout assert Path(download).exists() def test_generate_binary_response_no_download(runner, api_key, monkeypatch): resp = httpx.Response(200, content=b"\x89PNGfake", headers={"content-type": "image/png"}) monkeypatch.setattr(gen_app.client.httpx, "post", lambda *a, **kw: resp) r = runner.invoke(cli_app, ["generate", "stability-ultra", "--prompt", "x"]) assert r.exit_code == 0 assert "nothing saved" in r.stdout # ─── generate: HTTP and network errors ─────────────────────────────────── def test_generate_api_error_surface(runner, api_key, monkeypatch): resp = httpx.Response(401, json={"message": "Invalid token"}) monkeypatch.setattr(gen_app.client.httpx, "post", lambda *a, **kw: resp) r = runner.invoke(cli_app, ["generate", "flux-pro", "--prompt", "x", "--width", "1", "--height", "1"]) assert r.exit_code == 1 assert "API error 401" in r.stdout assert "Invalid token" in r.stdout def test_generate_network_error_surface(runner, api_key, monkeypatch): def boom(*a, **kw): raise httpx.ConnectError("connection refused") monkeypatch.setattr(gen_app.client.httpx, "post", boom) r = runner.invoke(cli_app, ["generate", "flux-pro", "--prompt", "x", "--width", "1", "--height", "1"]) assert r.exit_code == 1 assert "Network error" in r.stdout def test_generate_non_json_response(runner, api_key, monkeypatch): resp = httpx.Response(200, text="not really json", headers={"content-type": "text/plain"}) monkeypatch.setattr(gen_app.client.httpx, "post", lambda *a, **kw: resp) r = runner.invoke(cli_app, ["generate", "dalle", "--prompt", "x"]) assert r.exit_code == 1 assert "non-JSON" in r.stdout # ─── resume ────────────────────────────────────────────────────────────── def test_resume_missing_args(runner, api_key): r = runner.invoke(cli_app, ["generate", "resume"]) assert r.exit_code == 1 assert "Usage" in r.stdout def test_resume_sync_model_rejected(runner, api_key): r = runner.invoke(cli_app, ["generate", "resume", "dalle", "abc"]) assert r.exit_code == 1 assert "sync" in r.stdout def test_resume_unknown_model(runner, api_key): r = runner.invoke(cli_app, ["generate", "resume", "nope-model", "abc"]) assert r.exit_code == 1 assert "Unknown model" in r.stdout def test_resume_async_succeeds(runner, api_key, monkeypatch): poll_done = httpx.Response( 200, json={"status": "Ready", "progress": 1.0, "result": {"sample": "https://cdn.example/done.png"}}, ) monkeypatch.setattr("comfy_cli.command.generate.client.get", lambda *a, **kw: poll_done) monkeypatch.setattr("comfy_cli.command.generate.poll._sleep", lambda *_: None) r = runner.invoke(cli_app, ["generate", "resume", "flux-pro", "job-123"]) assert r.exit_code == 0 assert "https://cdn.example/done.png" in r.stdout def test_resume_with_download(runner, api_key, tmp_path, monkeypatch): poll_done = httpx.Response( 200, json={"status": "Ready", "progress": 1.0, "result": {"sample": "https://cdn.example/done.png"}}, ) monkeypatch.setattr("comfy_cli.command.generate.client.get", lambda *a, **kw: poll_done) monkeypatch.setattr("comfy_cli.command.generate.client.download_bytes", lambda *a, **kw: b"bytes") monkeypatch.setattr("comfy_cli.command.generate.poll._sleep", lambda *_: None) download = str(tmp_path / "resumed.png") r = runner.invoke(cli_app, ["generate", "resume", "flux-pro", "job-123", "--download", download]) assert r.exit_code == 0 assert Path(download).exists() # ─── refresh ───────────────────────────────────────────────────────────── def test_refresh_writes_cache(runner, monkeypatch, tmp_path): captured = {} class FakeClient: def __init__(self, *a, **kw): pass def __enter__(self): return self def __exit__(self, *a): pass def get(self, url, headers=None): captured["url"] = url captured["headers"] = headers or {} return httpx.Response( 200, text="openapi: 3.0.0\n", request=httpx.Request("GET", url), ) monkeypatch.setattr(gen_app.httpx, "Client", FakeClient) monkeypatch.setattr("comfy_cli.command.generate.spec._USER_CACHE", tmp_path / "openapi-cache.yml") r = runner.invoke(cli_app, ["generate", "refresh"]) assert r.exit_code == 0, r.stdout assert "Refreshed" in r.stdout assert (tmp_path / "openapi-cache.yml").exists() assert captured["headers"].get("Comfy-Env") == "comfy-cli" def test_refresh_network_failure(runner, monkeypatch): class FakeClient: def __init__(self, *a, **kw): pass def __enter__(self): return self def __exit__(self, *a): pass def get(self, *a, **kw): raise httpx.ConnectError("no net") monkeypatch.setattr(gen_app.httpx, "Client", FakeClient) r = runner.invoke(cli_app, ["generate", "refresh"]) assert r.exit_code == 1 assert "Failed to fetch" in r.stdout # ─── upload subcommand ────────────────────────────────────────────────── def test_upload_missing_arg(runner, api_key): r = runner.invoke(cli_app, ["generate", "upload"]) assert r.exit_code == 1 assert "Usage" in r.stdout def test_upload_local_file(runner, api_key, tmp_path, monkeypatch): img = tmp_path / "x.png" img.write_bytes(b"png-data") monkeypatch.setattr( "comfy_cli.command.generate.upload.upload_target", lambda target, api_key: gen_app.upload.UploadResult( url="https://cdn/x.png", expires_at="2099-01-01T00:00:00Z", existing_file=False ), ) r = runner.invoke(cli_app, ["generate", "upload", str(img)]) assert r.exit_code == 0, r.stdout assert "Uploaded" in r.stdout assert "https://cdn/x.png" in r.stdout def test_upload_json_output(runner, api_key, tmp_path, monkeypatch): img = tmp_path / "x.png" img.write_bytes(b"png-data") monkeypatch.setattr( "comfy_cli.command.generate.upload.upload_target", lambda target, api_key: gen_app.upload.UploadResult( url="https://cdn/x.png", expires_at="2099-01-01T00:00:00Z", existing_file=True ), ) r = runner.invoke(cli_app, ["generate", "upload", str(img), "--json"]) assert r.exit_code == 0 flat = "".join(r.stdout.split()) assert '"url":"https://cdn/x.png"' in flat assert '"existing_file":true' in flat def test_upload_does_not_mistake_meta_value_for_target(runner, monkeypatch, tmp_path): """`upload --api-key KEY ./img.png` must resolve ./img.png as the target, not KEY — regression check for the positional parsing bug.""" img = tmp_path / "x.png" img.write_bytes(b"png-data") captured = {} def fake_upload(target, api_key): captured["target"] = target captured["api_key"] = api_key return gen_app.upload.UploadResult(url="https://cdn/x.png", expires_at=None, existing_file=False) monkeypatch.setattr("comfy_cli.command.generate.upload.upload_target", fake_upload) r = runner.invoke(cli_app, ["generate", "upload", "--api-key", "comfyui-test", str(img)]) assert r.exit_code == 0, r.stdout assert captured["target"] == str(img) assert captured["api_key"] == "comfyui-test" def test_upload_propagates_api_error(runner, api_key, tmp_path, monkeypatch): img = tmp_path / "x.png" img.write_bytes(b"png-data") def boom(*a, **kw): raise gen_app.client.ApiError(500, "fail", "boom") monkeypatch.setattr("comfy_cli.command.generate.upload.upload_target", boom) r = runner.invoke(cli_app, ["generate", "upload", str(img)]) assert r.exit_code == 1 assert "Upload failed" in r.stdout # ─── auto-upload during generate ──────────────────────────────────────── def test_generate_auto_base64_for_kontext(runner, api_key, tmp_path, monkeypatch): """flux-kontext's input_image expects a Base64 string — local files should be auto-encoded with no extra steps.""" img = tmp_path / "ref.png" img.write_bytes(b"\x89PNGfake") captured = {} def fake_post(url, *, json=None, headers=None, timeout=None, **_): captured["body"] = json return httpx.Response(200, json={"id": "job-xyz", "polling_url": "https://x/poll"}) monkeypatch.setattr(gen_app.client.httpx, "post", fake_post) r = runner.invoke( cli_app, ["generate", "flux-kontext", "--prompt", "edit it", "--input_image", str(img), "--async"], ) assert r.exit_code == 0, r.stdout assert captured["body"]["input_image"] == base64.b64encode(b"\x89PNGfake").decode("ascii") def test_generate_auto_upload_leaves_url_alone(runner, api_key, monkeypatch): """A pre-existing https:// URL must NOT trigger an upload.""" upload_called = {"hit": False} def fake_upload(*a, **kw): upload_called["hit"] = True return gen_app.upload.UploadResult(url="x", expires_at=None, existing_file=False) monkeypatch.setattr("comfy_cli.command.generate.upload.upload_path", fake_upload) captured = {} def fake_post(url, *, json=None, headers=None, timeout=None, **_): captured["body"] = json return httpx.Response(200, json={"id": "x", "polling_url": "https://x"}) monkeypatch.setattr(gen_app.client.httpx, "post", fake_post) r = runner.invoke( cli_app, [ "generate", "flux-kontext", "--prompt", "x", "--input_image", "https://existing/url.png", "--async", ], ) assert r.exit_code == 0 assert upload_called["hit"] is False assert captured["body"]["input_image"] == "https://existing/url.png" def test_generate_auto_upload_skipped_for_multipart(runner, api_key, tmp_path, monkeypatch): """Multipart endpoints (ideogram-edit) already stream files via httpx — they must not be funneled through /customers/storage.""" img = tmp_path / "x.png" img.write_bytes(b"png") upload_called = {"hit": False} monkeypatch.setattr( "comfy_cli.command.generate.upload.upload_path", lambda *a, **kw: upload_called.__setitem__("hit", True) or gen_app.upload.UploadResult("x", None, False), ) monkeypatch.setattr( gen_app.client.httpx, "post", lambda *a, **kw: httpx.Response(200, json={"data": [{"url": "https://x/a.png"}]}), ) r = runner.invoke( cli_app, [ "generate", "ideogram-edit", "--prompt", "x", "--rendering_speed", "TURBO", "--image", str(img), ], ) assert r.exit_code == 0 assert upload_called["hit"] is False # ─── video models (async polling, generic poller path) ───────────────── def test_video_kling_async_path(runner, api_key, monkeypatch): """End-to-end async path through the generic kling poller.""" submit = httpx.Response(200, json={"data": {"task_id": "k-xyz"}}) finished = httpx.Response( 200, json={ "data": { "task_status": "succeed", "task_result": {"videos": [{"url": "https://cdn.example/k.mp4"}]}, } }, ) monkeypatch.setattr(gen_app.client.httpx, "post", lambda *a, **kw: submit) monkeypatch.setattr("comfy_cli.command.generate.client.get", lambda *a, **kw: finished) monkeypatch.setattr("comfy_cli.command.generate.poll._sleep", lambda *_: None) r = runner.invoke(cli_app, ["generate", "kling", "--prompt", "a cat", "--duration", "5"]) assert r.exit_code == 0, r.stdout assert "https://cdn.example/k.mp4" in r.stdout def test_video_luma_async_path(runner, api_key, monkeypatch): submit = httpx.Response(200, json={"id": "luma-1", "state": "queued"}) done = httpx.Response(200, json={"id": "luma-1", "state": "completed", "assets": {"video": "https://cdn/l.mp4"}}) monkeypatch.setattr(gen_app.client.httpx, "post", lambda *a, **kw: submit) monkeypatch.setattr("comfy_cli.command.generate.client.get", lambda *a, **kw: done) monkeypatch.setattr("comfy_cli.command.generate.poll._sleep", lambda *_: None) r = runner.invoke( cli_app, [ "generate", "luma", "--prompt", "a cat", "--aspect_ratio", "16:9", "--model", "ray-2", "--resolution", "{}", "--duration", "{}", ], ) assert r.exit_code == 0, r.stdout assert "https://cdn/l.mp4" in r.stdout def test_video_runway_failure_surfaces(runner, api_key, monkeypatch): submit = httpx.Response(200, json={"id": "rw-1"}) fail = httpx.Response(200, json={"id": "rw-1", "status": "FAILED"}) monkeypatch.setattr(gen_app.client.httpx, "post", lambda *a, **kw: submit) monkeypatch.setattr("comfy_cli.command.generate.client.get", lambda *a, **kw: fail) monkeypatch.setattr("comfy_cli.command.generate.poll._sleep", lambda *_: None) r = runner.invoke( cli_app, [ "generate", "runway-i2v", "--promptImage", '"https://x/img.png"', "--seed", "1", "--model", "gen4_turbo", "--duration", "5", "--ratio", "1280:720", ], ) assert r.exit_code == 1 assert "FAILED" in r.stdout def test_video_async_submission_shows_resume_alias(runner, api_key, monkeypatch): submit = httpx.Response(200, json={"data": {"task_id": "k-async-1"}}) monkeypatch.setattr(gen_app.client.httpx, "post", lambda *a, **kw: submit) r = runner.invoke(cli_app, ["generate", "kling", "--prompt", "x", "--async"]) assert r.exit_code == 0, r.stdout assert "k-async-1" in r.stdout assert "comfy generate resume kling k-async-1" in r.stdout def test_video_resume_kling(runner, api_key, monkeypatch): done = httpx.Response( 200, json={ "data": { "task_status": "succeed", "task_result": {"videos": [{"url": "https://cdn/resumed.mp4"}]}, } }, ) monkeypatch.setattr("comfy_cli.command.generate.client.get", lambda *a, **kw: done) monkeypatch.setattr("comfy_cli.command.generate.poll._sleep", lambda *_: None) r = runner.invoke(cli_app, ["generate", "resume", "kling", "k-async-1"]) assert r.exit_code == 0, r.stdout assert "https://cdn/resumed.mp4" in r.stdout def test_list_video_filter(runner): r = runner.invoke(cli_app, ["generate", "list", "--style", "text-to-video"]) assert r.exit_code == 0 assert "kling" in r.stdout assert "luma" in r.stdout assert "pika" in r.stdout # ─── helpers: _arg_value / _separate_meta_flags ────────────────────────── def test_arg_value_long_and_eq(): assert gen_app._arg_value(["--foo", "bar"], "--foo") == "bar" assert gen_app._arg_value(["--foo=baz"], "--foo") == "baz" assert gen_app._arg_value(["--bar", "v"], "--foo", "-f") is None def test_arg_value_alternatives(): assert gen_app._arg_value(["-p", "bfl"], "--partner", "-p") == "bfl" def test_separate_meta_flags_typical(): rest, meta = gen_app._separate_meta_flags(["--prompt", "x", "--download", "out.png", "--async", "--timeout", "30"]) assert rest == ["--prompt", "x"] assert meta["download"] == "out.png" assert meta["async"] is True assert meta["timeout"] == "30" def test_separate_meta_flags_eq_form(): _, meta = gen_app._separate_meta_flags(["--download=cat.png", "--json"]) assert meta == {"download": "cat.png", "json": True} def test_separate_meta_flags_missing_value_raises(): from comfy_cli.command.generate.schema import SchemaError with pytest.raises(SchemaError): gen_app._separate_meta_flags(["--download"]) ================================================ FILE: tests/comfy_cli/command/generate/test_client.py ================================================ """Tests for the httpx client wrapper — auth header, payload split.""" import httpx import pytest from comfy_cli.command.generate import client, schema, spec def test_resolve_api_key_from_env(monkeypatch): monkeypatch.setenv("COMFY_API_KEY", " sk-abc ") assert client.resolve_api_key() == "sk-abc" def test_resolve_api_key_explicit_wins(monkeypatch): monkeypatch.setenv("COMFY_API_KEY", "env-key") assert client.resolve_api_key("flag-key") == "flag-key" def test_resolve_api_key_missing(monkeypatch): monkeypatch.delenv("COMFY_API_KEY", raising=False) with pytest.raises(client.ApiError, match="No API key"): client.resolve_api_key() def test_split_payload_json_pass_through(): ep = spec.get_endpoint("bfl/flux-pro-1.1/generate") flags = schema.flags_for(ep) json_body, files, data = client._split_payload( {"prompt": "x", "width": 1024, "height": 1024}, flags, ep.request_content_type, ) assert json_body == {"prompt": "x", "width": 1024, "height": 1024} assert files is None and data is None def test_split_payload_multipart_separates_files(tmp_path): img = tmp_path / "img.png" img.write_bytes(b"fake") ep = spec.get_endpoint("ideogram/ideogram-v3/edit") flags = schema.flags_for(ep) json_body, files, data = client._split_payload( {"prompt": "edit", "rendering_speed": "TURBO", "image": img, "num_images": 2}, flags, ep.request_content_type, ) assert json_body is None field_names = [name for name, _ in files] assert "image" in field_names assert data["prompt"] == "edit" assert data["num_images"] == "2" # Close any file handles we opened. for _name, payload in files: payload[1].close() def _capture_post(monkeypatch): captured = {} def fake_post(url, *, json=None, headers=None, timeout=None, **_kw): captured["url"] = url captured["headers"] = headers captured["json"] = json return httpx.Response(200, json={"id": "abc", "polling_url": "https://x"}) monkeypatch.setattr(client.httpx, "post", fake_post) return captured def test_send_request_uses_x_api_key_for_comfyui_keys(monkeypatch): ep = spec.get_endpoint("bfl/flux-pro-1.1/generate") flags = schema.flags_for(ep) captured = _capture_post(monkeypatch) client.send_request(ep, {"prompt": "x", "width": 1, "height": 1}, flags, api_key="comfyui-abc") assert captured["headers"]["X-API-Key"] == "comfyui-abc" assert "Authorization" not in captured["headers"] assert captured["headers"]["Comfy-Env"] == "comfy-cli" def test_send_request_uses_bearer_for_firebase_tokens(monkeypatch): ep = spec.get_endpoint("bfl/flux-pro-1.1/generate") flags = schema.flags_for(ep) captured = _capture_post(monkeypatch) client.send_request(ep, {"prompt": "x", "width": 1, "height": 1}, flags, api_key="eyJhbGciOi.foo.bar") assert captured["headers"]["Authorization"] == "Bearer eyJhbGciOi.foo.bar" assert "X-API-Key" not in captured["headers"] assert captured["url"].endswith("/proxy/bfl/flux-pro-1.1/generate") def test_raise_for_status_includes_body(): resp = httpx.Response(400, json={"error": "bad prompt"}) with pytest.raises(client.ApiError) as exc: client.raise_for_status(resp) assert exc.value.status == 400 assert "bad prompt" in exc.value.body ================================================ FILE: tests/comfy_cli/command/generate/test_output.py ================================================ """Tests for download templating.""" import httpx from comfy_cli.command.generate import output def test_resolve_template_directory_shorthand(tmp_path): p = output._resolve_template(f"{tmp_path}/", "abc123", 0, "png") assert p == tmp_path / "abc123_0.png" def test_resolve_template_placeholders(tmp_path): tmpl = str(tmp_path / "out_{request_id}_{index}.{ext}") p = output._resolve_template(tmpl, "abc", 2, "jpg") assert p == tmp_path / "out_abc_2.jpg" def test_ext_from_response_known_mime(): r = httpx.Response(200, headers={"content-type": "image/jpeg"}) assert output._ext_from_response(r) == "jpg" def test_ext_from_url_strips_query(): assert output._ext_from_url("https://x/result.webp?sig=abc") == "webp" ================================================ FILE: tests/comfy_cli/command/generate/test_poll.py ================================================ """Tests for the BFL polling adapter.""" from unittest.mock import patch import httpx from comfy_cli.command.generate import poll def _resp(body): return httpx.Response(200, json=body) def test_poll_bfl_extracts_sample_url(): responses = iter( [ _resp({"id": "abc", "status": "Pending", "progress": 0.2}), _resp( { "id": "abc", "status": "Ready", "progress": 1.0, "result": {"sample": "https://cdn.example/result.png"}, } ), ] ) progress_seen: list[float] = [] with ( patch("comfy_cli.command.generate.client.get", side_effect=lambda *a, **kw: next(responses)), patch("comfy_cli.command.generate.poll._sleep", lambda *_: None), ): result = poll.poll_bfl( {"polling_url": "https://api.comfy.org/proxy/bfl/get_result?id=abc"}, api_key="sk-test", on_progress=progress_seen.append, ) assert result.status == "succeeded" assert result.image_urls == ["https://cdn.example/result.png"] assert progress_seen == [0.2, 1.0] def test_poll_bfl_reports_failure(): responses = iter([_resp({"id": "abc", "status": "Content Moderated", "progress": 0.0})]) with ( patch("comfy_cli.command.generate.client.get", side_effect=lambda *a, **kw: next(responses)), patch("comfy_cli.command.generate.poll._sleep", lambda *_: None), ): result = poll.poll_bfl( {"polling_url": "https://x"}, api_key="sk-test", ) assert result.status == "failed" assert "Content Moderated" in (result.error or "") ================================================ FILE: tests/comfy_cli/command/generate/test_schema.py ================================================ """Tests for openapi schema → CLI flag conversion and argv parsing.""" import pytest from comfy_cli.command.generate import schema, spec def test_flags_for_bfl_classifies_types(): ep = spec.get_endpoint("bfl/flux-pro-1.1/generate") flags = {f.name: f for f in schema.flags_for(ep)} assert flags["prompt"].kind == "string" assert flags["prompt"].required assert flags["width"].kind == "integer" assert flags["prompt_upsampling"].kind == "boolean" assert flags["output_format"].kind == "enum" assert flags["output_format"].enum == ["jpeg", "png"] def test_flags_for_multipart_finds_binary_fields(): ep = spec.get_endpoint("ideogram/ideogram-v3/edit") flags = {f.name: f for f in schema.flags_for(ep)} assert flags["image"].kind == "binary" # style_reference_images is an array of binary file inputs. assert flags["style_reference_images"].kind == "array" assert flags["style_reference_images"].item_kind == "binary" def test_parse_args_basic_coercion(): ep = spec.get_endpoint("bfl/flux-pro-1.1/generate") flags = schema.flags_for(ep) values = schema.parse_args( flags, ["--prompt", "a cat", "--width", "1024", "--height", "1024", "--prompt_upsampling"], ) assert values == { "prompt": "a cat", "width": 1024, "height": 1024, "prompt_upsampling": True, } def test_parse_args_eq_form_and_enum(): ep = spec.get_endpoint("bfl/flux-pro-1.1/generate") flags = schema.flags_for(ep) values = schema.parse_args( flags, ["--prompt=a", "--width=1", "--height=1", "--output_format=png"], ) assert values["output_format"] == "png" def test_parse_args_rejects_unknown_flag(): ep = spec.get_endpoint("bfl/flux-pro-1.1/generate") flags = schema.flags_for(ep) with pytest.raises(schema.SchemaError, match="Unknown flag"): schema.parse_args(flags, ["--prompt", "a", "--width", "1", "--height", "1", "--bogus", "x"]) def test_parse_args_rejects_bad_int(): ep = spec.get_endpoint("bfl/flux-pro-1.1/generate") flags = schema.flags_for(ep) with pytest.raises(schema.SchemaError, match="expected integer"): schema.parse_args(flags, ["--prompt", "a", "--width", "abc", "--height", "1"]) def test_parse_args_missing_required(): ep = spec.get_endpoint("bfl/flux-pro-1.1/generate") flags = schema.flags_for(ep) with pytest.raises(schema.SchemaError, match="Missing required"): schema.parse_args(flags, ["--prompt", "a"]) def test_parse_args_enum_value_validated(): ep = spec.get_endpoint("bfl/flux-pro-1.1/generate") flags = schema.flags_for(ep) with pytest.raises(schema.SchemaError, match="not one of"): schema.parse_args( flags, ["--prompt", "a", "--width", "1", "--height", "1", "--output_format", "tiff"], ) def test_parse_args_object_accepts_json(): ep = spec.get_endpoint("ideogram/ideogram-v3/generate") flags = schema.flags_for(ep) values = schema.parse_args( flags, [ "--prompt", "x", "--rendering_speed", "TURBO", "--color_palette", '{"name":"PASTEL"}', ], ) assert values["color_palette"] == {"name": "PASTEL"} ================================================ FILE: tests/comfy_cli/command/generate/test_spec.py ================================================ """Tests for the openapi registry — verify the curated image allowlist resolves against the vendored spec and classifies each endpoint correctly.""" from comfy_cli.command.generate import spec def test_registry_loads_and_has_entries(): eps = spec.list_endpoints() assert len(eps) > 20, "expected the v1 allowlist to resolve >20 endpoints" def test_get_endpoint_round_trip(): ep = spec.get_endpoint("bfl/flux-pro-1.1/generate") assert ep.partner == "bfl" assert ep.path == "/proxy/bfl/flux-pro-1.1/generate" assert ep.method == "post" assert ep.polling == "bfl" assert ep.category == "text-to-image" def test_unknown_endpoint_suggests_close_match(): try: spec.get_endpoint("bfl/flux-pro-1.1/genrate") # typo except spec.SpecError as e: assert "Did you mean" in str(e) assert "bfl/flux-pro-1.1/generate" in str(e) else: raise AssertionError("expected SpecError") def test_request_schema_resolved_no_refs(): ep = spec.get_endpoint("ideogram/ideogram-v3/generate") props = ep.request_schema["properties"] # `rendering_speed` was a $ref in source; should now be inlined. assert isinstance(props["rendering_speed"], dict) assert "$ref" not in props["rendering_speed"] def test_multipart_endpoints_detected(): ep = spec.get_endpoint("ideogram/ideogram-v3/edit") assert ep.request_content_type == "multipart/form-data" def test_json_endpoints_detected(): ep = spec.get_endpoint("bfl/flux-pro-1.1/generate") assert ep.request_content_type == "application/json" def test_sync_endpoints_have_no_polling(): ep = spec.get_endpoint("openai/images/generations") assert ep.polling is None def test_filter_by_partner_and_category(): bfl = spec.list_endpoints(partner="bfl") assert bfl and all(e.partner == "bfl" for e in bfl) t2i = spec.list_endpoints(category="text-to-image") assert all(e.category == "text-to-image" for e in t2i) def test_proxy_prefix_accepted(): ep = spec.get_endpoint("/proxy/bfl/flux-pro-1.1/generate") assert ep.id == "bfl/flux-pro-1.1/generate" ================================================ FILE: tests/comfy_cli/command/generate/test_upload.py ================================================ """Tests for /customers/storage upload helpers.""" import httpx import pytest from comfy_cli.command.generate import client, upload def test_request_signed_url_posts_hash(monkeypatch): captured = {} def fake_post(url, *, json=None, headers=None, timeout=None): captured["url"] = url captured["json"] = json captured["headers"] = headers return httpx.Response( 200, json={ "upload_url": "https://signed/up", "download_url": "https://signed/down", "expires_at": "2099-01-01T00:00:00Z", "existing_file": False, }, ) monkeypatch.setattr(upload.httpx, "post", fake_post) body = upload._request_signed_url("cat.png", "image/png", "deadbeef", "comfyui-test") assert body["upload_url"] == "https://signed/up" assert captured["json"] == {"file_name": "cat.png", "content_type": "image/png", "file_hash": "deadbeef"} assert captured["headers"]["X-API-Key"] == "comfyui-test" assert captured["headers"]["Comfy-Env"] == "comfy-cli" assert captured["url"].endswith("/customers/storage") def test_upload_bytes_dedupe_skips_put(monkeypatch): monkeypatch.setattr( upload, "_request_signed_url", lambda **kw: { "download_url": "https://cached/x.png", "existing_file": True, "expires_at": "2099-01-01T00:00:00Z", }, ) called = {"put": False} def boom(*a, **kw): called["put"] = True monkeypatch.setattr(upload, "_put_bytes", boom) result = upload.upload_bytes(b"hello", "x.png", "comfyui-test") assert result.url == "https://cached/x.png" assert result.existing_file is True assert called["put"] is False def test_upload_bytes_new_file_puts(monkeypatch): monkeypatch.setattr( upload, "_request_signed_url", lambda **kw: { "upload_url": "https://signed/up", "download_url": "https://signed/down", "existing_file": False, "expires_at": None, }, ) captured = {} def fake_put(upload_url, data, content_type): captured["upload_url"] = upload_url captured["data"] = data captured["content_type"] = content_type monkeypatch.setattr(upload, "_put_bytes", fake_put) result = upload.upload_bytes(b"raw-bytes", "cat.png", "comfyui-test") assert result.url == "https://signed/down" assert result.existing_file is False assert captured["data"] == b"raw-bytes" assert captured["content_type"] == "image/png" def test_upload_path_reads_file(monkeypatch, tmp_path): img = tmp_path / "x.jpg" img.write_bytes(b"jpeg-bytes") called = {} def fake_upload_bytes(data, file_name, api_key, content_type=None): called["data"] = data called["file_name"] = file_name called["content_type"] = content_type return upload.UploadResult(url="https://x/a", expires_at=None, existing_file=False) monkeypatch.setattr(upload, "upload_bytes", fake_upload_bytes) upload.upload_path(img, "comfyui-test") assert called["data"] == b"jpeg-bytes" assert called["file_name"] == "x.jpg" def test_upload_path_missing_file(tmp_path): with pytest.raises(client.ApiError, match="not found"): upload.upload_path(tmp_path / "nope.png", "comfyui-test") def test_upload_remote_url_rehosts(monkeypatch): monkeypatch.setattr( upload, "upload_bytes", lambda data, file_name, api_key, content_type=None: upload.UploadResult( url=f"https://rehosted/{file_name}", expires_at=None, existing_file=False ), ) class FakeClient: def __init__(self, *a, **kw): pass def __enter__(self): return self def __exit__(self, *a): pass def get(self, url): return httpx.Response( 200, content=b"png-bytes", headers={"content-type": "image/png"}, request=httpx.Request("GET", url), ) monkeypatch.setattr(upload.httpx, "Client", FakeClient) result = upload.upload_remote_url("https://example.com/photo.png", "comfyui-test") assert result.url == "https://rehosted/photo.png" def test_upload_target_dispatches_on_scheme(monkeypatch, tmp_path): called = {} monkeypatch.setattr(upload, "upload_path", lambda p, api_key: called.setdefault("path", p)) monkeypatch.setattr(upload, "upload_remote_url", lambda u, api_key: called.setdefault("url", u)) upload.upload_target("https://example.com/x.png", "k") upload.upload_target("/tmp/x.png", "k") assert called["url"] == "https://example.com/x.png" assert called["path"] == "/tmp/x.png" def test_put_bytes_raises_on_error(monkeypatch): class FakeClient: def __init__(self, *a, **kw): pass def __enter__(self): return self def __exit__(self, *a): pass def put(self, *a, **kw): return httpx.Response(500, text="boom", request=httpx.Request("PUT", "https://x")) monkeypatch.setattr(upload.httpx, "Client", FakeClient) with pytest.raises(client.ApiError, match="HTTP 500"): upload._put_bytes("https://x", b"data", "image/png") ================================================ FILE: tests/comfy_cli/command/generate/test_video_poll.py ================================================ """Tests for the generic config-driven poller and per-partner specs.""" import httpx import pytest from comfy_cli.command.generate import poll def _resp(body): return httpx.Response(200, json=body) @pytest.fixture def no_sleep(monkeypatch): monkeypatch.setattr(poll, "_sleep", lambda *_: None) def _make_runner(get_responses): """Patch client.get with an iterator over fake responses.""" it = iter(get_responses) return lambda *_a, **_kw: next(it) def test_kling_sibling_poll_path(no_sleep, monkeypatch): """Kling builds the poll URL from {create_path}/{id}.""" captured = {} def fake_get(url, **kw): captured["url"] = url return _resp({"data": {"task_status": "succeed", "task_result": {"videos": [{"url": "https://cdn/v.mp4"}]}}}) monkeypatch.setattr("comfy_cli.command.generate.client.get", fake_get) poller = poll.get_poller("kling") result = poller( {"data": {"task_id": "abc"}}, api_key="comfyui-test", create_path="/proxy/kling/v1/videos/text2video", ) assert captured["url"] == "/proxy/kling/v1/videos/text2video/abc" assert result.status == "succeeded" assert result.image_urls == ["https://cdn/v.mp4"] def test_luma_succeeds(no_sleep, monkeypatch): monkeypatch.setattr( "comfy_cli.command.generate.client.get", _make_runner( [ _resp({"id": "luma-1", "state": "dreaming"}), _resp({"id": "luma-1", "state": "completed", "assets": {"video": "https://cdn/x.mp4"}}), ] ), ) result = poll.get_poller("luma")({"id": "luma-1", "state": "queued"}, api_key="k") assert result.status == "succeeded" assert "https://cdn/x.mp4" in result.image_urls def test_runway_progress_normalized(no_sleep, monkeypatch): """Runway reports progress as 0–1 floats — the poller forwards as-is.""" seen: list[float] = [] monkeypatch.setattr( "comfy_cli.command.generate.client.get", _make_runner( [ _resp({"id": "x", "status": "RUNNING", "progress": 0.5}), _resp({"id": "x", "status": "SUCCEEDED", "output": ["https://cdn/v.mp4"]}), ] ), ) poll.get_poller("runway")({"id": "x"}, api_key="k", on_progress=seen.append) assert seen == [0.5] def test_runway_failure_states(no_sleep, monkeypatch): monkeypatch.setattr( "comfy_cli.command.generate.client.get", _make_runner([_resp({"id": "x", "status": "CANCELLED"})]), ) result = poll.get_poller("runway")({"id": "x"}, api_key="k") assert result.status == "failed" assert "CANCELLED" in (result.error or "") def test_minimax_redeems_file_id(no_sleep, monkeypatch): """After Success, minimax needs a second GET to /files/retrieve to get the download URL.""" monkeypatch.setattr( "comfy_cli.command.generate.client.get", _make_runner( [ _resp({"status": "Processing", "task_id": "t1"}), _resp({"status": "Success", "task_id": "t1", "file_id": "f-42"}), _resp({"file": {"download_url": "https://cdn/minimax.mp4"}}), ] ), ) result = poll.get_poller("minimax")({"task_id": "t1"}, api_key="k") assert result.status == "succeeded" assert "https://cdn/minimax.mp4" in result.image_urls def test_pika_polls_videos_endpoint(no_sleep, monkeypatch): captured = {} def fake_get(url, **kw): captured["url"] = url return _resp({"id": "v1", "status": "finished", "url": "https://cdn/p.mp4"}) monkeypatch.setattr("comfy_cli.command.generate.client.get", fake_get) result = poll.get_poller("pika")({"video_id": "v1"}, api_key="k") assert captured["url"] == "/proxy/pika/videos/v1" assert result.status == "succeeded" def test_vidu_polls_creations_path(no_sleep, monkeypatch): captured = {} def fake_get(url, **kw): captured["url"] = url return _resp({"state": "success", "creations": [{"url": "https://cdn/vidu.mp4"}]}) monkeypatch.setattr("comfy_cli.command.generate.client.get", fake_get) poll.get_poller("vidu")({"task_id": "t1"}, api_key="k") assert captured["url"] == "/proxy/vidu/tasks/t1/creations" def test_xai_video_polls_request_id(no_sleep, monkeypatch): captured = {} def fake_get(url, **kw): captured["url"] = url return _resp({"status": "done", "video": {"url": "https://cdn/x.mp4"}}) monkeypatch.setattr("comfy_cli.command.generate.client.get", fake_get) poll.get_poller("xai_video")({"request_id": "req-1"}, api_key="k") assert captured["url"] == "/proxy/xai/v1/videos/req-1" def test_moonvalley_polls_prompts(no_sleep, monkeypatch): captured = {} def fake_get(url, **kw): captured["url"] = url return _resp({"id": "p-1", "status": "completed", "output_url": "https://cdn/m.mp4"}) monkeypatch.setattr("comfy_cli.command.generate.client.get", fake_get) poll.get_poller("moonvalley")({"id": "p-1"}, api_key="k") assert captured["url"] == "/proxy/moonvalley/prompts/p-1" def test_missing_id_raises(monkeypatch): monkeypatch.setattr("comfy_cli.command.generate.client.get", lambda *a, **kw: _resp({})) with pytest.raises(Exception, match="missing id"): poll.get_poller("kling")({}, api_key="k", create_path="/x") def test_kling_without_create_path_raises(): with pytest.raises(Exception, match="create path"): poll.get_poller("kling")({"data": {"task_id": "abc"}}, api_key="k") def test_build_synthetic_initial_for_each_partner(): """Sanity-check the resume helper for every registered partner.""" for name in ("kling", "luma", "minimax", "runway", "moonvalley", "pika", "vidu", "xai_video"): body = poll.build_synthetic_initial(name, "abc") assert poll.extract_job_id(name, body) == "abc", name def test_build_synthetic_initial_for_bfl(): body = poll.build_synthetic_initial("bfl", "abc", base_url="https://api.comfy.org") assert "polling_url" in body assert "abc" in body["polling_url"] def test_extract_urls_recognizes_video_extensions(): found = poll._extract_urls({"video_url": "https://cdn/x.mp4", "ignore": "https://cdn/notmedia"}) assert "https://cdn/x.mp4" in found assert "https://cdn/notmedia" not in found def test_extract_urls_recognizes_query_strings(): """Signed URLs with ?Expires=… shouldn't be excluded by their query string.""" found = poll._extract_urls({"url": "https://cdn/v.mp4?Expires=123&Signature=abc"}) assert found == ["https://cdn/v.mp4?Expires=123&Signature=abc"] def test_extract_job_id_from_nested_paths(): assert poll.extract_job_id("kling", {"data": {"task_id": "k1"}}) == "k1" assert poll.extract_job_id("luma", {"id": "l1"}) == "l1" assert poll.extract_job_id("minimax", {"task_id": "m1"}) == "m1" assert poll.extract_job_id("xai_video", {"request_id": "x1"}) == "x1" def test_existing_bfl_poller_still_works(no_sleep, monkeypatch): """Regression: the original BFL adapter shouldn't be disturbed by the refactor.""" monkeypatch.setattr( "comfy_cli.command.generate.client.get", _make_runner([_resp({"status": "Ready", "result": {"sample": "https://cdn/b.png"}})]), ) result = poll.get_poller("bfl")({"polling_url": "https://x"}, api_key="k") assert result.status == "succeeded" assert "https://cdn/b.png" in result.image_urls ================================================ FILE: tests/comfy_cli/command/github/test_pr.py ================================================ import subprocess import sys from unittest.mock import Mock, patch import pytest import requests from typer.testing import CliRunner from comfy_cli.cmdline import app, g_exclusivity, g_gpu_exclusivity from comfy_cli.command import install as install_module from comfy_cli.command.install import ( GitHubRateLimitError, PRInfo, _parse_github_owner_repo, _resolve_latest_tag_from_local, checkout_stable_comfyui, fetch_pr_info, find_pr_by_branch, get_latest_release, handle_github_rate_limit, handle_pr_checkout, parse_pr_reference, ) from comfy_cli.git_utils import checkout_pr, git_checkout_tag @pytest.fixture(scope="function") def runner(): g_exclusivity.reset_for_testing() g_gpu_exclusivity.reset_for_testing() return CliRunner() @pytest.fixture def sample_pr_info(): return PRInfo( number=123, head_repo_url="https://github.com/jtydhr88/ComfyUI.git", head_branch="load-3d-nodes", base_repo_url="https://github.com/comfyanonymous/ComfyUI.git", base_branch="master", title="Add 3D node loading support", user="jtydhr88", mergeable=True, ) class TestPRReferenceParsing: def test_parse_pr_number_format(self): """Test parsing #123 format""" repo_owner, repo_name, pr_number = parse_pr_reference("#123") assert repo_owner == "comfyanonymous" assert repo_name == "ComfyUI" assert pr_number == 123 def test_parse_user_branch_format(self): """Test parsing username:branch format""" repo_owner, repo_name, pr_number = parse_pr_reference("jtydhr88:load-3d-nodes") assert repo_owner == "jtydhr88" assert repo_name == "ComfyUI" assert pr_number is None def test_parse_github_url_format(self): """Test parsing full GitHub PR URL""" url = "https://github.com/comfyanonymous/ComfyUI/pull/456" repo_owner, repo_name, pr_number = parse_pr_reference(url) assert repo_owner == "comfyanonymous" assert repo_name == "ComfyUI" assert pr_number == 456 def test_parse_invalid_format(self): """Test parsing invalid format raises ValueError""" with pytest.raises(ValueError, match="Invalid PR reference format"): parse_pr_reference("invalid-format") def test_parse_empty_string(self): """Test parsing empty string raises ValueError""" with pytest.raises(ValueError): parse_pr_reference("") class TestGitHubAPIIntegration: """Test GitHub API integration""" @patch("requests.get") def test_fetch_pr_info_success(self, mock_get, sample_pr_info): """Test successful PR info fetching""" # Mock API response mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = { "number": 123, "title": "Add 3D node loading support", "head": { "repo": {"clone_url": "https://github.com/jtydhr88/ComfyUI.git", "owner": {"login": "jtydhr88"}}, "ref": "load-3d-nodes", }, "base": {"repo": {"clone_url": "https://github.com/comfyanonymous/ComfyUI.git"}, "ref": "master"}, "mergeable": True, } mock_get.return_value = mock_response result = fetch_pr_info("comfyanonymous", "ComfyUI", 123) assert result.number == 123 assert result.title == "Add 3D node loading support" assert result.user == "jtydhr88" assert result.head_branch == "load-3d-nodes" assert result.mergeable is True @patch("requests.get") def test_fetch_pr_info_not_found(self, mock_get): """Test PR not found (404)""" mock_response = Mock() mock_response.status_code = 404 mock_response.raise_for_status.side_effect = requests.HTTPError("404 Not Found") mock_get.return_value = mock_response with pytest.raises(Exception, match="Failed to fetch PR"): fetch_pr_info("comfyanonymous", "ComfyUI", 999) @patch("requests.get") def test_fetch_pr_info_rate_limit(self, mock_get): """Test GitHub API rate limit handling""" mock_response = Mock() mock_response.status_code = 403 mock_response.headers = {"x-ratelimit-remaining": "0"} mock_get.return_value = mock_response with pytest.raises(Exception, match="Primary rate limit from Github exceeded!"): fetch_pr_info("comfyanonymous", "ComfyUI", 123) @patch("requests.get") def test_find_pr_by_branch_success(self, mock_get): """Test successful PR search by branch""" mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = [ { "number": 456, "title": "Test PR", "head": { "repo": {"clone_url": "https://github.com/testuser/ComfyUI.git", "owner": {"login": "testuser"}}, "ref": "test-branch", }, "base": {"repo": {"clone_url": "https://github.com/comfyanonymous/ComfyUI.git"}, "ref": "master"}, "mergeable": True, } ] mock_get.return_value = mock_response result = find_pr_by_branch("comfyanonymous", "ComfyUI", "testuser", "test-branch") assert result is not None assert result.number == 456 assert result.title == "Test PR" assert result.user == "testuser" assert result.head_branch == "test-branch" @patch("requests.get") def test_find_pr_by_branch_not_found(self, mock_get): """Test PR not found by branch""" mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = [] mock_get.return_value = mock_response result = find_pr_by_branch("comfyanonymous", "ComfyUI", "testuser", "nonexistent-branch") assert result is None @patch("requests.get") def test_find_pr_by_branch_error(self, mock_get): """Test error when searching PR by branch""" mock_get.side_effect = requests.RequestException("Network error") result = find_pr_by_branch("comfyanonymous", "ComfyUI", "testuser", "test-branch") assert result is None class TestGitOperations: """Test Git operations for PR checkout""" @patch("subprocess.run") @patch("os.chdir") @patch("os.getcwd") def test_checkout_pr_fork_success(self, mock_getcwd, mock_chdir, mock_subprocess, sample_pr_info): """Test successful checkout of PR from fork""" mock_getcwd.return_value = "/original/dir" mock_subprocess.side_effect = [ subprocess.CompletedProcess([], 1), subprocess.CompletedProcess([], 0), subprocess.CompletedProcess([], 0), subprocess.CompletedProcess([], 0), ] result = checkout_pr("/repo/path", sample_pr_info) assert result is True assert mock_subprocess.call_count == 4 calls = mock_subprocess.call_args_list assert "git" in calls[0][0][0] assert "remote" in calls[1][0][0] assert "fetch" in calls[2][0][0] assert "checkout" in calls[3][0][0] @patch("subprocess.run") @patch("os.chdir") @patch("os.getcwd") def test_checkout_pr_non_fork_success(self, mock_getcwd, mock_chdir, mock_subprocess): """Test successful checkout of PR from same repo""" mock_getcwd.return_value = "/original/dir" pr_info = PRInfo( number=123, head_repo_url="https://github.com/comfyanonymous/ComfyUI.git", head_branch="feature-branch", base_repo_url="https://github.com/comfyanonymous/ComfyUI.git", base_branch="master", title="Feature branch", user="comfyanonymous", mergeable=True, ) mock_subprocess.side_effect = [ subprocess.CompletedProcess([], 0), # fetch succeeds subprocess.CompletedProcess([], 0), # checkout succeeds ] result = checkout_pr("/repo/path", pr_info) assert result is True assert mock_subprocess.call_count == 2 @patch("subprocess.run") @patch("os.chdir") @patch("os.getcwd") def test_checkout_pr_git_failure(self, mock_getcwd, mock_chdir, mock_subprocess, sample_pr_info): """Test Git operation failure""" mock_getcwd.return_value = "/original/dir" error = subprocess.CalledProcessError(1, "git", stderr="Permission denied") mock_subprocess.side_effect = error result = checkout_pr("/repo/path", sample_pr_info) assert result is False class TestGitCheckoutTag: """Cover ``git_checkout_tag``'s skip-fetch-when-tag-is-local behavior. The fetch is intentionally avoided when the tag already exists in the local clone, both to skip a redundant network round-trip on the happy path and to let offline installs succeed when the caller (e.g. the `--version latest` resolver) already validated a cached tag. """ @staticmethod def _init_repo(path): subprocess.run(["git", "init", "-q", str(path)], check=True) subprocess.run(["git", "-C", str(path), "config", "user.email", "x@x"], check=True) subprocess.run(["git", "-C", str(path), "config", "user.name", "x"], check=True) subprocess.run( ["git", "-C", str(path), "commit", "--allow-empty", "-m", "init", "-q"], check=True, ) def test_succeeds_offline_when_tag_already_local(self, tmp_path): """The bug: cached-tag offline path must not crash on the redundant fetch. Repro: tag exists locally + origin is unreachable. Old code would call `git fetch --tags` with check=True and fail; new code skips the fetch because the tag is already present. """ self._init_repo(tmp_path) subprocess.run(["git", "-C", str(tmp_path), "tag", "v0.20.1"], check=True) # Point origin at an unreachable path so any fetch attempt would fail. subprocess.run( ["git", "-C", str(tmp_path), "remote", "add", "origin", "file:///nonexistent-repo-path-for-test"], check=True, ) result = git_checkout_tag(str(tmp_path), "v0.20.1") assert result is True # HEAD really moved to the tag head = subprocess.run( ["git", "-C", str(tmp_path), "describe", "--tags", "--exact-match", "HEAD"], capture_output=True, text=True, check=True, ) assert head.stdout.strip() == "v0.20.1" def test_fetches_when_tag_missing_locally(self, tmp_path): """When the tag isn't local we must still fetch — and an unreachable remote is then a real, surfaced error (not silently swallowed).""" self._init_repo(tmp_path) # Tag is NOT created locally subprocess.run( ["git", "-C", str(tmp_path), "remote", "add", "origin", "file:///nonexistent-repo-path-for-test"], check=True, ) result = git_checkout_tag(str(tmp_path), "v0.20.1") assert result is False # fetch failed, surfaced as a checkout failure class TestHandlePRCheckout: """Test the main PR checkout handler""" @patch("comfy_cli.command.install.parse_pr_reference") @patch("comfy_cli.command.install.fetch_pr_info") @patch("comfy_cli.command.install.checkout_pr") @patch("comfy_cli.command.install.clone_comfyui") @patch("comfy_cli.ui.prompt_confirm_action") @patch("os.path.exists") @patch("os.makedirs") def test_handle_pr_checkout_success( self, mock_makedirs, mock_exists, mock_confirm, mock_clone, mock_checkout, mock_fetch, mock_parse, sample_pr_info, ): """Test successful PR checkout handling""" mock_parse.return_value = ("jtydhr88", "ComfyUI", 123) mock_fetch.return_value = sample_pr_info mock_exists.side_effect = [True, False] # Parent exists, repo doesn't mock_confirm.return_value = True mock_checkout.return_value = True with patch("comfy_cli.command.install.workspace_manager") as mock_ws: mock_ws.skip_prompting = False result = handle_pr_checkout("jtydhr88:load-3d-nodes", "/path/to/comfy") assert result == "https://github.com/comfyanonymous/ComfyUI.git" mock_clone.assert_called_once() mock_checkout.assert_called_once() class TestCommandLineIntegration: """Test command line integration""" @patch("comfy_cli.command.install.execute") def test_install_with_pr_parameter(self, mock_execute, runner): """Test install command with --pr parameter""" result = runner.invoke(app, ["install", "--pr", "jtydhr88:load-3d-nodes", "--nvidia", "--skip-prompt"]) assert "Invalid PR reference format" not in result.stdout if mock_execute.called: call_args = mock_execute.call_args assert "pr" in call_args.kwargs or len(call_args.args) > 8 def test_pr_and_version_conflict(self, runner): """Test that --pr conflicts with --version""" result = runner.invoke(app, ["install", "--pr", "#123", "--version", "1.0.0"]) assert result.exit_code != 0 def test_pr_and_commit_conflict(self, runner): """Test that --pr conflicts with --commit""" result = runner.invoke(app, ["install", "--pr", "#123", "--version", "nightly", "--commit", "abc123"]) assert result.exit_code != 0 @patch("comfy_cli.command.install.execute") @patch("comfy_cli.cmdline.check_comfy_repo", return_value=(False, None)) @patch("comfy_cli.cmdline.workspace_manager") @patch("comfy_cli.tracking.prompt_tracking_consent") def test_commit_without_pr_does_not_conflict(self, mock_track, mock_ws, mock_check, mock_execute, runner): """Test that --commit alone does not trigger --pr conflict error (issue #335)""" mock_ws.get_workspace_path.return_value = ("/tmp/test", None) result = runner.invoke( app, ["--skip-prompt", "install", "--version", "nightly", "--commit", "abc123", "--nvidia"] ) assert "--pr cannot be used" not in result.stdout assert mock_execute.called @patch("comfy_cli.command.install.execute") @patch("comfy_cli.cmdline.check_comfy_repo", return_value=(False, None)) @patch("comfy_cli.cmdline.workspace_manager") @patch("comfy_cli.tracking.prompt_tracking_consent") def test_cpu_pr_conflict_with_version(self, mock_track, mock_ws, mock_check, mock_execute, runner): """Test that --cpu --pr with --version is rejected""" mock_ws.get_workspace_path.return_value = ("/tmp/test", None) result = runner.invoke(app, ["--skip-prompt", "install", "--cpu", "--pr", "#123", "--version", "1.0.0"]) assert result.exit_code != 0 assert "--pr cannot be used" in result.stdout assert not mock_execute.called @patch("comfy_cli.command.install.execute") @patch("comfy_cli.cmdline.check_comfy_repo", return_value=(False, None)) @patch("comfy_cli.cmdline.workspace_manager") @patch("comfy_cli.tracking.prompt_tracking_consent") def test_cpu_pr_conflict_with_commit(self, mock_track, mock_ws, mock_check, mock_execute, runner): """Test that --cpu --pr with --commit is rejected""" mock_ws.get_workspace_path.return_value = ("/tmp/test", None) result = runner.invoke( app, ["--skip-prompt", "install", "--cpu", "--pr", "#123", "--version", "nightly", "--commit", "abc123"] ) assert result.exit_code != 0 assert "--pr cannot be used" in result.stdout assert not mock_execute.called @patch("comfy_cli.command.install.execute") @patch("comfy_cli.cmdline.check_comfy_repo", return_value=(False, None)) @patch("comfy_cli.cmdline.workspace_manager") @patch("comfy_cli.tracking.prompt_tracking_consent") def test_cpu_pr_passes_pr_to_execute(self, mock_track, mock_ws, mock_check, mock_execute, runner): """Test that --cpu --pr passes pr parameter to install_inner.execute""" mock_ws.get_workspace_path.return_value = ("/tmp/test", None) runner.invoke(app, ["--skip-prompt", "install", "--cpu", "--pr", "#123"]) assert mock_execute.called call_kwargs = mock_execute.call_args.kwargs assert call_kwargs.get("pr") == "#123" class TestPRInfoDataClass: """Test PRInfo data class""" def test_pr_info_is_fork_true(self): """Test is_fork property returns True for fork""" pr_info = PRInfo( number=123, head_repo_url="https://github.com/user/ComfyUI.git", head_branch="branch", base_repo_url="https://github.com/comfyanonymous/ComfyUI.git", base_branch="master", title="Title", user="user", mergeable=True, ) assert pr_info.is_fork is True def test_pr_info_is_fork_false(self): """Test is_fork property returns False for same repo""" pr_info = PRInfo( number=123, head_repo_url="https://github.com/comfyanonymous/ComfyUI.git", head_branch="feature", base_repo_url="https://github.com/comfyanonymous/ComfyUI.git", base_branch="master", title="Title", user="comfyanonymous", mergeable=True, ) assert pr_info.is_fork is False class TestEdgeCases: """Test edge cases and error conditions""" def test_parse_pr_reference_whitespace(self): """Test parsing with whitespace""" repo_owner, repo_name, pr_number = parse_pr_reference(" #123 ") assert repo_owner == "comfyanonymous" assert repo_name == "ComfyUI" assert pr_number == 123 @patch("requests.get") def test_fetch_pr_info_with_github_token(self, mock_get): """Test PR fetching with GitHub token""" mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = { "number": 123, "title": "Test", "head": {"repo": {"clone_url": "url", "owner": {"login": "user"}}, "ref": "branch"}, "base": {"repo": {"clone_url": "base_url"}, "ref": "master"}, "mergeable": True, } mock_get.return_value = mock_response with patch.dict("os.environ", {"GITHUB_TOKEN": "test-token"}): fetch_pr_info("owner", "repo", 123) call_args = mock_get.call_args headers = call_args.kwargs.get("headers", {}) assert "Authorization" in headers assert headers["Authorization"] == "Bearer test-token" @patch("subprocess.run") @patch("os.chdir") @patch("os.getcwd") def test_checkout_pr_remote_already_exists(self, mock_getcwd, mock_chdir, mock_subprocess, sample_pr_info): """Test checkout when remote already exists""" mock_getcwd.return_value = "/dir" mock_subprocess.side_effect = [ subprocess.CompletedProcess([], 0), subprocess.CompletedProcess([], 0), subprocess.CompletedProcess([], 0), ] result = checkout_pr("/repo", sample_pr_info) assert result is True assert mock_subprocess.call_count == 3 class TestGetLatestRelease: """Test get_latest_release GitHub API calls""" @patch("requests.get") def test_sends_auth_header_when_token_set(self, mock_get): """Ensure GITHUB_TOKEN is sent as Bearer auth to avoid rate limits (issue #425)""" mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = { "tag_name": "v0.18.2", "zipball_url": "https://github.com/comfyanonymous/ComfyUI/archive/v0.18.2.zip", } mock_get.return_value = mock_response with patch.dict("os.environ", {"GITHUB_TOKEN": "ghp_test123"}): result = get_latest_release("comfyanonymous", "ComfyUI") headers = mock_get.call_args.kwargs.get("headers", {}) assert headers["Authorization"] == "Bearer ghp_test123" assert result is not None assert result["tag"] == "v0.18.2" @patch("requests.get") def test_no_auth_header_without_token(self, mock_get): """Without GITHUB_TOKEN the request has no Authorization header""" mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = { "tag_name": "v0.18.2", "zipball_url": "https://github.com/comfyanonymous/ComfyUI/archive/v0.18.2.zip", } mock_get.return_value = mock_response with patch.dict("os.environ", {}, clear=True): get_latest_release("comfyanonymous", "ComfyUI") headers = mock_get.call_args.kwargs.get("headers", {}) assert "Authorization" not in headers @patch("requests.get") def test_rate_limit_raises_error(self, mock_get): """A 403 with exhausted rate limit raises GitHubRateLimitError""" mock_response = Mock() mock_response.status_code = 403 mock_response.headers = {"x-ratelimit-remaining": "0", "x-ratelimit-reset": "1700000000"} mock_get.return_value = mock_response with pytest.raises(GitHubRateLimitError): get_latest_release("comfyanonymous", "ComfyUI") @patch("requests.get") def test_non_semver_tag_returns_release_with_version_none(self, mock_get): """Forks may use non-semver tags (e.g. `release-2026-04`); the parser must not crash — caller only needs the raw tag string for checkout.""" mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = { "tag_name": "release-2026-04", "zipball_url": "https://example/zip", } mock_get.return_value = mock_response result = get_latest_release("some-fork", "ComfyUI") assert result is not None assert result["tag"] == "release-2026-04" assert result["version"] is None class TestHandleGithubRateLimit: def test_primary_rate_limit_message_format(self): """Verify the error message does not contain stray characters.""" mock_response = Mock() mock_response.headers = {"x-ratelimit-remaining": "0", "x-ratelimit-reset": "1700000000"} with pytest.raises(GitHubRateLimitError) as exc_info: handle_github_rate_limit(mock_response) msg = str(exc_info.value) assert "1700000000" in msg assert msg.endswith("1700000000") # no stray trailing characters def test_retry_after_header(self): mock_response = Mock() mock_response.headers = {"x-ratelimit-remaining": "5", "retry-after": "30"} with pytest.raises(GitHubRateLimitError, match="30 seconds"): handle_github_rate_limit(mock_response) def test_no_rate_limit_does_not_raise(self): mock_response = Mock() mock_response.headers = {"x-ratelimit-remaining": "100"} handle_github_rate_limit(mock_response) # should not raise class TestResolveLatestTagFromLocal: """Cover the local-tag resolver added for issue #440 — `--version latest` must not require a GitHub API hit when tags are already on disk.""" @staticmethod def _init_repo(path): subprocess.run(["git", "init", "-q", str(path)], check=True) subprocess.run(["git", "-C", str(path), "config", "user.email", "x@x"], check=True) subprocess.run(["git", "-C", str(path), "config", "user.name", "x"], check=True) subprocess.run( ["git", "-C", str(path), "commit", "--allow-empty", "-m", "init", "-q"], check=True, ) @classmethod def _make_repo(cls, path, tags): cls._init_repo(path) for tag in tags: subprocess.run(["git", "-C", str(path), "tag", tag], check=True) def test_picks_highest_stable_semver(self, tmp_path): self._make_repo(tmp_path, ["v0.19.5", "v0.20.0", "v0.20.1", "v0.18.2"]) tag, _fetch_ok = _resolve_latest_tag_from_local(str(tmp_path)) assert tag == "v0.20.1" def test_skips_pre_release_tags(self, tmp_path): """GitHub's releases/latest excludes pre-releases; we mirror that.""" self._make_repo(tmp_path, ["v0.20.0", "v0.20.1", "v0.21.0-rc1", "v0.21.0-beta.1"]) tag, _ = _resolve_latest_tag_from_local(str(tmp_path)) assert tag == "v0.20.1" def test_skips_non_semver_tags(self, tmp_path): self._make_repo(tmp_path, ["v0.20.1", "release-foo", "nightly", "weird/slash"]) tag, _ = _resolve_latest_tag_from_local(str(tmp_path)) assert tag == "v0.20.1" def test_returns_none_when_no_tags(self, tmp_path): self._init_repo(tmp_path) tag, _ = _resolve_latest_tag_from_local(str(tmp_path)) assert tag is None def test_returns_none_when_only_prereleases(self, tmp_path): self._make_repo(tmp_path, ["v1.0.0-rc1", "v1.0.0-beta"]) tag, _ = _resolve_latest_tag_from_local(str(tmp_path)) assert tag is None def test_returns_none_when_only_non_semver(self, tmp_path): self._make_repo(tmp_path, ["main", "release-foo", "nightly"]) tag, _ = _resolve_latest_tag_from_local(str(tmp_path)) assert tag is None def test_returns_none_for_non_git_directory(self, tmp_path): tag, fetch_ok = _resolve_latest_tag_from_local(str(tmp_path)) assert tag is None assert fetch_ok is False def test_tolerates_fetch_exception(self, tmp_path): """Fetch may raise (timeout, OSError) — resolver should still use local tags.""" self._make_repo(tmp_path, ["v0.20.1"]) real_run = subprocess.run def flaky(args, **kwargs): if len(args) >= 4 and args[3] == "fetch": raise subprocess.SubprocessError("simulated network failure") return real_run(args, **kwargs) with patch("comfy_cli.command.install.subprocess.run", side_effect=flaky): tag, fetch_ok = _resolve_latest_tag_from_local(str(tmp_path)) assert tag == "v0.20.1" assert fetch_ok is False def test_tolerates_fetch_nonzero_exit(self, tmp_path): """Fetch may exit non-zero without raising (auth, network, bad remote). Without ``check=True`` subprocess.run silently returns a non-zero CompletedProcess. The resolver should still produce tags from disk and report ``fetch_ok=False`` so the caller can warn the user. """ self._make_repo(tmp_path, ["v0.20.0", "v0.20.1"]) # Point origin at a path that doesn't exist → fetch exits 128 without raising in Python subprocess.run( ["git", "-C", str(tmp_path), "remote", "add", "origin", "file:///nonexistent-repo-path-for-test"], check=True, ) tag, fetch_ok = _resolve_latest_tag_from_local(str(tmp_path)) assert tag == "v0.20.1" assert fetch_ok is False def test_tag_with_v_prefix_normalized(self, tmp_path): """Tags may be present with or without the leading 'v'; the higher stable wins.""" self._make_repo(tmp_path, ["v0.20.0", "0.20.1"]) tag, _ = _resolve_latest_tag_from_local(str(tmp_path)) assert tag == "0.20.1" class TestParseGithubOwnerRepo: """Cover the URL parser used by the API fallback to query the same repo we cloned from (forks included), instead of always asking upstream.""" @pytest.mark.parametrize( "url,expected", [ # The default URL the install command uses ("https://github.com/comfyanonymous/ComfyUI", ("comfyanonymous", "ComfyUI")), # With .git suffix ("https://github.com/comfyanonymous/ComfyUI.git", ("comfyanonymous", "ComfyUI")), # With trailing slash ("https://github.com/comfyanonymous/ComfyUI/", ("comfyanonymous", "ComfyUI")), # setuptools-style @branch suffix that clone_comfyui supports ("https://github.com/comfyanonymous/ComfyUI@master", ("comfyanonymous", "ComfyUI")), ("https://github.com/comfyanonymous/ComfyUI.git@release/1.0", ("comfyanonymous", "ComfyUI")), # Forks ("https://github.com/myfork/ComfyUI", ("myfork", "ComfyUI")), ("https://github.com/some-user/some-repo.git", ("some-user", "some-repo")), # SSH forms ("git@github.com:comfyanonymous/ComfyUI", ("comfyanonymous", "ComfyUI")), ("git@github.com:comfyanonymous/ComfyUI.git", ("comfyanonymous", "ComfyUI")), ], ) def test_parses_github_urls(self, url, expected): assert _parse_github_owner_repo(url) == expected @pytest.mark.parametrize( "url", [ None, "", "/local/path/to/comfyui", # local path "https://gitlab.com/foo/bar", # not GitHub "https://example.com/owner/repo", # not GitHub "https://github.com/owner/repo/pull/123", # not a repo URL "ftp://github.com/owner/repo", # exotic scheme — still parses since regex matches `github.com/...` ], ) def test_returns_none_for_non_github_urls(self, url): # The PR URL form (last case) intentionally doesn't match — `[^/@]+?` excludes `/` # so `repo/pull/123` cannot be the second capture; we want this to fall through # to the upstream default in the caller. if url == "ftp://github.com/owner/repo": # Edge-case: this DOES match because we don't anchor on the scheme. # That's fine — owner/repo is what matters; the API call uses HTTPS regardless. assert _parse_github_owner_repo(url) == ("owner", "repo") else: assert _parse_github_owner_repo(url) is None class TestCheckoutStableComfyUI: """Verify checkout_stable_comfyui prefers local tag resolution over the GitHub API for `--version latest` (issue #440), and falls back when local resolution fails.""" @patch("comfy_cli.command.install.git_checkout_tag", return_value=True) @patch("comfy_cli.command.install.get_latest_release") @patch("comfy_cli.command.install._resolve_latest_tag_from_local", return_value=("v0.20.1", True)) def test_latest_uses_local_tag_no_api_call(self, mock_local, mock_api, mock_co): """When local tags resolve, the API is never consulted.""" checkout_stable_comfyui("latest", "/repo") mock_local.assert_called_once_with("/repo") mock_api.assert_not_called() mock_co.assert_called_once_with("/repo", "v0.20.1") @patch("comfy_cli.command.install.git_checkout_tag", return_value=True) @patch("comfy_cli.command.install.get_latest_release") @patch("comfy_cli.command.install._resolve_latest_tag_from_local", return_value=("v0.20.1", False)) def test_latest_warns_on_stale_tag_when_fetch_failed(self, mock_local, mock_api, mock_co, capsys): """Fetch failed but a tag was found locally → warn the user it may be stale. Old behavior was to hard-fail via the API path; new behavior succeeds with whatever's on disk. Without this warning the user has no way to tell the clone is stale. """ checkout_stable_comfyui("latest", "/repo") captured = capsys.readouterr() assert "could not refresh tags from remote" in captured.out assert "v0.20.1" in captured.out # Still uses the cached tag, no API call mock_api.assert_not_called() mock_co.assert_called_once_with("/repo", "v0.20.1") @patch("comfy_cli.command.install.git_checkout_tag", return_value=True) @patch("comfy_cli.command.install.get_latest_release") @patch("comfy_cli.command.install._resolve_latest_tag_from_local", return_value=("v0.20.1", True)) def test_latest_no_warning_when_fetch_succeeded(self, mock_local, mock_api, mock_co, capsys): """Happy path: fetch_ok=True → no stale-tag warning, quiet success.""" checkout_stable_comfyui("latest", "/repo") captured = capsys.readouterr() assert "could not refresh tags" not in captured.out assert "querying GitHub API" not in captured.out @patch("comfy_cli.command.install.git_checkout_tag", return_value=True) @patch("comfy_cli.command.install.get_latest_release") @patch("comfy_cli.command.install._resolve_latest_tag_from_local", return_value=(None, True)) def test_latest_falls_back_to_api_when_local_empty(self, mock_local, mock_api, mock_co): """Fetch succeeded but the repo has no stable tags → API fallback runs.""" mock_api.return_value = {"tag": "v0.20.1", "version": None, "download_url": "u"} checkout_stable_comfyui("latest", "/repo") mock_local.assert_called_once_with("/repo") mock_api.assert_called_once_with("comfyanonymous", "ComfyUI") mock_co.assert_called_once_with("/repo", "v0.20.1") @patch("comfy_cli.command.install.git_checkout_tag", return_value=True) @patch("comfy_cli.command.install.get_latest_release") @patch("comfy_cli.command.install._resolve_latest_tag_from_local", return_value=(None, True)) def test_latest_fallback_uses_fork_owner_repo_from_url(self, mock_local, mock_api, mock_co): """Fork case: API fallback must query the FORK's releases/latest, not upstream's. Otherwise we'd ask GitHub for `comfyanonymous/ComfyUI`'s latest tag and try to check it out in a fork that may not have it. """ mock_api.return_value = {"tag": "v0.20.1-myfork", "version": None, "download_url": "u"} checkout_stable_comfyui("latest", "/repo", url="https://github.com/myfork/ComfyUI") mock_api.assert_called_once_with("myfork", "ComfyUI") mock_co.assert_called_once_with("/repo", "v0.20.1-myfork") @patch("comfy_cli.command.install.git_checkout_tag", return_value=True) @patch("comfy_cli.command.install.get_latest_release") @patch("comfy_cli.command.install._resolve_latest_tag_from_local", return_value=(None, True)) def test_latest_fallback_strips_branch_suffix_from_url(self, mock_local, mock_api, mock_co): """The setuptools-style `@branch` suffix in the install URL must not leak into the API call. `clone_comfyui` already strips it before cloning.""" mock_api.return_value = {"tag": "v0.20.1", "version": None, "download_url": "u"} checkout_stable_comfyui("latest", "/repo", url="https://github.com/myfork/ComfyUI.git@some-branch") mock_api.assert_called_once_with("myfork", "ComfyUI") @patch("comfy_cli.command.install.git_checkout_tag", return_value=True) @patch("comfy_cli.command.install.get_latest_release") @patch("comfy_cli.command.install._resolve_latest_tag_from_local", return_value=(None, True)) def test_latest_fallback_defaults_to_upstream_for_non_github_url(self, mock_local, mock_api, mock_co): """Non-GitHub URLs (local paths, GitLab, etc.) fall back to upstream defaults — preserves prior behavior for users whose URL we can't parse.""" mock_api.return_value = {"tag": "v0.20.1", "version": None, "download_url": "u"} checkout_stable_comfyui("latest", "/repo", url="/local/path/to/comfyui") mock_api.assert_called_once_with("comfyanonymous", "ComfyUI") @patch("comfy_cli.command.install.git_checkout_tag", return_value=True) @patch("comfy_cli.command.install.get_latest_release") @patch("comfy_cli.command.install._resolve_latest_tag_from_local", return_value=(None, True)) def test_latest_fallback_defaults_to_upstream_when_url_omitted(self, mock_local, mock_api, mock_co): """Backward compat: omitting the new `url` kwarg yields the prior behavior (querying upstream).""" mock_api.return_value = {"tag": "v0.20.1", "version": None, "download_url": "u"} checkout_stable_comfyui("latest", "/repo") # no url= mock_api.assert_called_once_with("comfyanonymous", "ComfyUI") @patch("comfy_cli.command.install.git_checkout_tag", return_value=True) @patch("comfy_cli.command.install.get_latest_release") @patch("comfy_cli.command.install._resolve_latest_tag_from_local", return_value=(None, False)) def test_latest_warns_when_fetch_failed_before_api_fallback(self, mock_local, mock_api, mock_co, capsys): """When fetch failed AND local has no tags, surface the fetch failure so the user understands why we're falling back to the API.""" mock_api.return_value = {"tag": "v0.20.1", "version": None, "download_url": "u"} checkout_stable_comfyui("latest", "/repo") captured = capsys.readouterr() assert "Could not refresh tags from the remote" in captured.out # Sanity: didn't print the wrong (success-fetch) branch assert "No stable release tags found locally" not in captured.out @patch("comfy_cli.command.install.git_checkout_tag", return_value=True) @patch("comfy_cli.command.install.get_latest_release", return_value=None) @patch("comfy_cli.command.install._resolve_latest_tag_from_local", return_value=(None, True)) def test_latest_exits_when_both_local_and_api_fail(self, mock_local, mock_api, mock_co): with pytest.raises(SystemExit): checkout_stable_comfyui("latest", "/repo") mock_co.assert_not_called() @patch("comfy_cli.command.install.git_checkout_tag", return_value=True) @patch("comfy_cli.command.install.get_latest_release") @patch("comfy_cli.command.install._resolve_latest_tag_from_local") def test_specific_version_skips_both_local_and_api(self, mock_local, mock_api, mock_co): """`--version 0.20.1` must not consult the API or the local resolver.""" checkout_stable_comfyui("0.20.1", "/repo") mock_local.assert_not_called() mock_api.assert_not_called() mock_co.assert_called_once_with("/repo", "v0.20.1") @patch("comfy_cli.command.install.git_checkout_tag", return_value=True) @patch("comfy_cli.command.install.get_latest_release") @patch("comfy_cli.command.install._resolve_latest_tag_from_local") def test_specific_version_with_v_prefix_passes_through(self, mock_local, mock_api, mock_co): checkout_stable_comfyui("v0.20.1", "/repo") mock_local.assert_not_called() mock_api.assert_not_called() mock_co.assert_called_once_with("/repo", "v0.20.1") @patch("comfy_cli.command.install.requests.get") @patch("comfy_cli.command.install.git_checkout_tag", return_value=True) def test_latest_with_rate_limited_api_when_no_local_tags(self, mock_co, mock_get, tmp_path): """End-to-end repro of issue #440: empty local clone + 60/hr exhausted IP. With no local tags, the resolver returns None and the API path runs; a 403 there must surface as GitHubRateLimitError exactly as before. """ # Real but tag-less git repo subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) rate_limited = Mock() rate_limited.status_code = 403 rate_limited.headers = {"x-ratelimit-remaining": "0", "x-ratelimit-reset": "1777415867"} mock_get.return_value = rate_limited with patch.dict("os.environ", {}, clear=True): with pytest.raises(GitHubRateLimitError, match="1777415867"): checkout_stable_comfyui("latest", str(tmp_path)) mock_co.assert_not_called() @patch("comfy_cli.command.install.requests.get") @patch("comfy_cli.command.install.git_checkout_tag", return_value=True) def test_latest_with_local_tags_no_network_at_all(self, mock_co, mock_get, tmp_path): """The pre-fix repro of issue #440: with local tags present, no GitHub API call should be made even when the network is hostile.""" TestResolveLatestTagFromLocal._make_repo(tmp_path, ["v0.19.5", "v0.20.0", "v0.20.1"]) with patch.dict("os.environ", {}, clear=True): checkout_stable_comfyui("latest", str(tmp_path)) # Resolved locally; never touched the API assert mock_get.call_count == 0 mock_co.assert_called_once_with(str(tmp_path), "v0.20.1") class TestInstallExecuteWithLatest: """Integration test for the FULL `install.execute()` flow with `--version latest`. Uses a real (synthetic) git repo on disk so `clone_comfyui`, `_resolve_latest_tag_from_local`, and `git_checkout_tag` actually run. The slow pip / venv steps are mocked. Most importantly, ``requests.get`` inside ``install`` is wired to **raise** if invoked — so any future refactor that puts a GitHub API call back on the happy path of ``--version latest`` will fail this test loudly. This is the regression net the unit tests can't provide: it proves the clone-then-resolve-then-checkout ordering survives changes to ``execute()``. """ @staticmethod def _make_comfy_repo(path): """Build a tag-bearing git repo at `path` that mimics ComfyUI's pattern. Each tag points at its own commit so ``git describe --exact-match HEAD`` is unambiguous after checkout. """ subprocess.run(["git", "init", "-q", str(path)], check=True) subprocess.run(["git", "-C", str(path), "config", "user.email", "x@x"], check=True) subprocess.run(["git", "-C", str(path), "config", "user.name", "x"], check=True) for tag in ["v0.18.2", "v0.19.5", "v0.20.0", "v0.20.1", "v0.21.0-rc1"]: subprocess.run( ["git", "-C", str(path), "commit", "--allow-empty", "-m", f"release {tag}", "-q"], check=True, ) subprocess.run(["git", "-C", str(path), "tag", tag], check=True) def test_full_execute_resolves_latest_locally_no_api_call(self, tmp_path, capsys): repo_dir = tmp_path / "ComfyUI" self._make_comfy_repo(repo_dir) api_calls = [] def crash_on_api(*args, **kwargs): api_calls.append(("requests.get", args, kwargs)) raise AssertionError( "Regression: install.execute('--version latest') made an unexpected " f"GitHub API call: args={args}, kwargs={kwargs}" ) with ( patch.dict("os.environ", {}, clear=True), patch("comfy_cli.command.install.requests.get", side_effect=crash_on_api), patch("comfy_cli.command.install.clone_comfyui") as mock_clone, patch("comfy_cli.command.install.ensure_workspace_python", return_value=sys.executable), patch("comfy_cli.command.install.pip_install_comfyui_dependencies"), patch("comfy_cli.command.install.update_node_id_cache"), patch.object(install_module.workspace_manager, "skip_prompting", True), patch.object(install_module.workspace_manager, "setup_workspace_manager"), patch("comfy_cli.command.install.WorkspaceManager") as mock_ws_class, patch("comfy_cli.config_manager.ConfigManager") as mock_cfg_class, ): mock_ws_class.return_value = Mock() mock_cfg_class.return_value = Mock() install_module.execute( url="https://github.com/comfyanonymous/ComfyUI", comfy_path=str(repo_dir), restore=False, skip_manager=True, version="latest", ) # The core regression assertions: assert api_calls == [], "GitHub API was called on the --version latest happy path" mock_clone.assert_not_called() # repo already exists at comfy_path # The right tag actually got checked out by the real git_checkout_tag call head = subprocess.run( ["git", "-C", str(repo_dir), "describe", "--tags", "--exact-match", "HEAD"], capture_output=True, text=True, check=True, ) assert head.stdout.strip() == "v0.20.1", ( f"Expected HEAD at v0.20.1 (highest stable tag), got: {head.stdout.strip()!r}" ) def test_full_execute_with_specific_version_no_api_no_resolver(self, tmp_path): """`--version 0.20.0` must take the direct-tag path, not the resolver.""" repo_dir = tmp_path / "ComfyUI" self._make_comfy_repo(repo_dir) with ( patch.dict("os.environ", {}, clear=True), patch( "comfy_cli.command.install.requests.get", side_effect=AssertionError("API must not be called for specific versions"), ), patch( "comfy_cli.command.install._resolve_latest_tag_from_local", side_effect=AssertionError("Local resolver must not be called for specific versions"), ), patch("comfy_cli.command.install.clone_comfyui"), patch("comfy_cli.command.install.ensure_workspace_python", return_value=sys.executable), patch("comfy_cli.command.install.pip_install_comfyui_dependencies"), patch("comfy_cli.command.install.update_node_id_cache"), patch.object(install_module.workspace_manager, "skip_prompting", True), patch.object(install_module.workspace_manager, "setup_workspace_manager"), patch("comfy_cli.command.install.WorkspaceManager") as mock_ws_class, patch("comfy_cli.config_manager.ConfigManager") as mock_cfg_class, ): mock_ws_class.return_value = Mock() mock_cfg_class.return_value = Mock() install_module.execute( url="https://github.com/comfyanonymous/ComfyUI", comfy_path=str(repo_dir), restore=False, skip_manager=True, version="0.20.0", ) head = subprocess.run( ["git", "-C", str(repo_dir), "describe", "--tags", "--exact-match", "HEAD"], capture_output=True, text=True, check=True, ) assert head.stdout.strip() == "v0.20.0" if __name__ == "__main__": pytest.main([__file__]) ================================================ FILE: tests/comfy_cli/command/models/test_models.py ================================================ import pathlib from unittest.mock import Mock, patch import typer.testing from comfy_cli import constants from comfy_cli.command.models.models import _format_elapsed, app, check_civitai_url, check_huggingface_url, list_models def _make_model_tree(tmp_path: pathlib.Path) -> pathlib.Path: """Create a realistic model directory tree and return its root.""" model_dir = tmp_path / "models" (model_dir / "root_model.safetensors").parent.mkdir(parents=True, exist_ok=True) (model_dir / "root_model.safetensors").write_bytes(b"x" * 100) (model_dir / "checkpoints").mkdir() (model_dir / "checkpoints" / "sd15.safetensors").write_bytes(b"x" * 200) (model_dir / "loras" / "SD1.5").mkdir(parents=True) (model_dir / "loras" / "SD1.5" / "detail.safetensors").write_bytes(b"x" * 300) (model_dir / "empty_dir").mkdir() return model_dir def test_list_models_finds_files_in_subdirectories(tmp_path): model_dir = _make_model_tree(tmp_path) result = list_models(model_dir) names = {f.name for f in result} assert "sd15.safetensors" in names deep = [f for f in result if f.name == "detail.safetensors"] assert len(deep) == 1 assert deep[0].relative_to(model_dir) == pathlib.Path("loras/SD1.5/detail.safetensors") def test_list_models_finds_root_level_files(tmp_path): model_dir = _make_model_tree(tmp_path) result = list_models(model_dir) names = {f.name for f in result} assert "root_model.safetensors" in names def test_list_models_returns_empty_for_missing_directory(tmp_path): assert list_models(tmp_path / "nonexistent") == [] def test_list_models_ignores_directories(tmp_path): model_dir = _make_model_tree(tmp_path) result = list_models(model_dir) assert all(f.is_file() for f in result) dir_names = {f.name for f in result} assert "empty_dir" not in dir_names assert "checkpoints" not in dir_names runner = typer.testing.CliRunner() def test_list_command_shows_type_column(tmp_path): _make_model_tree(tmp_path) with patch("comfy_cli.command.models.models.get_workspace", return_value=tmp_path): result = runner.invoke(app, ["list", "--relative-path", "models"]) assert result.exit_code == 0 assert "Type" in result.output assert "checkpoints" in result.output assert "loras/SD1.5" in result.output assert "root_model.safetensors" in result.output def test_remove_with_path_traversal_is_rejected(tmp_path): model_dir = tmp_path / "models" model_dir.mkdir() (model_dir / "legit.bin").write_bytes(b"x") secret = tmp_path / "secret.txt" secret.write_text("sensitive") with patch("comfy_cli.command.models.models.get_workspace", return_value=tmp_path): result = runner.invoke( app, ["remove", "--relative-path", "models", "--model-names", "../secret.txt", "--confirm"], ) assert secret.exists() assert "Invalid model path" in result.output def test_remove_deletes_model_in_subdirectory(tmp_path): model_dir = _make_model_tree(tmp_path) target = model_dir / "checkpoints" / "sd15.safetensors" assert target.exists() with patch("comfy_cli.command.models.models.get_workspace", return_value=tmp_path): result = runner.invoke( app, ["remove", "--relative-path", "models", "--model-names", "checkpoints/sd15.safetensors", "--confirm"], ) assert result.exit_code == 0 assert not target.exists() def test_remove_rejects_directory_name(tmp_path): _make_model_tree(tmp_path) with patch("comfy_cli.command.models.models.get_workspace", return_value=tmp_path): result = runner.invoke( app, ["remove", "--relative-path", "models", "--model-names", "checkpoints", "--confirm"], ) assert (tmp_path / "models" / "checkpoints").is_dir() assert "not found" in result.output def test_remove_deletes_root_level_model(tmp_path): model_dir = _make_model_tree(tmp_path) target = model_dir / "root_model.safetensors" assert target.exists() with patch("comfy_cli.command.models.models.get_workspace", return_value=tmp_path): result = runner.invoke( app, ["remove", "--relative-path", "models", "--model-names", "root_model.safetensors", "--confirm"], ) assert result.exit_code == 0 assert not target.exists() def test_remove_interactive_shows_relative_paths(tmp_path): _make_model_tree(tmp_path) with ( patch("comfy_cli.command.models.models.get_workspace", return_value=tmp_path), patch("comfy_cli.command.models.models.ui") as mock_ui, ): mock_ui.prompt_multi_select.return_value = ["checkpoints/sd15.safetensors"] mock_ui.prompt_confirm_action.return_value = True runner.invoke(app, ["remove", "--relative-path", "models"]) choices = mock_ui.prompt_multi_select.call_args[0][1] assert "checkpoints/sd15.safetensors" in choices assert "loras/SD1.5/detail.safetensors" in choices assert "root_model.safetensors" in choices assert not (tmp_path / "models" / "checkpoints" / "sd15.safetensors").exists() def test_valid_model_url(): url = "https://civitai.com/models/43331" assert check_civitai_url(url) == (True, False, 43331, None) def test_valid_model_url_with_version(): url = "https://civitai.com/models/43331/majicmix-realistic" assert check_civitai_url(url) == (True, False, 43331, None) def test_valid_model_url_with_version_and_additional_segments(): url = "https://civitai.com/models/43331/majicmix-realistic/extra" assert check_civitai_url(url) == (True, False, 43331, None) def test_valid_model_url_with_query(): url = "https://civitai.com/models/43331?version=12345" assert check_civitai_url(url) == (True, False, 43331, 12345) def test_valid_api_url(): url = "https://civitai.com/api/download/models/67890" assert check_civitai_url(url) == (False, True, None, 67890) def test_invalid_url(): url = "https://example.com/models/43331" assert check_civitai_url(url) == (False, False, None, None) def test_malformed_url(): url = "https://civitai.com/models/" assert check_civitai_url(url) == (False, False, None, None) def test_invalid_model_id_url(): url = "https://civitai.com/models/invalid_id" assert check_civitai_url(url) == (False, False, None, None) def test_malformed_query_url(): url = "https://civitai.com/models/43331?version=" assert check_civitai_url(url) == (True, False, 43331, None) def test_model_url_with_model_version_id_query(): url = "https://civitai.com/models/43331?modelVersionId=485088" assert check_civitai_url(url) == (True, False, 43331, 485088) def test_model_url_with_model_version_id_invalid(): url = "https://civitai.com/models/43331?modelVersionId=abc" assert check_civitai_url(url) == (True, False, 43331, None) def test_valid_api_v1_model_versions_url(): url = "https://civitai.com/api/v1/model-versions/1617665" assert check_civitai_url(url) == (False, True, None, 1617665) def test_valid_api_v1_model_versions_camelcase_segment(): url = "https://civitai.com/api/v1/modelVersions/1617665" assert check_civitai_url(url) == (False, True, None, 1617665) def test_valid_api_download_with_query_params(): url = "https://civitai.com/api/download/models/1617665?type=Model&format=SafeTensor" assert check_civitai_url(url) == (False, True, None, 1617665) def test_api_download_trailing_slash_is_ok(): url = "https://civitai.com/api/download/models/1617665/" assert check_civitai_url(url) == (False, True, None, 1617665) def test_api_download_non_numeric_id_models_version(): url = "https://civitai.com/api/v1/modelVersions/notanumber" assert check_civitai_url(url) == (False, True, None, None) def test_api_download_non_numeric_id(): url = "https://civitai.com/api/download/models/notanumber" assert check_civitai_url(url) == (False, True, None, None) def test_model_url_with_slug_and_query(): url = "https://civitai.com/models/43331/majicmix-realistic?modelVersionId=485088" assert check_civitai_url(url) == (True, False, 43331, 485088) def test_www_subdomain_is_accepted(): url = "https://www.civitai.com/models/43331?version=12345" assert check_civitai_url(url) == (True, False, 43331, 12345) def test_completly_mailformed_civitai_url(): url = "https://civitai.com/" assert check_civitai_url(url) == (False, False, None, None) def test_non_evil_civitai_url(): url = "https://evilcivitai.com/models/43331?version=12345" assert check_civitai_url(url) == (False, False, None, None) def test_valid_model_url_red_domain(): url = "https://civitai.red/models/43331" assert check_civitai_url(url) == (True, False, 43331, None) def test_valid_model_url_red_with_query(): url = "https://civitai.red/models/43331?modelVersionId=485088" assert check_civitai_url(url) == (True, False, 43331, 485088) def test_valid_api_download_url_red_domain(): url = "https://civitai.red/api/download/models/1617665?type=Model&format=SafeTensor" assert check_civitai_url(url) == (False, True, None, 1617665) def test_valid_api_v1_model_versions_url_red_domain(): url = "https://civitai.red/api/v1/model-versions/1617665" assert check_civitai_url(url) == (False, True, None, 1617665) def test_www_subdomain_red_is_accepted(): url = "https://www.civitai.red/models/43331?version=12345" assert check_civitai_url(url) == (True, False, 43331, 12345) def test_non_evil_civitai_red_url(): url = "https://evilcivitai.red/models/43331?version=12345" assert check_civitai_url(url) == (False, False, None, None) def test_red_as_spoofed_subdomain_of_other_tld(): url = "https://civitai.red.evil.com/models/43331" assert check_civitai_url(url) == (False, False, None, None) def test_valid_huggingface_url(): url = "https://huggingface.co/CompVis/stable-diffusion-v1-4/resolve/main/sd-v1-4.ckpt" assert check_huggingface_url(url) == (True, "CompVis/stable-diffusion-v1-4", "sd-v1-4.ckpt", None, "main") def test_valid_huggingface_url_sd_audio(): url = "https://huggingface.co/stabilityai/stable-audio-open-1.0/blob/main/model.safetensors" assert check_huggingface_url(url) == (True, "stabilityai/stable-audio-open-1.0", "model.safetensors", None, "main") def test_valid_huggingface_url_with_folder(): url = "https://huggingface.co/runwayml/stable-diffusion-v1-5/resolve/main/v1-5-pruned-emaonly.ckpt" assert check_huggingface_url(url) == ( True, "runwayml/stable-diffusion-v1-5", "v1-5-pruned-emaonly.ckpt", None, "main", ) def test_valid_huggingface_url_with_subfolder(): url = "https://huggingface.co/stabilityai/stable-diffusion-2-1/resolve/main/v2-1_768-ema-pruned.ckpt" assert check_huggingface_url(url) == ( True, "stabilityai/stable-diffusion-2-1", "v2-1_768-ema-pruned.ckpt", None, "main", ) def test_valid_huggingface_url_with_encoded_filename(): url = "https://huggingface.co/CompVis/stable-diffusion-v1-4/resolve/main/sd-v1-4%20(1).ckpt" assert check_huggingface_url(url) == (True, "CompVis/stable-diffusion-v1-4", "sd-v1-4 (1).ckpt", None, "main") def test_invalid_huggingface_url(): url = "https://example.com/CompVis/stable-diffusion-v1-4/resolve/main/sd-v1-4.ckpt" assert check_huggingface_url(url) == (False, None, None, None, None) def test_invalid_huggingface_url_structure(): url = "https://huggingface.co/CompVis/stable-diffusion-v1-4/main/sd-v1-4.ckpt" assert check_huggingface_url(url) == (False, None, None, None, None) def test_huggingface_url_with_com_domain(): url = "https://huggingface.com/CompVis/stable-diffusion-v1-4/resolve/main/sd-v1-4.ckpt" assert check_huggingface_url(url) == (True, "CompVis/stable-diffusion-v1-4", "sd-v1-4.ckpt", None, "main") def test_huggingface_url_with_folder_structure(): url = "https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0/resolve/main/sd_xl_base_1.0.safetensors" assert check_huggingface_url(url) == ( True, "stabilityai/stable-diffusion-xl-base-1.0", "sd_xl_base_1.0.safetensors", None, "main", ) class TestFormatElapsed: def test_under_one_minute(self): assert _format_elapsed(5.3) == "5.3s" def test_fractional_seconds(self): assert _format_elapsed(0.4) == "0.4s" def test_rounds_up_to_minute_boundary(self): assert _format_elapsed(59.95) == "1m 0s" def test_exactly_sixty_seconds(self): assert _format_elapsed(60) == "1m 0s" def test_minutes_and_seconds(self): assert _format_elapsed(154) == "2m 34s" def test_over_one_hour(self): assert _format_elapsed(3661) == "1h 1m 1s" def test_large_duration(self): assert _format_elapsed(7384) == "2h 3m 4s" # --------------------------------------------------------------------------- # --downloader CLI option tests # --------------------------------------------------------------------------- class TestDownloadCommandDownloaderOption: def test_downloader_flag_forwarded(self, tmp_path): """--downloader aria2 flag is forwarded to download_file.""" with ( patch("comfy_cli.command.models.models.get_workspace", return_value=tmp_path), patch("comfy_cli.command.models.models.download_file") as mock_dl, patch("comfy_cli.command.models.models.check_civitai_url", return_value=(False, False, None, None)), patch( "comfy_cli.command.models.models.check_huggingface_url", return_value=(False, None, None, None, None) ), patch("comfy_cli.command.models.models.ui") as mock_ui, patch("comfy_cli.command.models.models.config_manager"), patch("comfy_cli.tracking.track_command", lambda _cmd: lambda fn: fn), ): mock_ui.prompt_input.side_effect = ["mymodel.bin", ""] result = runner.invoke( app, [ "download", "--url", "http://example.com/model.bin", "--downloader", "aria2", "--filename", "model.bin", ], ) assert mock_dl.called _, kwargs = mock_dl.call_args assert kwargs.get("downloader") == "aria2" assert "Done in " in result.output def test_default_from_config(self, tmp_path): """Config default_downloader is used when no --downloader flag.""" mock_cfg = Mock() mock_cfg.get_or_override.return_value = None mock_cfg.get.side_effect = lambda key: "aria2" if key == constants.CONFIG_KEY_DEFAULT_DOWNLOADER else None with ( patch("comfy_cli.command.models.models.get_workspace", return_value=tmp_path), patch("comfy_cli.command.models.models.download_file") as mock_dl, patch("comfy_cli.command.models.models.check_civitai_url", return_value=(False, False, None, None)), patch( "comfy_cli.command.models.models.check_huggingface_url", return_value=(False, None, None, None, None) ), patch("comfy_cli.command.models.models.ui") as mock_ui, patch("comfy_cli.command.models.models.config_manager", mock_cfg), patch("comfy_cli.tracking.track_command", lambda _cmd: lambda fn: fn), ): mock_ui.prompt_input.side_effect = ["mymodel.bin", ""] runner.invoke( app, [ "download", "--url", "http://example.com/model.bin", "--filename", "model.bin", ], ) assert mock_dl.called _, kwargs = mock_dl.call_args assert kwargs.get("downloader") == "aria2" def test_cli_flag_overrides_config(self, tmp_path): """CLI --downloader flag takes precedence over config.""" mock_cfg = Mock() mock_cfg.get_or_override.return_value = None mock_cfg.get.side_effect = lambda key: "aria2" if key == constants.CONFIG_KEY_DEFAULT_DOWNLOADER else None with ( patch("comfy_cli.command.models.models.get_workspace", return_value=tmp_path), patch("comfy_cli.command.models.models.download_file") as mock_dl, patch("comfy_cli.command.models.models.check_civitai_url", return_value=(False, False, None, None)), patch( "comfy_cli.command.models.models.check_huggingface_url", return_value=(False, None, None, None, None) ), patch("comfy_cli.command.models.models.ui") as mock_ui, patch("comfy_cli.command.models.models.config_manager", mock_cfg), patch("comfy_cli.tracking.track_command", lambda _cmd: lambda fn: fn), ): mock_ui.prompt_input.side_effect = ["mymodel.bin", ""] runner.invoke( app, [ "download", "--url", "http://example.com/model.bin", "--downloader", "httpx", "--filename", "model.bin", ], ) assert mock_dl.called _, kwargs = mock_dl.call_args assert kwargs.get("downloader") == "httpx" class TestDownloadCommandErrorHandling: """Verify DownloadException is rendered as a friendly one-line error (not a traceback).""" def _run_with_download_error(self, tmp_path, exc): with ( patch("comfy_cli.command.models.models.get_workspace", return_value=tmp_path), patch("comfy_cli.command.models.models.download_file", side_effect=exc), patch("comfy_cli.command.models.models.check_civitai_url", return_value=(False, False, None, None)), patch( "comfy_cli.command.models.models.check_huggingface_url", return_value=(False, None, None, None, None), ), patch("comfy_cli.command.models.models.ui") as mock_ui, patch("comfy_cli.command.models.models.config_manager"), patch("comfy_cli.tracking.track_command", lambda _cmd: lambda fn: fn), ): mock_ui.prompt_input.side_effect = ["mymodel.bin", ""] return runner.invoke( app, [ "download", "--url", "http://example.com/model.bin", "--filename", "model.bin", ], ) def test_download_exception_exits_with_code_1(self, tmp_path): from comfy_cli.file_utils import DownloadException result = self._run_with_download_error(tmp_path, DownloadException("boom")) assert result.exit_code == 1 assert "boom" in result.output def test_download_exception_does_not_show_traceback(self, tmp_path): from comfy_cli.file_utils import DownloadException result = self._run_with_download_error(tmp_path, DownloadException("boom")) assert "Traceback" not in result.output assert "DownloadException" not in result.output # Rich markup must be rendered as styling, not leak through as literal tags. assert "[bold red]" not in result.output assert "[/bold red]" not in result.output def test_download_exception_skips_done_message(self, tmp_path): from comfy_cli.file_utils import DownloadException result = self._run_with_download_error(tmp_path, DownloadException("boom")) assert "Done in" not in result.output def test_download_exception_with_markup_chars_does_not_crash(self, tmp_path): """A DownloadException message containing rich-markup metacharacters (e.g. from a server JSON body embedded via guess_status_code_reason) must not raise MarkupError nor be silently stripped — the error must render literally and exit cleanly.""" from comfy_cli.file_utils import DownloadException # Covers both the closing-tag crash case and the bracketed-style stripping case. result = self._run_with_download_error(tmp_path, DownloadException("server said [/] at /path/[id]/resource")) assert result.exit_code == 1 assert "Traceback" not in result.output assert "MarkupError" not in result.output # Literal markup characters must survive to the output so the user sees the real message. assert "[/]" in result.output assert "[id]" in result.output ================================================ FILE: tests/comfy_cli/command/nodes/test_bisect_custom_nodes.py ================================================ import json from unittest.mock import patch import pytest from comfy_cli.command.custom_nodes.bisect_custom_nodes import BisectState @pytest.fixture(scope="function") def bisect_state(): return BisectState( status="running", all=["node1", "node2", "node3"], range=["node1", "node2", "node3"], active=["node1", "node2"], ) def test_good(): bisect_state = BisectState( status="running", all=["node1", "node2", "node3"], range=["node1", "node2", "node3"], active=["node1"], ) new_state = bisect_state.good() assert new_state.status == "running" assert new_state.all == bisect_state.all assert set(new_state.range) == set(["node3", "node2"]) assert len(new_state.active) == 1 def test_good_resolved(bisect_state: BisectState): new_state = bisect_state.good() assert new_state.status == "resolved" assert new_state.all == bisect_state.all assert new_state.range == ["node3"] assert new_state.active == [] def test_bad(bisect_state): new_state = bisect_state.bad() assert new_state.status == "running" assert new_state.all == bisect_state.all assert new_state.range == ["node1", "node2"] assert new_state.active == ["node2"] def test_bad_resolved(): bisect_state = BisectState( status="running", all=["node1", "node2", "node3"], range=["node1", "node2", "node3"], active=["node1"], ) new_state = bisect_state.bad() assert new_state.status == "resolved" assert new_state.all == bisect_state.all assert new_state.range == ["node1"] assert new_state.active == [] @patch("comfy_cli.command.custom_nodes.bisect_custom_nodes.execute_cm_cli") def test_save(mock_execute_cm_cli, bisect_state, tmp_path): state_file = tmp_path / "bisect_state.json" bisect_state.save(state_file) assert state_file.exists() assert mock_execute_cm_cli.call_count == 2 with state_file.open() as f: saved_state = json.load(f) assert saved_state == bisect_state._asdict() @patch("comfy_cli.command.custom_nodes.bisect_custom_nodes.execute_cm_cli") def test_reset(mock_execute_cm_cli, bisect_state): new_state = bisect_state.reset() assert new_state.status == "idle" assert new_state.all == ["node1", "node2", "node3"] assert new_state.range == ["node1", "node2", "node3"] assert new_state.active == ["node1", "node2", "node3"] assert mock_execute_cm_cli.call_count == 1 def test_load_existing_state(tmp_path): state_file = tmp_path / "bisect_state.json" state_data = { "status": "running", "all": ["node1", "node2", "node3"], "range": ["node1", "node2", "node3"], "active": ["node1", "node2"], } with state_file.open("w") as f: json.dump(state_data, f) loaded_state = BisectState.load(state_file) assert loaded_state.status == state_data["status"] assert loaded_state.all == state_data["all"] assert loaded_state.range == state_data["range"] assert loaded_state.active == state_data["active"] def test_load_nonexistent_state(tmp_path): state_file = tmp_path / "bisect_state.json" loaded_state = BisectState.load(state_file) assert loaded_state.status == "idle" assert loaded_state.all == [] assert loaded_state.range == [] assert loaded_state.active == [] @patch("comfy_cli.command.custom_nodes.bisect_custom_nodes.execute_cm_cli") def test_set_custom_node_enabled_states(mock_execute_cm_cli, bisect_state): bisect_state.set_custom_node_enabled_states() assert mock_execute_cm_cli.call_count == 2 @patch("comfy_cli.command.custom_nodes.bisect_custom_nodes.execute_cm_cli") def test_set_custom_node_enabled_states_no_active_nodes(mock_execute_cm_cli): bisect_state = BisectState( status="running", all=["node1", "node2", "node3"], range=["node1", "node2", "node3"], active=[], ) bisect_state.set_custom_node_enabled_states() assert mock_execute_cm_cli.call_count == 1 ================================================ FILE: tests/comfy_cli/command/nodes/test_node_init.py ================================================ import subprocess import tomlkit from typer.testing import CliRunner from comfy_cli.command.custom_nodes.command import app runner = CliRunner() def test_node_init_strips_credentials(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) subprocess.run(["git", "init"], cwd=tmp_path, check=True, capture_output=True) subprocess.run( ["git", "remote", "add", "origin", "https://ghp_FAKESECRET123@github.com/user/ComfyUI-TestNode.git"], cwd=tmp_path, check=True, capture_output=True, ) (tmp_path / "requirements.txt").write_text("requests\n") result = runner.invoke(app, ["init"]) assert result.exit_code == 0 with open(tmp_path / "pyproject.toml") as f: data = tomlkit.parse(f.read()) raw = tomlkit.dumps(data) assert "ghp_FAKESECRET123" not in raw assert data["project"]["urls"]["Repository"] == "https://github.com/user/ComfyUI-TestNode" def test_node_init_refuses_overwrite(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) (tmp_path / "pyproject.toml").write_text("[project]\n") result = runner.invoke(app, ["init"]) assert result.exit_code == 1 assert "already exists" in result.stdout ================================================ FILE: tests/comfy_cli/command/nodes/test_node_install.py ================================================ import re import subprocess from unittest.mock import MagicMock, patch from typer.testing import CliRunner from comfy_cli.command.custom_nodes.command import app from comfy_cli.file_utils import DownloadException runner = CliRunner() def strip_ansi(text): ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") return ansi_escape.sub("", text) def test_install_no_deps_option_exists(): result = runner.invoke(app, ["install", "--help"]) assert result.exit_code == 0 clean_output = strip_ansi(result.stdout) assert "--no-deps" in clean_output assert "Skip dependency installation" in clean_output def test_install_fast_deps_and_no_deps_mutually_exclusive(): result = runner.invoke(app, ["install", "test-node", "--fast-deps", "--no-deps"]) assert result.exit_code != 0 assert "Cannot use --fast-deps and --no-deps together" in result.output def test_install_no_deps_alone_works(): with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: result = runner.invoke(app, ["install", "test-node", "--no-deps"]) assert result.exit_code == 0 mock_execute.assert_called_once() _, kwargs = mock_execute.call_args assert kwargs.get("no_deps") is True assert kwargs.get("fast_deps") is False def test_install_fast_deps_alone_works(): with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: result = runner.invoke(app, ["install", "test-node", "--fast-deps"]) assert result.exit_code == 0 mock_execute.assert_called_once() _, kwargs = mock_execute.call_args assert kwargs.get("fast_deps") is True assert kwargs.get("no_deps") is False def test_install_neither_deps_option(): with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: result = runner.invoke(app, ["install", "test-node"]) assert result.exit_code == 0 mock_execute.assert_called_once() _, kwargs = mock_execute.call_args assert kwargs.get("fast_deps") is False assert kwargs.get("no_deps") is False def test_multiple_commands_work_independently(): with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli"): result1 = runner.invoke(app, ["install", "test-node", "--no-deps"]) assert result1.exit_code == 0 with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli"): result2 = runner.invoke(app, ["install", "test-node2", "--fast-deps"]) assert result2.exit_code == 0 def test_install_uv_compile_passes_to_execute(): with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: result = runner.invoke(app, ["install", "test-node", "--uv-compile"]) assert result.exit_code == 0 mock_execute.assert_called_once() _, kwargs = mock_execute.call_args assert kwargs.get("uv_compile") is True assert kwargs.get("fast_deps") is False assert kwargs.get("no_deps") is False def test_install_no_uv_compile_passes_false(): with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: result = runner.invoke(app, ["install", "test-node", "--no-uv-compile"]) assert result.exit_code == 0 mock_execute.assert_called_once() _, kwargs = mock_execute.call_args assert kwargs.get("uv_compile") is False def test_install_uv_compile_and_fast_deps_mutually_exclusive(): result = runner.invoke(app, ["install", "test-node", "--uv-compile", "--fast-deps"]) assert result.exit_code != 0 assert "Cannot use" in result.output def test_install_uv_compile_and_no_deps_mutually_exclusive(): result = runner.invoke(app, ["install", "test-node", "--uv-compile", "--no-deps"]) assert result.exit_code != 0 assert "Cannot use" in result.output def test_uv_sync_calls_execute_cm_cli(): with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: result = runner.invoke(app, ["uv-sync"]) assert result.exit_code == 0 mock_execute.assert_called_once() args, _ = mock_execute.call_args assert args[0] == ["uv-sync"] def test_reinstall_uv_compile_passes_to_execute(): with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: result = runner.invoke(app, ["reinstall", "test-node", "--uv-compile"]) assert result.exit_code == 0 mock_execute.assert_called_once() _, kwargs = mock_execute.call_args assert kwargs.get("uv_compile") is True def test_reinstall_uv_compile_and_fast_deps_mutually_exclusive(): result = runner.invoke(app, ["reinstall", "test-node", "--uv-compile", "--fast-deps"]) assert result.exit_code != 0 assert "Cannot use" in result.output def test_reinstall_no_uv_compile_passes_false(): with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: result = runner.invoke(app, ["reinstall", "test-node", "--no-uv-compile"]) assert result.exit_code == 0 mock_execute.assert_called_once() _, kwargs = mock_execute.call_args assert kwargs.get("uv_compile") is False def test_install_exit_on_fail_reraises_and_propagates_code(): with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: mock_execute.side_effect = subprocess.CalledProcessError(7, "cm-cli") result = runner.invoke(app, ["install", "bad-node", "--exit-on-fail"]) assert result.exit_code == 7 assert mock_execute.called args, kwargs = mock_execute.call_args assert kwargs.get("raise_on_error") is True assert args[0][0] == "install" and "--exit-on-fail" in args[0] and "bad-node" in args[0] def test_save_snapshot_no_output(): with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: result = runner.invoke(app, ["save-snapshot"]) assert result.exit_code == 0 mock_execute.assert_called_once() args, _ = mock_execute.call_args assert args[0] == ["save-snapshot"] def test_save_snapshot_with_output(): with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: result = runner.invoke(app, ["save-snapshot", "--output", "/tmp/snap.json"]) assert result.exit_code == 0 mock_execute.assert_called_once() args, _ = mock_execute.call_args assert args[0][0] == "save-snapshot" assert "--output" in args[0] def test_restore_snapshot_with_uv_compile(): with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: result = runner.invoke(app, ["restore-snapshot", "/tmp/snap.json", "--uv-compile"]) assert result.exit_code == 0 mock_execute.assert_called_once() _, kwargs = mock_execute.call_args assert kwargs.get("uv_compile") is True def test_restore_snapshot_with_pip_flags(): with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: result = runner.invoke(app, ["restore-snapshot", "/tmp/snap.json", "--pip-non-url", "--pip-local-url"]) assert result.exit_code == 0 mock_execute.assert_called_once() args, _ = mock_execute.call_args assert "--pip-non-url" in args[0] assert "--pip-local-url" in args[0] def test_restore_dependencies_with_uv_compile(): with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: result = runner.invoke(app, ["restore-dependencies", "--uv-compile"]) assert result.exit_code == 0 mock_execute.assert_called_once() _, kwargs = mock_execute.call_args assert kwargs.get("uv_compile") is True def test_update_with_uv_compile(): with ( patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute, patch("comfy_cli.command.custom_nodes.command.update_node_id_cache"), ): result = runner.invoke(app, ["update", "test-node", "--uv-compile"]) assert result.exit_code == 0 mock_execute.assert_called_once() _, kwargs = mock_execute.call_args assert kwargs.get("uv_compile") is True def test_fix_with_uv_compile(): with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: result = runner.invoke(app, ["fix", "test-node", "--uv-compile"]) assert result.exit_code == 0 mock_execute.assert_called_once() _, kwargs = mock_execute.call_args assert kwargs.get("uv_compile") is True def test_uninstall_rejects_all(): result = runner.invoke(app, ["uninstall", "all"]) assert result.exit_code != 0 assert "`uninstall all` is not allowed" in result.output assert "Invalid command" not in result.output def test_reinstall_rejects_all(): result = runner.invoke(app, ["reinstall", "all"]) assert result.exit_code != 0 assert "`reinstall all` is not allowed" in result.output assert "Invalid command" not in result.output def test_validate_mode_rejects_invalid(): result = runner.invoke(app, ["install", "test-node", "--mode", "invalid-mode"]) assert result.exit_code != 0 assert "Invalid mode" in result.output def test_install_deps_with_deps_file(): with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: result = runner.invoke(app, ["install-deps", "--deps", "/tmp/deps.json"]) assert result.exit_code == 0 mock_execute.assert_called_once() args, _ = mock_execute.call_args assert "install-deps" in args[0] def test_install_deps_with_uv_compile(): with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: result = runner.invoke(app, ["install-deps", "--deps", "/tmp/deps.json", "--uv-compile"]) assert result.exit_code == 0 mock_execute.assert_called_once() _, kwargs = mock_execute.call_args assert kwargs.get("uv_compile") is True def test_install_deps_no_args_shows_error(): with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli"): result = runner.invoke(app, ["install-deps"]) assert "One of --deps or --workflow" in result.output def test_restore_snapshot_with_pip_non_local_url(): with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: result = runner.invoke(app, ["restore-snapshot", "/tmp/snap.json", "--pip-non-local-url"]) assert result.exit_code == 0 mock_execute.assert_called_once() args, _ = mock_execute.call_args assert "--pip-non-local-url" in args[0] def test_update_calls_update_node_id_cache(): with ( patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute, patch("comfy_cli.command.custom_nodes.command.update_node_id_cache") as mock_cache, ): result = runner.invoke(app, ["update", "test-node"]) assert result.exit_code == 0 mock_execute.assert_called_once() mock_cache.assert_called_once() def test_uninstall_calls_execute(): with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: result = runner.invoke(app, ["uninstall", "test-node"]) assert result.exit_code == 0 mock_execute.assert_called_once() args, _ = mock_execute.call_args assert args[0] == ["uninstall", "test-node"] def test_show_installed(): with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: result = runner.invoke(app, ["show", "installed"]) assert result.exit_code == 0 mock_execute.assert_called_once() args, _ = mock_execute.call_args assert args[0] == ["show", "installed"] def test_install_deps_with_workflow(tmp_path): workflow_file = tmp_path / "workflow.json" workflow_file.write_text("{}") with ( patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute, patch("comfy_cli.command.custom_nodes.command.workspace_manager") as mock_ws, ): mock_ws.config_manager.get_config_path.return_value = str(tmp_path) result = runner.invoke(app, ["install-deps", "--workflow", str(workflow_file)]) assert result.exit_code == 0 assert mock_execute.call_count == 2 first_call_args = mock_execute.call_args_list[0][0][0] second_call_args = mock_execute.call_args_list[1][0][0] assert first_call_args[0] == "deps-in-workflow" assert second_call_args[0] == "install-deps" def test_install_rejects_all(): result = runner.invoke(app, ["install", "all"]) assert result.exit_code != 0 assert "`install all` is not allowed" in result.output assert "Invalid command" not in result.output def test_simple_show_installed(): with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: result = runner.invoke(app, ["simple-show", "installed"]) assert result.exit_code == 0 mock_execute.assert_called_once() args, _ = mock_execute.call_args assert args[0] == ["simple-show", "installed"] def test_show_with_channel(): with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: result = runner.invoke(app, ["show", "installed", "--channel", "dev"]) assert result.exit_code == 0 mock_execute.assert_called_once() _, kwargs = mock_execute.call_args assert kwargs.get("channel") == "dev" class TestRegistryInstallDownloadError: """registry-install must catch DownloadException, surface a friendly one-line error via ui.display_error_message, and exit cleanly — never raise a traceback.""" def _invoke(self, tmp_path, download_side_effect): fake_version = MagicMock(download_url="http://example.com/node.zip", version="1.0.0") with ( patch("comfy_cli.command.custom_nodes.command.registry_api") as mock_api, patch("comfy_cli.command.custom_nodes.command.workspace_manager") as mock_ws, patch("comfy_cli.command.custom_nodes.command.download_file", side_effect=download_side_effect) as mock_dl, patch("comfy_cli.command.custom_nodes.command.ui") as mock_ui, patch("comfy_cli.command.custom_nodes.command.extract_package_as_zip") as mock_extract, patch("comfy_cli.command.custom_nodes.command.execute_install_script") as mock_script, ): mock_api.install_node.return_value = fake_version mock_ws.workspace_path = str(tmp_path) result = runner.invoke(app, ["registry-install", "test-node"]) return result, mock_ui, mock_dl, mock_extract, mock_script def test_download_exception_caught_and_reported(self, tmp_path): result, mock_ui, mock_dl, mock_extract, mock_script = self._invoke( tmp_path, DownloadException("server unreachable") ) # Must exit non-zero so automation / CI can detect the failure. assert result.exit_code == 1 mock_dl.assert_called_once() mock_ui.display_error_message.assert_called_once() (msg,), _ = mock_ui.display_error_message.call_args assert "test-node" in msg assert "server unreachable" in msg def test_no_extract_or_install_script_after_failure(self, tmp_path): """After a download failure we must not try to unzip or run the install script.""" result, _mock_ui, _mock_dl, mock_extract, mock_script = self._invoke(tmp_path, DownloadException("boom")) assert result.exit_code == 1 mock_extract.assert_not_called() mock_script.assert_not_called() def test_no_traceback_in_output(self, tmp_path): result, _mock_ui, _mock_dl, _mock_extract, _mock_script = self._invoke(tmp_path, DownloadException("boom")) assert "Traceback" not in result.output assert "DownloadException" not in result.output ================================================ FILE: tests/comfy_cli/command/nodes/test_pack.py ================================================ import subprocess import zipfile from unittest.mock import patch from typer.testing import CliRunner from comfy_cli.cmdline import app from comfy_cli.registry.config_parser import extract_node_configuration PYPROJECT = """\ [project] name = "test-node" version = "1.0.0" description = "A test node" license = {text = "MIT"} [tool.comfy] PublisherId = "test-publisher" DisplayName = "Test Node" includes = ["models"] """ def test_pack_creates_zip_with_correct_contents(tmp_path, monkeypatch): """Full integration: git repo + pyproject.toml + .comfyignore + includes -> zip. Verifies that `comfy node pack`: - includes git-tracked files - excludes files matched by .comfyignore (even if git-tracked) - excludes untracked files - force-includes directories listed in [tool.comfy] includes (even if untracked) - does not include the zip file itself """ monkeypatch.chdir(tmp_path) # extract_node_configuration's default path is frozen at import time; # patch it so it reads pyproject.toml from the temp directory. monkeypatch.setattr(extract_node_configuration, "__defaults__", (str(tmp_path / "pyproject.toml"),)) (tmp_path / "pyproject.toml").write_text(PYPROJECT) (tmp_path / "__init__.py").write_text("# entry\n") (tmp_path / "nodes.py").write_text("class MyNode: pass\n") (tmp_path / ".comfyignore").write_text("*.log\n") (tmp_path / "debug.log").write_text("log output\n") # Non-git-tracked directory listed in includes (tmp_path / "models").mkdir() (tmp_path / "models" / "weights.bin").write_bytes(b"\x00" * 8) # Init git and commit (debug.log is git-tracked but .comfyignore'd) subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True) subprocess.run(["git", "config", "user.email", "t@t"], cwd=tmp_path, capture_output=True, check=True) subprocess.run(["git", "config", "user.name", "T"], cwd=tmp_path, capture_output=True, check=True) subprocess.run( ["git", "add", "pyproject.toml", "__init__.py", "nodes.py", ".comfyignore", "debug.log"], cwd=tmp_path, capture_output=True, check=True, ) subprocess.run(["git", "commit", "-m", "init"], cwd=tmp_path, capture_output=True, check=True) # Create untracked file after commit (tmp_path / "untracked.txt").write_text("not in git\n") with patch("comfy_cli.tracking.prompt_tracking_consent"): result = CliRunner().invoke(app, ["node", "pack"]) assert result.exit_code == 0, result.output zip_path = tmp_path / "node.zip" assert zip_path.exists() with zipfile.ZipFile(zip_path) as zf: names = set(zf.namelist()) # Git-tracked files present assert "pyproject.toml" in names assert "__init__.py" in names assert "nodes.py" in names # .comfyignore excludes git-tracked file assert "debug.log" not in names # Untracked file excluded assert "untracked.txt" not in names # includes directory added despite not being git-tracked assert "models/weights.bin" in names # Zip itself not included assert "node.zip" not in names ================================================ FILE: tests/comfy_cli/command/nodes/test_publish.py ================================================ from unittest.mock import MagicMock, patch from typer.testing import CliRunner from comfy_cli.command.custom_nodes.command import app from comfy_cli.registry.types import ComfyConfig, ProjectConfig, PyProjectConfig runner = CliRunner() def create_mock_config(includes_list=None): if includes_list is None: includes_list = [] mock_pyproject_config = MagicMock() mock_tool_comfy_section = MagicMock() mock_tool_comfy_section.name = "test-node" mock_tool_comfy_section.version = "0.1.0" mock_tool_comfy_section.description = "A test node." mock_tool_comfy_section.author = "Test Author" mock_tool_comfy_section.license = "MIT" mock_tool_comfy_section.tags = ["test"] mock_tool_comfy_section.repository = "http://example.com/repo" mock_tool_comfy_section.homepage = "http://example.com/home" mock_tool_comfy_section.documentation = "http://example.com/docs" mock_tool_comfy_section.includes = includes_list mock_pyproject_config.tool_comfy = mock_tool_comfy_section return mock_pyproject_config def test_publish_fails_on_security_violations(): # Mock subprocess.run to simulate security violations mock_result = MagicMock() mock_result.returncode = 1 mock_result.stdout = "S102 Use of exec() detected" with ( patch("subprocess.run", return_value=mock_result), patch("typer.prompt", return_value="test-token"), ): result = runner.invoke(app, ["publish"]) # TODO: re-enable exit when we disable exec and eval # assert result.exit_code == 1 # assert "Security issues found" in result.stdout assert "Security warnings found" in result.stdout def test_publish_continues_on_no_security_violations(): # Mock subprocess.run to simulate no violations mock_result = MagicMock() mock_result.returncode = 0 mock_result.stdout = "" with ( patch("subprocess.run", return_value=mock_result), patch("comfy_cli.command.custom_nodes.command.extract_node_configuration") as mock_extract, patch("typer.prompt") as mock_prompt, patch("comfy_cli.command.custom_nodes.command.registry_api.publish_node_version") as mock_publish, patch("comfy_cli.command.custom_nodes.command.zip_files") as mock_zip, patch("comfy_cli.command.custom_nodes.command.upload_file_to_signed_url") as mock_upload, ): # Setup the mocks mock_extract.return_value = create_mock_config() mock_prompt.return_value = "test-token" mock_publish.return_value = MagicMock(signedUrl="https://test.url") # Run the publish command _result = runner.invoke(app, ["publish"]) # Verify the publish flow continued assert mock_extract.called assert mock_publish.called assert mock_zip.called assert mock_upload.called def test_publish_handles_missing_ruff(): with patch("subprocess.run", side_effect=FileNotFoundError()): result = runner.invoke(app, ["publish"]) assert result.exit_code == 1 assert "Ruff is not installed" in result.stdout def test_publish_with_token_option(): # Mock subprocess.run to simulate no violations mock_result = MagicMock() mock_result.returncode = 0 mock_result.stdout = "" with ( patch("subprocess.run", return_value=mock_result), patch("comfy_cli.command.custom_nodes.command.extract_node_configuration") as mock_extract, patch("comfy_cli.command.custom_nodes.command.registry_api.publish_node_version") as mock_publish, patch("comfy_cli.command.custom_nodes.command.zip_files") as mock_zip, patch("comfy_cli.command.custom_nodes.command.upload_file_to_signed_url") as mock_upload, ): # Setup the mocks mock_extract.return_value = create_mock_config() mock_publish.return_value = MagicMock(signedUrl="https://test.url") # Run the publish command with token _result = runner.invoke(app, ["publish", "--token", "test-token"]) # Verify the publish flow worked with provided token assert mock_extract.called assert mock_publish.called assert mock_zip.called assert mock_upload.called def test_publish_exits_on_upload_failure(): # Mock subprocess.run to simulate no violations mock_result = MagicMock() mock_result.returncode = 0 mock_result.stdout = "" with ( patch("subprocess.run", return_value=mock_result), patch("comfy_cli.command.custom_nodes.command.extract_node_configuration") as mock_extract, patch("typer.prompt", return_value="test-token"), patch("comfy_cli.command.custom_nodes.command.registry_api.publish_node_version") as mock_publish, patch("comfy_cli.command.custom_nodes.command.zip_files") as mock_zip, patch("comfy_cli.command.custom_nodes.command.upload_file_to_signed_url") as mock_upload, ): # Setup the mocks mock_extract.return_value = create_mock_config() mock_publish.return_value = MagicMock(signedUrl="https://test.url") mock_upload.side_effect = Exception("Upload failed with status code: 403") # Run the publish command result = runner.invoke(app, ["publish"]) # Verify the command exited with error assert result.exit_code == 1 assert mock_extract.called assert mock_publish.called assert mock_zip.called assert mock_upload.called def test_publish_fails_when_config_is_none(): # extract_node_configuration returns None when pyproject.toml is missing; # validate_node_for_publishing must exit 1 (not crash on the subsequent # `config.project.version` access). with patch( "comfy_cli.command.custom_nodes.command.extract_node_configuration", return_value=None, ): result = runner.invoke(app, ["validate"]) assert result.exit_code == 1 def test_publish_fails_when_version_is_empty(): # Guards against issue #294: dynamic versions that failed to resolve must # not silently POST an empty `version` to the registry. validate_node_for_publishing # should exit 1 with a user-facing error pointing at [tool.comfy.version].path. empty_version_config = PyProjectConfig( project=ProjectConfig(name="x", version=""), tool_comfy=ComfyConfig(publisher_id="pub"), ) with patch( "comfy_cli.command.custom_nodes.command.extract_node_configuration", return_value=empty_version_config, ): result = runner.invoke(app, ["validate"]) assert result.exit_code == 1 assert "project version is empty" in result.stdout assert "[tool.comfy.version].path" in result.stdout def test_publish_with_includes_parameter(): # Mock subprocess.run to simulate no violations mock_result = MagicMock() mock_result.returncode = 0 mock_result.stdout = "" with ( patch("subprocess.run", return_value=mock_result), patch("comfy_cli.command.custom_nodes.command.extract_node_configuration") as mock_extract, patch("comfy_cli.command.custom_nodes.command.registry_api.publish_node_version") as mock_publish, patch("comfy_cli.command.custom_nodes.command.zip_files") as mock_zip, patch("comfy_cli.command.custom_nodes.command.upload_file_to_signed_url") as mock_upload, ): includes = ["/js", "/dist"] # Setup the mocks mock_extract.return_value = create_mock_config(includes) mock_publish.return_value = MagicMock(signedUrl="https://test.url") # Run the publish command with token _result = runner.invoke(app, ["publish", "--token", "test-token"]) # Verify the publish flow worked with provided token assert mock_extract.called assert mock_publish.called assert mock_zip.called assert mock_upload.called ================================================ FILE: tests/comfy_cli/command/test_bisect_parse.py ================================================ from comfy_cli.command.custom_nodes.bisect_custom_nodes import parse_cm_output CM_OUTPUT_REAL = """\ FETCH ComfyRegistry Data: 5/85 FETCH ComfyRegistry Data: 10/85 FETCH ComfyRegistry Data: 15/85 FETCH ComfyRegistry Data: 20/85 FETCH ComfyRegistry Data: 25/85 FETCH ComfyRegistry Data: 30/85 FETCH ComfyRegistry Data: 35/85 FETCH ComfyRegistry Data: 40/85 FETCH ComfyRegistry Data: 45/85 FETCH ComfyRegistry Data: 50/85 FETCH ComfyRegistry Data: 55/85 FETCH ComfyRegistry Data: 60/85 FETCH ComfyRegistry Data: 65/85 FETCH ComfyRegistry Data: 70/85 FETCH ComfyRegistry Data: 75/85 FETCH ComfyRegistry Data: 80/85 FETCH ComfyRegistry Data: 85/85 FETCH ComfyRegistry Data [DONE] FETCH DATA from: https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main/custom-node-list.json [DONE] FETCH DATA from: https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main/custom-node-list.json [DONE] A3D ComfyUI Integration@1.0.2 ComfyUI_ACE-Step@1.1.2 Bjornulf_custom_nodes@1.1.1 cg-use-everywhere@6.1.0 ComfyUI-0246@1.1.3 ComfyUI ArtVenture@nightly comfyui-auto-nodes-layout@0.0.1 ComfyUI-ConDelta@nightly ComfyUI-Custom-Scripts@nightly ComfyUI-DiaTTS@0.3.0 ComfyUI-Easy-Use@1.3.0 ComfyUI F5-TTS@nightly ComfyUI-Florence2@1.0.3 ComfyUI@nightly ComfyUI-GGUF@nightly ComfyUI-Image-Filters@nightly ComfyUI-KJNodes@nightly comfyui-lmstudio-image-to-text-node@1.1.14 ComfyUI-LogicUtils@1.7.2 ComfyUI-LTXVideo@nightly ComfyUI-Manager@nightly ComfyUI-MMAudio@1.0.2 ComfyUI-mxToolkit@0.9.92 ComfyUI-VideoHelperSuite@1.6.1 ComfyUI Web Viewer@1.0.32 comfyui_controlnet_aux@1.0.7 ComfyUI_IPAdapter_plus@2.0.0 Prompt Stash@1.2.0 efficiency-nodes-comfyui@1.0.6 gguf@2.1.0 comfyui_HiDream-Sampler@1.0.0 LF Nodes@0.7.0 lora-info@1.0.2 Masquerade Nodes@nightly rgthree-comfy@nightly ComfyUI-TeaCache@1.5.1 WAS Node Suite@1.0.2 ComfyUI-ultimate-openpose-editor@nightly ComfyUI-Dia@unknown ComfyUI-Orpheus@unknown """ EXPECTED_NODES = [ "A3D ComfyUI Integration@1.0.2", "ComfyUI_ACE-Step@1.1.2", "Bjornulf_custom_nodes@1.1.1", "cg-use-everywhere@6.1.0", "ComfyUI-0246@1.1.3", "ComfyUI ArtVenture@nightly", "comfyui-auto-nodes-layout@0.0.1", "ComfyUI-ConDelta@nightly", "ComfyUI-Custom-Scripts@nightly", "ComfyUI-DiaTTS@0.3.0", "ComfyUI-Easy-Use@1.3.0", "ComfyUI F5-TTS@nightly", "ComfyUI-Florence2@1.0.3", "ComfyUI@nightly", "ComfyUI-GGUF@nightly", "ComfyUI-Image-Filters@nightly", "ComfyUI-KJNodes@nightly", "comfyui-lmstudio-image-to-text-node@1.1.14", "ComfyUI-LogicUtils@1.7.2", "ComfyUI-LTXVideo@nightly", "ComfyUI-Manager@nightly", "ComfyUI-MMAudio@1.0.2", "ComfyUI-mxToolkit@0.9.92", "ComfyUI-VideoHelperSuite@1.6.1", "ComfyUI Web Viewer@1.0.32", "comfyui_controlnet_aux@1.0.7", "ComfyUI_IPAdapter_plus@2.0.0", "Prompt Stash@1.2.0", "efficiency-nodes-comfyui@1.0.6", "gguf@2.1.0", "comfyui_HiDream-Sampler@1.0.0", "LF Nodes@0.7.0", "lora-info@1.0.2", "Masquerade Nodes@nightly", "rgthree-comfy@nightly", "ComfyUI-TeaCache@1.5.1", "WAS Node Suite@1.0.2", "ComfyUI-ultimate-openpose-editor@nightly", "ComfyUI-Dia@unknown", "ComfyUI-Orpheus@unknown", ] class TestParseCmOutput: def test_real_output_filters_fetch_lines(self): result = parse_cm_output(CM_OUTPUT_REAL) assert result == EXPECTED_NODES assert len(result) == 40 def test_no_fetch_lines_in_result(self): result = parse_cm_output(CM_OUTPUT_REAL) for node in result: assert not node.startswith("FETCH"), f"FETCH line leaked: {node}" def test_pinned_nodes_excluded(self): pinned = {"ComfyUI-Manager@nightly", "ComfyUI@nightly"} result = parse_cm_output(CM_OUTPUT_REAL, pinned) assert "ComfyUI-Manager@nightly" not in result assert "ComfyUI@nightly" not in result assert len(result) == 38 def test_empty_output(self): assert parse_cm_output("") == [] assert parse_cm_output(" \n \n ") == [] def test_only_fetch_lines(self): output = "FETCH ComfyRegistry Data: 5/85\nFETCH DATA from: foo [DONE]\n" assert parse_cm_output(output) == [] def test_no_fetch_lines(self): output = "NodeA@1.0\nNodeB@nightly\n" assert parse_cm_output(output) == ["NodeA@1.0", "NodeB@nightly"] def test_arbitrary_status_lines_filtered(self): output = "some random status line\nINFO: loading\nNodeA@1.0\nDone.\n" assert parse_cm_output(output) == ["NodeA@1.0"] ================================================ FILE: tests/comfy_cli/command/test_cm_cli_util.py ================================================ import subprocess import sys from unittest.mock import MagicMock, patch import pytest import typer from comfy_cli.command.custom_nodes import cm_cli_util def _make_mock_proc(returncode, stdout_lines=None, stderr_lines=None): """Create a mock Popen process with given returncode and stdout/stderr lines.""" mock_proc = MagicMock() mock_proc.stdout = iter(stdout_lines or []) mock_proc.stderr = iter(stderr_lines or []) mock_proc.wait.return_value = returncode return mock_proc @pytest.fixture(autouse=True) def _clear_find_cm_cli_cache(): cm_cli_util.find_cm_cli.cache_clear() yield cm_cli_util.find_cm_cli.cache_clear() @pytest.fixture() def _cm_cli_env(tmp_path): mock_proc = MagicMock() mock_proc.stdout = iter(["ok\n"]) mock_proc.stderr = iter([]) mock_proc.wait.return_value = 0 with ( patch.object(cm_cli_util.workspace_manager, "workspace_path", str(tmp_path)), patch.object(cm_cli_util.workspace_manager, "set_recent_workspace"), patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") as mock_cfg, patch("comfy_cli.command.custom_nodes.cm_cli_util.check_comfy_repo", return_value=(True, None)), patch("comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python", return_value="/resolved/python"), patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=True), patch("comfy_cli.command.custom_nodes.cm_cli_util.subprocess.Popen", return_value=mock_proc) as mock_popen, patch("comfy_cli.command.custom_nodes.cm_cli_util.DependencyCompiler") as mock_compiler, ): mock_cfg.return_value.get_config_path.return_value = str(tmp_path / "config") mock_compiler.return_value = MagicMock() yield {"mock_popen": mock_popen, "mock_proc": mock_proc, "mock_compiler": mock_compiler} class TestFindCmCli: def test_returns_true_when_module_exists_same_python(self): """When workspace Python == sys.executable, checks importlib.util.find_spec.""" with ( patch.object(cm_cli_util.workspace_manager, "workspace_path", "/fake/workspace"), patch( "comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python", return_value=sys.executable, ), patch("comfy_cli.command.custom_nodes.cm_cli_util.importlib.util.find_spec", return_value=MagicMock()), ): assert cm_cli_util.find_cm_cli() is True def test_returns_true_no_workspace_module_exists(self): """When no workspace, falls back to importlib.util.find_spec.""" with ( patch.object(cm_cli_util.workspace_manager, "workspace_path", None), patch("comfy_cli.command.custom_nodes.cm_cli_util.importlib.util.find_spec", return_value=MagicMock()), ): assert cm_cli_util.find_cm_cli() is True def test_returns_false_when_module_missing(self): with ( patch("comfy_cli.command.custom_nodes.cm_cli_util.importlib.util.find_spec", return_value=None), patch.object(cm_cli_util.workspace_manager, "workspace_path", None), ): assert cm_cli_util.find_cm_cli() is False def test_returns_true_when_found_in_workspace_venv(self): mock_result = MagicMock(returncode=0) with ( patch.object(cm_cli_util.workspace_manager, "workspace_path", "/fake/workspace"), patch( "comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python", return_value="/fake/venv/python", ), patch("comfy_cli.command.custom_nodes.cm_cli_util.subprocess.run", return_value=mock_result), ): assert cm_cli_util.find_cm_cli() is True def test_returns_false_when_missing_from_workspace_venv(self): mock_result = MagicMock(returncode=1) with ( patch.object(cm_cli_util.workspace_manager, "workspace_path", "/fake/workspace"), patch( "comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python", return_value="/fake/venv/python", ), patch("comfy_cli.command.custom_nodes.cm_cli_util.subprocess.run", return_value=mock_result), ): assert cm_cli_util.find_cm_cli() is False def test_workspace_python_checked_first_not_cli_interpreter(self): """Core bug fix: when workspace Python differs, check workspace NOT cli env. Even if importlib.util.find_spec would return True in the CLI env, the function must check the workspace Python since that's what execute_cm_cli() actually uses. """ mock_result = MagicMock(returncode=1) # cm_cli NOT in workspace venv with ( patch.object(cm_cli_util.workspace_manager, "workspace_path", "/fake/workspace"), patch( "comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python", return_value="/fake/venv/python", ), patch("comfy_cli.command.custom_nodes.cm_cli_util.subprocess.run", return_value=mock_result), # find_spec would return True, but should NOT be called when workspace Python differs patch( "comfy_cli.command.custom_nodes.cm_cli_util.importlib.util.find_spec", return_value=MagicMock(), ) as mock_spec, ): assert cm_cli_util.find_cm_cli() is False mock_spec.assert_not_called() def test_result_is_cached(self): with ( patch.object(cm_cli_util.workspace_manager, "workspace_path", None), patch( "comfy_cli.command.custom_nodes.cm_cli_util.importlib.util.find_spec", return_value=MagicMock() ) as mock_spec, ): cm_cli_util.find_cm_cli() cm_cli_util.find_cm_cli() mock_spec.assert_called_once() def test_cache_clear_allows_recheck(self): with ( patch( "comfy_cli.command.custom_nodes.cm_cli_util.importlib.util.find_spec", return_value=None ) as mock_spec, patch.object(cm_cli_util.workspace_manager, "workspace_path", None), ): assert cm_cli_util.find_cm_cli() is False cm_cli_util.find_cm_cli.cache_clear() mock_spec.return_value = MagicMock() assert cm_cli_util.find_cm_cli() is True def test_returns_false_on_subprocess_timeout(self): """When workspace Python check times out, returns False (not fallback to cli).""" with ( patch.object(cm_cli_util.workspace_manager, "workspace_path", "/fake/workspace"), patch( "comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python", return_value="/fake/venv/python", ), patch( "comfy_cli.command.custom_nodes.cm_cli_util.subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="test", timeout=10), ), ): assert cm_cli_util.find_cm_cli() is False class TestResolveManagerGuiMode: def test_returns_config_mode_when_set(self): with patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") as mock_cfg: mock_cfg.return_value.get.side_effect = lambda k: "disable" if k == "manager_gui_mode" else None assert cm_cli_util.resolve_manager_gui_mode() == "disable" def test_legacy_false_returns_disable(self): with patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") as mock_cfg: mock_cfg.return_value.get.side_effect = lambda k: "False" if k == "manager_gui_enabled" else None assert cm_cli_util.resolve_manager_gui_mode() == "disable" def test_legacy_true_returns_enable_gui(self): with patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") as mock_cfg: mock_cfg.return_value.get.side_effect = lambda k: "True" if k == "manager_gui_enabled" else None assert cm_cli_util.resolve_manager_gui_mode() == "enable-gui" def test_legacy_boolean_0_returns_disable(self): with patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") as mock_cfg: mock_cfg.return_value.get.side_effect = lambda k: "0" if k == "manager_gui_enabled" else None assert cm_cli_util.resolve_manager_gui_mode() == "disable" def test_no_config_manager_available_returns_enable_gui(self): with ( patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") as mock_cfg, patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=True), ): mock_cfg.return_value.get.return_value = None assert cm_cli_util.resolve_manager_gui_mode() == "enable-gui" def test_no_config_no_manager_returns_not_installed_value(self): with ( patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") as mock_cfg, patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=False), ): mock_cfg.return_value.get.return_value = None assert cm_cli_util.resolve_manager_gui_mode("not-installed") == "not-installed" def test_no_config_no_manager_returns_none_by_default(self): with ( patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") as mock_cfg, patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=False), ): mock_cfg.return_value.get.return_value = None assert cm_cli_util.resolve_manager_gui_mode() is None class TestExecuteCmCli: def test_no_workspace_raises_exit(self): with ( patch.object(cm_cli_util.workspace_manager, "workspace_path", None), pytest.raises(typer.Exit), ): cm_cli_util.execute_cm_cli(["show"]) def test_no_cm_cli_raises_exit(self): with ( patch.object(cm_cli_util.workspace_manager, "workspace_path", "/workspace"), patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=False), pytest.raises(typer.Exit), ): cm_cli_util.execute_cm_cli(["show"]) def test_happy_path_returns_stdout(self, _cm_cli_env): result = cm_cli_util.execute_cm_cli(["show", "installed"]) assert result == "ok\n" def test_cmd_uses_python_m_cm_cli(self, _cm_cli_env): cm_cli_util.execute_cm_cli(["show"]) cmd = _cm_cli_env["mock_popen"].call_args[0][0] assert cmd[:3] == ["/resolved/python", "-m", "cm_cli"] def test_channel_appended(self, _cm_cli_env): cm_cli_util.execute_cm_cli(["show"], channel="stable") cmd = _cm_cli_env["mock_popen"].call_args[0][0] assert "--channel" in cmd assert cmd[cmd.index("--channel") + 1] == "stable" def test_uv_compile_flag(self, _cm_cli_env): cm_cli_util.execute_cm_cli(["install", "node"], uv_compile=True) cmd = _cm_cli_env["mock_popen"].call_args[0][0] assert "--uv-compile" in cmd def test_fast_deps_adds_no_deps(self, _cm_cli_env): cm_cli_util.execute_cm_cli(["install", "node"], fast_deps=True) cmd = _cm_cli_env["mock_popen"].call_args[0][0] assert "--no-deps" in cmd def test_no_deps_adds_no_deps(self, _cm_cli_env): cm_cli_util.execute_cm_cli(["install", "node"], no_deps=True) cmd = _cm_cli_env["mock_popen"].call_args[0][0] assert "--no-deps" in cmd def test_uv_compile_takes_precedence_over_fast_deps(self, _cm_cli_env): cm_cli_util.execute_cm_cli(["install", "node"], uv_compile=True, fast_deps=True) cmd = _cm_cli_env["mock_popen"].call_args[0][0] assert "--uv-compile" in cmd assert "--no-deps" not in cmd def test_mode_appended(self, _cm_cli_env): cm_cli_util.execute_cm_cli(["install", "node"], mode="remote") cmd = _cm_cli_env["mock_popen"].call_args[0][0] assert "--mode" in cmd assert cmd[cmd.index("--mode") + 1] == "remote" def test_error_returncode_1_returns_none(self, tmp_path): mock_proc = _make_mock_proc(1) with ( patch.object(cm_cli_util.workspace_manager, "workspace_path", str(tmp_path)), patch.object(cm_cli_util.workspace_manager, "set_recent_workspace"), patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") as mock_cfg, patch("comfy_cli.command.custom_nodes.cm_cli_util.check_comfy_repo", return_value=(True, None)), patch("comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python", return_value="/python"), patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=True), patch("comfy_cli.command.custom_nodes.cm_cli_util.subprocess.Popen", return_value=mock_proc), ): mock_cfg.return_value.get_config_path.return_value = str(tmp_path) result = cm_cli_util.execute_cm_cli(["install", "node"]) assert result is None def test_error_returncode_2_returns_none(self, tmp_path): mock_proc = _make_mock_proc(2) with ( patch.object(cm_cli_util.workspace_manager, "workspace_path", str(tmp_path)), patch.object(cm_cli_util.workspace_manager, "set_recent_workspace"), patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") as mock_cfg, patch("comfy_cli.command.custom_nodes.cm_cli_util.check_comfy_repo", return_value=(True, None)), patch("comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python", return_value="/python"), patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=True), patch("comfy_cli.command.custom_nodes.cm_cli_util.subprocess.Popen", return_value=mock_proc), ): mock_cfg.return_value.get_config_path.return_value = str(tmp_path) result = cm_cli_util.execute_cm_cli(["install", "node"]) assert result is None def test_error_other_returncode_raises(self, tmp_path): mock_proc = _make_mock_proc(42) with ( patch.object(cm_cli_util.workspace_manager, "workspace_path", str(tmp_path)), patch.object(cm_cli_util.workspace_manager, "set_recent_workspace"), patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") as mock_cfg, patch("comfy_cli.command.custom_nodes.cm_cli_util.check_comfy_repo", return_value=(True, None)), patch("comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python", return_value="/python"), patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=True), patch("comfy_cli.command.custom_nodes.cm_cli_util.subprocess.Popen", return_value=mock_proc), pytest.raises(subprocess.CalledProcessError, match="42"), ): mock_cfg.return_value.get_config_path.return_value = str(tmp_path) cm_cli_util.execute_cm_cli(["install", "node"]) def test_raise_on_error_reraises(self, tmp_path): mock_proc = _make_mock_proc(1) with ( patch.object(cm_cli_util.workspace_manager, "workspace_path", str(tmp_path)), patch.object(cm_cli_util.workspace_manager, "set_recent_workspace"), patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") as mock_cfg, patch("comfy_cli.command.custom_nodes.cm_cli_util.check_comfy_repo", return_value=(True, None)), patch("comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python", return_value="/python"), patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=True), patch("comfy_cli.command.custom_nodes.cm_cli_util.subprocess.Popen", return_value=mock_proc), pytest.raises(subprocess.CalledProcessError), ): mock_cfg.return_value.get_config_path.return_value = str(tmp_path) cm_cli_util.execute_cm_cli(["install", "node"], raise_on_error=True) def test_fast_deps_triggers_dependency_compiler(self, tmp_path): mock_proc = MagicMock() mock_proc.stdout = iter(["ok\n"]) mock_proc.stderr = iter([]) mock_proc.wait.return_value = 0 with ( patch.object(cm_cli_util.workspace_manager, "workspace_path", str(tmp_path)), patch.object(cm_cli_util.workspace_manager, "set_recent_workspace"), patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") as mock_cfg, patch("comfy_cli.command.custom_nodes.cm_cli_util.check_comfy_repo", return_value=(True, None)), patch("comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python", return_value="/python"), patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=True), patch("comfy_cli.command.custom_nodes.cm_cli_util.subprocess.Popen", return_value=mock_proc), patch("comfy_cli.command.custom_nodes.cm_cli_util.DependencyCompiler") as mock_compiler, ): mock_cfg.return_value.get_config_path.return_value = str(tmp_path) mock_instance = MagicMock() mock_compiler.return_value = mock_instance cm_cli_util.execute_cm_cli(["install", "node"], fast_deps=True) mock_compiler.assert_called_once() mock_instance.compile_deps.assert_called_once() mock_instance.install_deps.assert_called_once() def test_fast_deps_non_dependency_cmd_skips_compiler(self, tmp_path): mock_proc = MagicMock() mock_proc.stdout = iter(["ok\n"]) mock_proc.stderr = iter([]) mock_proc.wait.return_value = 0 with ( patch.object(cm_cli_util.workspace_manager, "workspace_path", str(tmp_path)), patch.object(cm_cli_util.workspace_manager, "set_recent_workspace"), patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") as mock_cfg, patch("comfy_cli.command.custom_nodes.cm_cli_util.check_comfy_repo", return_value=(True, None)), patch("comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python", return_value="/python"), patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=True), patch("comfy_cli.command.custom_nodes.cm_cli_util.subprocess.Popen", return_value=mock_proc), patch("comfy_cli.command.custom_nodes.cm_cli_util.DependencyCompiler") as mock_compiler, ): mock_cfg.return_value.get_config_path.return_value = str(tmp_path) cm_cli_util.execute_cm_cli(["show", "all"], fast_deps=True) mock_compiler.assert_not_called() def test_sets_comfyui_path_env(self, _cm_cli_env): cm_cli_util.execute_cm_cli(["show"]) env = _cm_cli_env["mock_popen"].call_args[1]["env"] assert "COMFYUI_PATH" in env def test_captures_stderr_via_pipe(self, _cm_cli_env): """Verify stderr is captured via PIPE (not inherited) to avoid swallowing errors.""" cm_cli_util.execute_cm_cli(["show"]) kwargs = _cm_cli_env["mock_popen"].call_args[1] assert kwargs["stderr"] == subprocess.PIPE ================================================ FILE: tests/comfy_cli/command/test_code_search.py ================================================ """Tests for the code-search command.""" import json from unittest.mock import MagicMock, patch import pytest import requests from typer.testing import CliRunner from comfy_cli.command.code_search import ( API_URL, DEFAULT_COUNT, REQUEST_TIMEOUT, _build_query, _fetch_results, _format_results, _get_stats, _print_results, app, ) runner = CliRunner() # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- _REPO_INFO = { "name": "github.com/Comfy-Org/ComfyUI", "defaultBranch": { "name": "refs/heads/main", "displayName": "main", "target": {"commit": {"oid": "abc123def456", "abbreviatedOID": "abc123d"}}, }, } @pytest.fixture def search_response(): """A realistic Sourcegraph search node.""" return { "stats": { "approximateResultCount": "42", "languages": [{"name": "Python", "totalBytes": 1234, "totalLines": 100}], }, "results": { "matchCount": 3, "limitHit": False, "approximateResultCount": "42", "elapsedMilliseconds": 50, "results": [ { "__typename": "FileMatch", "repository": _REPO_INFO, "file": {"path": "nodes.py"}, "lineMatches": [ {"preview": "class LoadImage:", "lineNumber": 41, "offsetAndLengths": [[6, 9]]}, { "preview": " def load_image(self, image):", "lineNumber": 55, "offsetAndLengths": [[8, 10]], }, ], }, { "__typename": "FileMatch", "repository": _REPO_INFO, "file": {"path": "server.py"}, "lineMatches": [ {"preview": "from nodes import LoadImage", "lineNumber": 9, "offsetAndLengths": [[20, 9]]}, ], }, ], }, } @pytest.fixture def raw_api_response(search_response): """Full API response wrapping the search node.""" return {"data": {"search": search_response}} @pytest.fixture def empty_search(): """A search node with no results.""" return { "stats": {"approximateResultCount": "0", "languages": []}, "results": { "matchCount": 0, "limitHit": False, "approximateResultCount": "0", "elapsedMilliseconds": 10, "results": [], }, } @pytest.fixture def empty_api_response(empty_search): return {"data": {"search": empty_search}} @pytest.fixture def limit_hit_search(search_response): search_response["results"]["limitHit"] = True return search_response @pytest.fixture def limit_hit_response(limit_hit_search): return {"data": {"search": limit_hit_search}} # --------------------------------------------------------------------------- # _build_query tests # --------------------------------------------------------------------------- class TestBuildQuery: def test_simple_query(self): assert _build_query("LoadImage", None, DEFAULT_COUNT) == f"type:file count:{DEFAULT_COUNT} LoadImage" def test_with_repo_short_name(self): result = _build_query("LoadImage", "ComfyUI", DEFAULT_COUNT) assert result == f"repo:^Comfy\\-Org/ComfyUI$ type:file count:{DEFAULT_COUNT} LoadImage" def test_with_repo_full_name(self): result = _build_query("LoadImage", "Comfy-Org/ComfyUI", DEFAULT_COUNT) assert result == f"repo:^Comfy\\-Org/ComfyUI$ type:file count:{DEFAULT_COUNT} LoadImage" def test_with_custom_count(self): result = _build_query("LoadImage", None, 50) assert result == "type:file count:50 LoadImage" def test_with_repo_and_count(self): result = _build_query("LoadImage", "ComfyUI", 100) assert result == "repo:^Comfy\\-Org/ComfyUI$ type:file count:100 LoadImage" def test_user_type_filter_preserved(self): """Don't inject type:file when the user already specified a type: filter.""" result = _build_query("type:commit fix bug", None, DEFAULT_COUNT) assert "type:file" not in result assert result == f"count:{DEFAULT_COUNT} type:commit fix bug" def test_user_type_file_not_duplicated(self): result = _build_query("type:file LoadImage", None, DEFAULT_COUNT) assert result.count("type:file") == 1 # --------------------------------------------------------------------------- # _format_results tests # --------------------------------------------------------------------------- class TestFormatResults: def test_formats_valid_results(self, search_response): results = _format_results(search_response) assert len(results) == 2 first = results[0] assert first["repository"] == "Comfy-Org/ComfyUI" assert first["file"] == "nodes.py" assert first["branch"] == "main" assert first["commit"] == "abc123def456" assert first["file_url"] == "https://github.com/Comfy-Org/ComfyUI/blob/abc123def456/nodes.py" assert len(first["matches"]) == 2 match = first["matches"][0] assert match["line"] == 42 # lineNumber + 1 assert match["preview"] == "class LoadImage:" assert match["url"] == f"{first['file_url']}#L42" def test_empty_results(self, empty_search): assert _format_results(empty_search) == [] def test_skips_results_without_repo(self): search = {"results": {"results": [{"__typename": "FileMatch", "repository": None, "file": {"path": "x.py"}}]}} assert _format_results(search) == [] def test_skips_results_without_file(self): search = { "results": { "results": [ {"__typename": "FileMatch", "repository": {"name": "github.com/Comfy-Org/ComfyUI"}, "file": None} ] } } assert _format_results(search) == [] def test_handles_missing_branch_info(self): search = { "results": { "results": [ { "__typename": "FileMatch", "repository": {"name": "github.com/Comfy-Org/ComfyUI", "defaultBranch": None}, "file": {"path": "test.py"}, "lineMatches": [{"preview": "hello", "lineNumber": 0, "offsetAndLengths": []}], } ] } } results = _format_results(search) assert len(results) == 1 assert results[0]["branch"] == "main" assert results[0]["commit"] == "" assert "blob/main/" in results[0]["matches"][0]["url"] def test_handles_completely_empty_response(self): assert _format_results({}) == [] def test_handles_no_line_matches(self): search = { "results": { "results": [ { "__typename": "FileMatch", "repository": {"name": "github.com/Comfy-Org/ComfyUI", "defaultBranch": None}, "file": {"path": "test.py"}, "lineMatches": None, } ] } } results = _format_results(search) assert len(results) == 1 assert results[0]["matches"] == [] # --------------------------------------------------------------------------- # _get_stats tests # --------------------------------------------------------------------------- class TestGetStats: def test_extracts_stats(self, search_response): stats = _get_stats(search_response) assert stats["approximate_count"] == "42" assert stats["match_count"] == 3 assert stats["limit_hit"] is False def test_empty_response(self): stats = _get_stats({}) assert stats["approximate_count"] == "0" assert stats["match_count"] == 0 assert stats["limit_hit"] is False def test_limit_hit(self, limit_hit_search): stats = _get_stats(limit_hit_search) assert stats["limit_hit"] is True # --------------------------------------------------------------------------- # _fetch_results tests # --------------------------------------------------------------------------- class TestFetchResults: @patch("comfy_cli.command.code_search.requests.get") def test_successful_fetch(self, mock_get, raw_api_response): mock_response = MagicMock() mock_response.json.return_value = raw_api_response mock_response.raise_for_status.return_value = None mock_get.return_value = mock_response result = _fetch_results("LoadImage") mock_get.assert_called_once_with(API_URL, params={"query": "LoadImage"}, timeout=REQUEST_TIMEOUT) assert result == raw_api_response @patch("comfy_cli.command.code_search.requests.get") def test_http_error_propagates(self, mock_get): mock_response = MagicMock() mock_response.raise_for_status.side_effect = requests.HTTPError(response=MagicMock(status_code=500)) mock_get.return_value = mock_response with pytest.raises(requests.HTTPError): _fetch_results("LoadImage") @patch("comfy_cli.command.code_search.requests.get") def test_timeout_propagates(self, mock_get): mock_get.side_effect = requests.Timeout("timed out") with pytest.raises(requests.Timeout): _fetch_results("LoadImage") @patch("comfy_cli.command.code_search.requests.get") def test_connection_error_propagates(self, mock_get): mock_get.side_effect = requests.ConnectionError("no connection") with pytest.raises(requests.ConnectionError): _fetch_results("LoadImage") # --------------------------------------------------------------------------- # _print_results tests # --------------------------------------------------------------------------- class TestPrintResults: def test_json_output(self, capsys, search_response): results = _format_results(search_response) stats = _get_stats(search_response) _print_results(results, stats, json_output=True) output = capsys.readouterr().out parsed = json.loads(output) assert "stats" in parsed assert "results" in parsed assert len(parsed["results"]) == 2 def test_empty_results_message(self, capsys): _print_results([], {"approximate_count": "0", "match_count": 0, "limit_hit": False}, json_output=False) output = capsys.readouterr().out assert "No results found" in output def test_formatted_output_contains_file_info(self, capsys, search_response): results = _format_results(search_response) stats = _get_stats(search_response) _print_results(results, stats, json_output=False) output = capsys.readouterr().out assert "Comfy-Org/ComfyUI" in output assert "nodes.py" in output assert "class LoadImage:" in output def test_limit_hit_message(self, capsys, limit_hit_search): results = _format_results(limit_hit_search) stats = _get_stats(limit_hit_search) _print_results(results, stats, json_output=False) output = capsys.readouterr().out assert "limit hit" in output def test_non_tty_prints_file_url_once_and_no_per_line_urls(self, capsys, search_response): """Non-TTY output: one URL per file, no per-match URLs, no OSC 8 escapes.""" with patch("comfy_cli.command.code_search.sys.stdout.isatty", return_value=False): results = _format_results(search_response) stats = _get_stats(search_response) _print_results(results, stats, json_output=False) output = capsys.readouterr().out # File URL printed once per file (2 files in fixture). assert output.count("https://github.com/Comfy-Org/ComfyUI/blob/abc123def456/nodes.py") == 1 assert "blob/abc123def456/nodes.py" in output assert "blob/abc123def456/server.py" in output # Per-line anchors must NOT appear in non-TTY mode. assert "#L42" not in output assert "#L56" not in output # No OSC 8 escape sequences. assert "\x1b]8;" not in output def test_tty_emits_osc8_and_hides_urls(self, search_response): """TTY output: OSC 8 escapes present, URLs not shown as plain text.""" import io import re from rich.console import Console buf = io.StringIO() fake_console = Console(file=buf, force_terminal=True, width=200, color_system="truecolor") with ( patch("comfy_cli.command.code_search.console", fake_console), patch("comfy_cli.command.code_search.sys.stdout.isatty", return_value=True), ): results = _format_results(search_response) stats = _get_stats(search_response) _print_results(results, stats, json_output=False) output = buf.getvalue() # OSC 8 hyperlink sequences must be present. assert "\x1b]8;" in output # Strip OSC 8 and SGR escape sequences to get visible text only. visible = re.sub(r"\x1b\][^\x1b\x07]*(?:\x07|\x1b\\)", "", output) visible = re.sub(r"\x1b\[[0-9;]*m", "", visible) # Raw URLs must NOT appear in visible output (they're inside OSC 8 payloads). assert "https://github.com/" not in visible # Header and line content must be rendered. assert "Comfy-Org/ComfyUI / nodes.py" in visible assert "L 42" in visible assert "class LoadImage:" in visible def test_non_tty_ignores_force_color_env(self, capsys, search_response, monkeypatch): """FORCE_COLOR / TTY_COMPATIBLE must not leak OSC 8 into a piped stream.""" monkeypatch.setenv("FORCE_COLOR", "1") monkeypatch.setenv("TTY_COMPATIBLE", "1") with patch("comfy_cli.command.code_search.sys.stdout.isatty", return_value=False): results = _format_results(search_response) stats = _get_stats(search_response) _print_results(results, stats, json_output=False) output = capsys.readouterr().out assert "\x1b]8;" not in output # --------------------------------------------------------------------------- # CLI integration tests (via typer runner) # --------------------------------------------------------------------------- class TestCodeSearchCLI: @patch("comfy_cli.command.code_search._fetch_results") def test_basic_search(self, mock_fetch, raw_api_response): mock_fetch.return_value = raw_api_response result = runner.invoke(app, ["LoadImage"]) assert result.exit_code == 0 assert "Comfy-Org/ComfyUI" in result.output mock_fetch.assert_called_once_with(f"type:file count:{DEFAULT_COUNT} LoadImage") @patch("comfy_cli.command.code_search._fetch_results") def test_search_with_repo(self, mock_fetch, raw_api_response): mock_fetch.return_value = raw_api_response result = runner.invoke(app, ["--repo", "ComfyUI", "LoadImage"]) assert result.exit_code == 0 mock_fetch.assert_called_once_with(f"repo:^Comfy\\-Org/ComfyUI$ type:file count:{DEFAULT_COUNT} LoadImage") @patch("comfy_cli.command.code_search._fetch_results") def test_search_with_count(self, mock_fetch, raw_api_response): mock_fetch.return_value = raw_api_response result = runner.invoke(app, ["--count", "50", "LoadImage"]) assert result.exit_code == 0 mock_fetch.assert_called_once_with("type:file count:50 LoadImage") @patch("comfy_cli.command.code_search._fetch_results") def test_search_json_output(self, mock_fetch, raw_api_response): mock_fetch.return_value = raw_api_response result = runner.invoke(app, ["--json", "LoadImage"]) assert result.exit_code == 0 parsed = json.loads(result.output) assert "results" in parsed @patch("comfy_cli.command.code_search._fetch_results") def test_search_no_results(self, mock_fetch, empty_api_response): mock_fetch.return_value = empty_api_response result = runner.invoke(app, ["nonexistent_xyz_query"]) assert result.exit_code == 0 assert "No results found" in result.output @patch("comfy_cli.command.code_search._fetch_results") def test_connection_error(self, mock_fetch): mock_fetch.side_effect = requests.ConnectionError("no connection") result = runner.invoke(app, ["LoadImage"]) assert result.exit_code == 1 assert "Could not connect" in result.output @patch("comfy_cli.command.code_search._fetch_results") def test_timeout_error(self, mock_fetch): mock_fetch.side_effect = requests.Timeout("timed out") result = runner.invoke(app, ["LoadImage"]) assert result.exit_code == 1 assert "timed out" in result.output @patch("comfy_cli.command.code_search._fetch_results") def test_http_error(self, mock_fetch): mock_response = MagicMock() mock_response.status_code = 503 mock_fetch.side_effect = requests.HTTPError(response=mock_response) result = runner.invoke(app, ["LoadImage"]) assert result.exit_code == 1 assert "503" in result.output @patch("comfy_cli.command.code_search._fetch_results") def test_http_error_no_response(self, mock_fetch): mock_fetch.side_effect = requests.HTTPError(response=None) result = runner.invoke(app, ["LoadImage"]) assert result.exit_code == 1 assert "unknown" in result.output @patch("comfy_cli.command.code_search._fetch_results") def test_short_options(self, mock_fetch, raw_api_response): mock_fetch.return_value = raw_api_response result = runner.invoke(app, ["-r", "ComfyUI", "-n", "30", "-j", "LoadImage"]) assert result.exit_code == 0 mock_fetch.assert_called_once_with("repo:^Comfy\\-Org/ComfyUI$ type:file count:30 LoadImage") parsed = json.loads(result.output) assert "results" in parsed # --------------------------------------------------------------------------- # Root CLI wiring smoke tests # --------------------------------------------------------------------------- class TestRootCLIWiring: """Smoke tests verifying code-search and cs alias are wired into the root app.""" @patch("comfy_cli.tracking.prompt_tracking_consent") @patch("comfy_cli.cmdline.workspace_manager") @patch("comfy_cli.command.code_search._fetch_results") def test_code_search_registered(self, mock_fetch, mock_ws, mock_track, raw_api_response): from comfy_cli.cmdline import app as root_app mock_fetch.return_value = raw_api_response result = runner.invoke(root_app, ["code-search", "--json", "LoadImage"]) assert result.exit_code == 0, result.output parsed = json.loads(result.output) assert "results" in parsed @patch("comfy_cli.tracking.prompt_tracking_consent") @patch("comfy_cli.cmdline.workspace_manager") @patch("comfy_cli.command.code_search._fetch_results") def test_cs_alias_registered(self, mock_fetch, mock_ws, mock_track, raw_api_response): from comfy_cli.cmdline import app as root_app mock_fetch.return_value = raw_api_response result = runner.invoke(root_app, ["cs", "--json", "LoadImage"]) assert result.exit_code == 0, result.output parsed = json.loads(result.output) assert "results" in parsed ================================================ FILE: tests/comfy_cli/command/test_command.py ================================================ import os from unittest.mock import patch import pytest from typer.testing import CliRunner from comfy_cli.cmdline import app, g_exclusivity, g_gpu_exclusivity @pytest.fixture(scope="function") def runner(): g_exclusivity.reset_for_testing() g_gpu_exclusivity.reset_for_testing() return CliRunner() @pytest.fixture(scope="function") def mock_execute(): with patch("comfy_cli.command.install.execute") as mock: yield mock @pytest.fixture(scope="function") def mock_prompt_select_enum(): def mocked_prompt_select_enum(question: str, choices: list, force_prompting: bool = False): return choices[0] with patch( "comfy_cli.ui.prompt_select_enum", new=mocked_prompt_select_enum, ) as mock: yield mock @pytest.fixture(autouse=True) def mock_tracking_consent(): with patch("comfy_cli.tracking.prompt_tracking_consent"): yield @pytest.mark.parametrize( "cmd", [ ["--here", "install"], ["--workspace", "./ComfyUI", "install"], ], ) def test_install_here(cmd, runner, mock_execute, mock_prompt_select_enum): result = runner.invoke(app, cmd) assert result.exit_code == 0, result.stdout args, _ = mock_execute.call_args url, comfy_path, *_ = args assert url == "https://github.com/comfyanonymous/ComfyUI" assert comfy_path == os.path.join(os.getcwd(), "ComfyUI") def test_version(runner): result = runner.invoke(app, ["-v"]) assert result.exit_code == 0 assert "0.0.0" in result.stdout @pytest.fixture def mock_run_execute(): with patch("comfy_cli.command.run.execute") as mock: yield mock def _write_workflow(tmp_path): wf = tmp_path / "wf.json" wf.write_text('{"1": {"class_type": "X", "inputs": {}}}') return str(wf) class TestRunApiKeyResolution: """typer envvar resolution: --api-key + COMFY_API_KEY must reach run.execute().""" def test_envvar_is_picked_up(self, runner, mock_run_execute, tmp_path): wf = _write_workflow(tmp_path) result = runner.invoke(app, ["run", "--workflow", wf], env={"COMFY_API_KEY": "env-key-xyz"}) assert result.exit_code == 0, result.output assert mock_run_execute.call_args.kwargs["api_key"] == "env-key-xyz" def test_flag_overrides_envvar(self, runner, mock_run_execute, tmp_path): wf = _write_workflow(tmp_path) result = runner.invoke( app, ["run", "--workflow", wf, "--api-key", "flag-key-abc"], env={"COMFY_API_KEY": "env-key-xyz"}, ) assert result.exit_code == 0, result.output assert mock_run_execute.call_args.kwargs["api_key"] == "flag-key-abc" def test_absent_resolves_to_none(self, runner, mock_run_execute, tmp_path): wf = _write_workflow(tmp_path) # Explicit empty env to neutralize any host-level COMFY_API_KEY leak. result = runner.invoke(app, ["run", "--workflow", wf], env={"COMFY_API_KEY": ""}) assert result.exit_code == 0, result.output assert mock_run_execute.call_args.kwargs["api_key"] is None def test_envvar_trailing_whitespace_is_stripped(self, runner, mock_run_execute, tmp_path): wf = _write_workflow(tmp_path) result = runner.invoke(app, ["run", "--workflow", wf], env={"COMFY_API_KEY": " sk-abc\n"}) assert result.exit_code == 0, result.output assert mock_run_execute.call_args.kwargs["api_key"] == "sk-abc" def test_whitespace_only_collapses_to_none(self, runner, mock_run_execute, tmp_path): wf = _write_workflow(tmp_path) result = runner.invoke(app, ["run", "--workflow", wf], env={"COMFY_API_KEY": " \n\t"}) assert result.exit_code == 0, result.output assert mock_run_execute.call_args.kwargs["api_key"] is None ================================================ FILE: tests/comfy_cli/command/test_frontend_pr.py ================================================ from unittest.mock import Mock, patch import pytest from typer.testing import CliRunner from comfy_cli.command.install import ( PRInfo, parse_frontend_pr_reference, verify_node_tools, ) @pytest.fixture def runner(): return CliRunner() @pytest.fixture def sample_frontend_pr_info(): return PRInfo( number=456, head_repo_url="https://github.com/testuser/ComfyUI_frontend.git", head_branch="feature-branch", base_repo_url="https://github.com/Comfy-Org/ComfyUI_frontend.git", base_branch="main", title="Add new feature to frontend", user="testuser", mergeable=True, ) class TestFrontendPRReferenceParsing: """Test frontend PR reference parsing functionality""" def test_parse_frontend_pr_number_format(self): """Test parsing #123 format for frontend""" repo_owner, repo_name, pr_number = parse_frontend_pr_reference("#456") assert repo_owner == "Comfy-Org" assert repo_name == "ComfyUI_frontend" assert pr_number == 456 def test_parse_frontend_user_branch_format(self): """Test parsing username:branch format for frontend""" repo_owner, repo_name, pr_number = parse_frontend_pr_reference("testuser:feature-branch") assert repo_owner == "testuser" assert repo_name == "ComfyUI_frontend" assert pr_number is None def test_parse_frontend_github_url_format(self): """Test parsing full GitHub PR URL for frontend""" url = "https://github.com/Comfy-Org/ComfyUI_frontend/pull/789" repo_owner, repo_name, pr_number = parse_frontend_pr_reference(url) assert repo_owner == "Comfy-Org" assert repo_name == "ComfyUI_frontend" assert pr_number == 789 def test_parse_frontend_custom_repo_url(self): """Test parsing URL from custom repository""" url = "https://github.com/customuser/customrepo/pull/123" repo_owner, repo_name, pr_number = parse_frontend_pr_reference(url) assert repo_owner == "customuser" assert repo_name == "customrepo" assert pr_number == 123 def test_parse_frontend_invalid_format(self): """Test parsing invalid format raises ValueError""" with pytest.raises(ValueError, match="Invalid PR reference format"): parse_frontend_pr_reference("invalid-format") def test_parse_frontend_empty_string(self): """Test parsing empty string raises ValueError""" with pytest.raises(ValueError): parse_frontend_pr_reference("") class TestNodeToolsVerification: """Test Node.js tools verification""" @patch("subprocess.run") def test_verify_node_tools_success(self, mock_run): """Test successful Node.js, npm, and pnpm verification""" # Mock successful node, npm, and pnpm commands node_result = Mock() node_result.returncode = 0 node_result.stdout = "v18.0.0" npm_result = Mock() npm_result.returncode = 0 npm_result.stdout = "9.0.0" pnpm_result = Mock() pnpm_result.returncode = 0 pnpm_result.stdout = "8.0.0" mock_run.side_effect = [node_result, npm_result, pnpm_result] assert verify_node_tools() is True assert mock_run.call_count == 3 @patch("subprocess.run") def test_verify_node_tools_missing_node(self, mock_run): """Test when Node.js is not installed""" node_result = Mock() node_result.returncode = 1 mock_run.return_value = node_result assert verify_node_tools() is False mock_run.assert_called_once_with(["node", "--version"], capture_output=True, text=True, check=False) @patch("subprocess.run") def test_verify_node_tools_missing_npm(self, mock_run): """Test when npm is not installed""" node_result = Mock() node_result.returncode = 0 node_result.stdout = "v18.0.0" npm_result = Mock() npm_result.returncode = 1 mock_run.side_effect = [node_result, npm_result] assert verify_node_tools() is False assert mock_run.call_count == 2 @patch("rich.prompt.Confirm.ask") @patch("subprocess.run") def test_verify_node_tools_auto_install_pnpm(self, mock_run, mock_confirm): """Test automatic pnpm installation when user agrees""" # Mock successful node and npm node_result = Mock() node_result.returncode = 0 node_result.stdout = "v18.0.0" npm_result = Mock() npm_result.returncode = 0 npm_result.stdout = "9.0.0" # Mock pnpm not found initially pnpm_missing = Mock() pnpm_missing.returncode = 1 # Mock successful pnpm installation install_result = Mock() install_result.returncode = 0 # Mock pnpm verification after install pnpm_verify = Mock() pnpm_verify.returncode = 0 pnpm_verify.stdout = "8.0.0" mock_run.side_effect = [node_result, npm_result, pnpm_missing, install_result, pnpm_verify] mock_confirm.return_value = True # User agrees to install assert verify_node_tools() is True assert mock_run.call_count == 5 mock_confirm.assert_called_once() @patch("rich.prompt.Confirm.ask") @patch("subprocess.run") def test_verify_node_tools_user_declines_pnpm_install(self, mock_run, mock_confirm): """Test when user declines pnpm installation""" # Mock successful node and npm node_result = Mock() node_result.returncode = 0 node_result.stdout = "v18.0.0" npm_result = Mock() npm_result.returncode = 0 npm_result.stdout = "9.0.0" # Mock pnpm not found pnpm_missing = Mock() pnpm_missing.returncode = 1 mock_run.side_effect = [node_result, npm_result, pnpm_missing] mock_confirm.return_value = False # User declines install assert verify_node_tools() is False assert mock_run.call_count == 3 mock_confirm.assert_called_once() @patch("subprocess.run") def test_verify_node_tools_file_not_found(self, mock_run): """Test when commands are not found""" mock_run.side_effect = FileNotFoundError("node not found") assert verify_node_tools() is False ================================================ FILE: tests/comfy_cli/command/test_launch_frontend_pr.py ================================================ """Tests for launch-time frontend PR functionality""" import json from datetime import datetime, timedelta from pathlib import Path from unittest.mock import Mock, patch import pytest from typer.testing import CliRunner from comfy_cli.cmdline import app from comfy_cli.command.install import PRInfo, handle_temporary_frontend_pr from comfy_cli.pr_cache import PRCache @pytest.fixture def runner(): return CliRunner() @pytest.fixture(autouse=True) def mock_tracking_consent(): with patch("comfy_cli.tracking.prompt_tracking_consent"): yield @pytest.fixture def sample_frontend_pr_info(): return PRInfo( number=789, head_repo_url="https://github.com/testuser/ComfyUI_frontend.git", head_branch="test-feature", base_repo_url="https://github.com/Comfy-Org/ComfyUI_frontend.git", base_branch="main", title="Test feature for frontend", user="testuser", mergeable=True, ) @pytest.fixture def mock_pr_cache(): with patch("comfy_cli.pr_cache.PRCache") as mock_cache_cls: mock_cache = Mock() mock_cache_cls.return_value = mock_cache yield mock_cache class TestLaunchWithFrontendPR: """Test launching with temporary frontend PR""" @patch("comfy_cli.command.install.verify_node_tools") def test_launch_frontend_pr_without_node(self, mock_verify): """Test launch with frontend PR when Node.js is missing""" mock_verify.return_value = False result = handle_temporary_frontend_pr("#123") assert result is None mock_verify.assert_called_once() @patch("comfy_cli.command.install.verify_node_tools") @patch("comfy_cli.command.install.parse_frontend_pr_reference") @patch("comfy_cli.command.install.fetch_pr_info") def test_launch_frontend_pr_with_cache_hit( self, mock_fetch, mock_parse, mock_verify, mock_pr_cache, sample_frontend_pr_info ): """Test launch with cached frontend PR""" mock_verify.return_value = True mock_parse.return_value = ("Comfy-Org", "ComfyUI_frontend", 789) mock_fetch.return_value = sample_frontend_pr_info # Mock cache hit cached_path = Path("/cache/frontend/pr-789/dist") mock_pr_cache.get_cached_frontend_path.return_value = cached_path result = handle_temporary_frontend_pr("#789") assert result == str(cached_path) mock_pr_cache.get_cached_frontend_path.assert_called_once() # Should not build if cache hit mock_pr_cache.save_cache_info.assert_not_called() @patch("pathlib.Path.mkdir") @patch("os.chdir") @patch("subprocess.run") @patch("comfy_cli.command.install.checkout_pr") @patch("comfy_cli.command.install.clone_comfyui") @patch("comfy_cli.command.install.verify_node_tools") @patch("comfy_cli.command.install.parse_frontend_pr_reference") @patch("comfy_cli.command.install.fetch_pr_info") def test_launch_frontend_pr_cache_miss_builds( self, mock_fetch, mock_parse, mock_verify, mock_clone, mock_checkout, mock_run, mock_chdir, mock_mkdir, mock_pr_cache, sample_frontend_pr_info, ): """Test launch builds frontend when not cached""" mock_verify.return_value = True mock_parse.return_value = ("Comfy-Org", "ComfyUI_frontend", 789) mock_fetch.return_value = sample_frontend_pr_info mock_checkout.return_value = True # Mock cache miss mock_pr_cache.get_cached_frontend_path.return_value = None cache_path = Path("/cache/frontend/pr-789") mock_pr_cache.get_frontend_cache_path.return_value = cache_path # Mock successful build mock_run.side_effect = [ Mock(returncode=0), # pnpm install Mock(returncode=0), # vite build ] # Mock dist exists with patch("pathlib.Path.exists") as mock_exists: mock_exists.return_value = True result = handle_temporary_frontend_pr("#789") # Should return built path assert result == str(cache_path / "repo" / "dist") # Should save cache info mock_pr_cache.save_cache_info.assert_called_once_with(sample_frontend_pr_info, cache_path) class TestPRCacheManagement: """Test PR cache functionality""" def test_pr_cache_get_frontend_path(self, sample_frontend_pr_info): """Test getting frontend cache path""" cache = PRCache() path = cache.get_frontend_cache_path(sample_frontend_pr_info) assert "frontend" in str(path) assert "testuser" in str(path) assert "789" in str(path) def test_pr_cache_list_empty(self): """Test listing empty cache""" cache = PRCache() with patch("pathlib.Path.exists", return_value=False): result = cache.list_cached_frontends() assert result == [] def test_pr_cache_clean_specific(self, tmp_path): """Test cleaning specific PR cache""" cache = PRCache() cache.cache_dir = tmp_path / "test-cache" # Create mock cache structure frontend_cache = cache.cache_dir / "frontend" / "pr-123" frontend_cache.mkdir(parents=True) cache_info = frontend_cache / ".cache-info.json" cache_info.write_text('{"pr_number": 123}') # Clean specific PR cache.clean_frontend_cache(123) assert not frontend_cache.exists() def test_pr_cache_age_check(self, sample_frontend_pr_info, tmp_path): """Test cache age validation""" cache = PRCache() cache.cache_dir = tmp_path / "test-cache" cache_path = cache.get_frontend_cache_path(sample_frontend_pr_info) cache_path.mkdir(parents=True) # Create cache info with old timestamp old_time = datetime.now() - timedelta(days=10) cache_info = { "pr_number": sample_frontend_pr_info.number, "pr_title": sample_frontend_pr_info.title, "user": sample_frontend_pr_info.user, "head_branch": sample_frontend_pr_info.head_branch, "cached_at": old_time.isoformat(), } info_path = cache.get_cache_info_path(cache_path) with open(info_path, "w") as f: json.dump(cache_info, f) # Should be invalid due to age assert not cache.is_cache_valid(sample_frontend_pr_info, cache_path) def test_pr_cache_enforce_limits(self, tmp_path): """Test cache limit enforcement""" cache = PRCache() cache.cache_dir = tmp_path / "test-cache" cache.max_cache_items = 3 # Set low limit for testing # Create multiple cache entries for i in range(5): cache_dir = cache.cache_dir / "frontend" / f"pr-{i}" cache_dir.mkdir(parents=True) cache_info = { "pr_number": i, "pr_title": f"Test PR {i}", "cached_at": (datetime.now() - timedelta(hours=i)).isoformat(), } with open(cache_dir / ".cache-info.json", "w") as f: json.dump(cache_info, f) # Enforce limits cache.enforce_cache_limits() # Should only have 3 newest items remaining = list((cache.cache_dir / "frontend").iterdir()) assert len(remaining) == 3 # Check that newest items remain remaining_numbers = sorted([int(d.name.split("-")[1]) for d in remaining]) assert remaining_numbers == [0, 1, 2] # Newest 3 def test_get_cache_age(self): """Test human-readable cache age""" cache = PRCache() # Test various ages now = datetime.now() assert cache.get_cache_age(now.isoformat()) == "just now" age_5_min = (now - timedelta(minutes=5)).isoformat() assert "5 minutes ago" in cache.get_cache_age(age_5_min) age_2_hours = (now - timedelta(hours=2)).isoformat() assert "2 hours ago" in cache.get_cache_age(age_2_hours) age_3_days = (now - timedelta(days=3)).isoformat() assert "3 days ago" in cache.get_cache_age(age_3_days) class TestPRCacheCommands: """Test PR cache CLI commands""" def test_pr_cache_list_command(self, runner): """Test pr-cache list command""" with patch("comfy_cli.command.pr_command.PRCache") as mock_cache_cls: mock_cache = Mock() mock_cache.list_cached_frontends.return_value = [] mock_cache_cls.return_value = mock_cache result = runner.invoke(app, ["pr-cache", "list"]) assert result.exit_code == 0 assert "No cached PR builds found" in result.output def test_pr_cache_clean_command_with_confirmation(self, runner): """Test pr-cache clean command with confirmation""" with patch("comfy_cli.command.pr_command.PRCache") as mock_cache_cls: mock_cache = Mock() mock_cache.list_cached_frontends.return_value = [ {"pr_number": 123, "pr_title": "Test PR"} # Mock some cached items ] mock_cache_cls.return_value = mock_cache # Simulate user saying "no" result = runner.invoke(app, ["pr-cache", "clean"], input="n\n") assert result.exit_code == 0 assert "Cancelled" in result.output mock_cache.clean_frontend_cache.assert_not_called() def test_pr_cache_clean_command_with_yes_flag(self, runner): """Test pr-cache clean command with --yes flag""" with patch("comfy_cli.command.pr_command.PRCache") as mock_cache_cls: mock_cache = Mock() mock_cache_cls.return_value = mock_cache result = runner.invoke(app, ["pr-cache", "clean", "--yes"]) assert result.exit_code == 0 mock_cache.clean_frontend_cache.assert_called_once_with() ================================================ FILE: tests/comfy_cli/command/test_manager_gui.py ================================================ from unittest.mock import MagicMock, patch import pytest import typer from comfy_cli import constants from comfy_cli.command.launch import _get_manager_flags @pytest.fixture() def mock_config_manager(): with patch("comfy_cli.command.custom_nodes.command.ConfigManager") as mock_cls: instance = MagicMock() mock_cls.return_value = instance yield instance @pytest.fixture() def mock_launch_config_manager(): with patch("comfy_cli.command.launch.ConfigManager") as mock_cls: instance = MagicMock() mock_cls.return_value = instance yield instance class TestManagerCommands: def test_disable_manager_sets_config(self, mock_config_manager): from comfy_cli.command.custom_nodes.command import disable_manager disable_manager() mock_config_manager.set.assert_called_once_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable") def test_enable_gui_sets_config(self, mock_config_manager): from comfy_cli.command.custom_nodes.command import enable_gui enable_gui() mock_config_manager.set.assert_called_once_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, "enable-gui") def test_disable_gui_sets_config(self, mock_config_manager): from comfy_cli.command.custom_nodes.command import disable_gui disable_gui() mock_config_manager.set.assert_called_once_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable-gui") def test_enable_legacy_gui_sets_config(self, mock_config_manager): from comfy_cli.command.custom_nodes.command import enable_legacy_gui enable_legacy_gui() mock_config_manager.set.assert_called_once_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, "enable-legacy-gui") class TestGetManagerFlags: @patch("comfy_cli.command.launch.resolve_manager_gui_mode", return_value="disable") def test_disable_mode_returns_empty(self, mock_resolve): result = _get_manager_flags() assert result == [] @patch("comfy_cli.command.launch.find_cm_cli", return_value=True) @patch("comfy_cli.command.launch.resolve_manager_gui_mode", return_value="enable-gui") def test_enable_gui_mode_returns_enable_manager(self, mock_resolve, mock_find): result = _get_manager_flags() assert result == ["--enable-manager"] @patch("comfy_cli.command.launch.find_cm_cli", return_value=True) @patch("comfy_cli.command.launch.resolve_manager_gui_mode", return_value="disable-gui") def test_disable_gui_mode_returns_both_flags(self, mock_resolve, mock_find): result = _get_manager_flags() assert result == ["--enable-manager", "--disable-manager-ui"] @patch("comfy_cli.command.launch.find_cm_cli", return_value=True) @patch("comfy_cli.command.launch.resolve_manager_gui_mode", return_value="enable-legacy-gui") def test_enable_legacy_gui_mode_returns_legacy_flags(self, mock_resolve, mock_find): result = _get_manager_flags() assert result == ["--enable-manager", "--enable-manager-legacy-ui"] @patch("comfy_cli.command.launch.find_cm_cli", return_value=True) @patch("comfy_cli.command.launch.resolve_manager_gui_mode", return_value="unknown-mode") def test_unknown_mode_returns_default_with_warning(self, mock_resolve, mock_find, capsys): result = _get_manager_flags() assert result == ["--enable-manager"] captured = capsys.readouterr() assert "unknown-mode" in captured.out.lower() or "Unknown manager mode" in captured.out @patch("comfy_cli.command.launch.find_cm_cli", return_value=False) @patch("comfy_cli.command.launch.resolve_manager_gui_mode", return_value="enable-gui") def test_enable_mode_without_cmcli_returns_empty(self, mock_resolve, mock_find): """When config is enable-* but cm-cli is not available, return empty list.""" result = _get_manager_flags() assert result == [] mock_find.assert_called_once() @patch("comfy_cli.command.launch.resolve_manager_gui_mode", return_value=None) def test_not_installed_returns_empty(self, mock_resolve): """When resolve returns None (not installed), return empty list.""" result = _get_manager_flags() assert result == [] class TestResolveManagerGuiMode: """Tests for resolve_manager_gui_mode shared helper.""" @patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") def test_returns_configured_mode(self, mock_cm_cls): instance = MagicMock() mock_cm_cls.return_value = instance instance.get.return_value = "disable-gui" from comfy_cli.command.custom_nodes.cm_cli_util import resolve_manager_gui_mode assert resolve_manager_gui_mode() == "disable-gui" @patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=True) @patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") def test_old_config_false_migrates_to_disable(self, mock_cm_cls, mock_find): instance = MagicMock() mock_cm_cls.return_value = instance instance.get.side_effect = lambda key: { constants.CONFIG_KEY_MANAGER_GUI_MODE: None, constants.CONFIG_KEY_MANAGER_GUI_ENABLED: "False", }.get(key) from comfy_cli.command.custom_nodes.cm_cli_util import resolve_manager_gui_mode assert resolve_manager_gui_mode() == "disable" @patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=True) @patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") def test_old_config_true_migrates_to_enable_gui(self, mock_cm_cls, mock_find): instance = MagicMock() mock_cm_cls.return_value = instance instance.get.side_effect = lambda key: { constants.CONFIG_KEY_MANAGER_GUI_MODE: None, constants.CONFIG_KEY_MANAGER_GUI_ENABLED: "True", }.get(key) from comfy_cli.command.custom_nodes.cm_cli_util import resolve_manager_gui_mode assert resolve_manager_gui_mode() == "enable-gui" @patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=True) @patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") def test_no_config_with_cmcli_defaults_to_enable_gui(self, mock_cm_cls, mock_find): instance = MagicMock() mock_cm_cls.return_value = instance instance.get.return_value = None from comfy_cli.command.custom_nodes.cm_cli_util import resolve_manager_gui_mode assert resolve_manager_gui_mode() == "enable-gui" @patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=False) @patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") def test_no_config_no_cmcli_returns_not_installed_value(self, mock_cm_cls, mock_find): instance = MagicMock() mock_cm_cls.return_value = instance instance.get.return_value = None from comfy_cli.command.custom_nodes.cm_cli_util import resolve_manager_gui_mode assert resolve_manager_gui_mode(not_installed_value=None) is None assert resolve_manager_gui_mode(not_installed_value="not-installed") == "not-installed" @patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") def test_old_config_boolean_false_migrates_to_disable(self, mock_cm_cls): """Test backward compatibility with actual boolean False value.""" instance = MagicMock() mock_cm_cls.return_value = instance instance.get.side_effect = lambda key: { constants.CONFIG_KEY_MANAGER_GUI_MODE: None, constants.CONFIG_KEY_MANAGER_GUI_ENABLED: False, }.get(key) from comfy_cli.command.custom_nodes.cm_cli_util import resolve_manager_gui_mode assert resolve_manager_gui_mode() == "disable" @patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=True) @patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") def test_old_config_boolean_true_migrates_to_enable_gui(self, mock_cm_cls, mock_find): """Test backward compatibility with actual boolean True value.""" instance = MagicMock() mock_cm_cls.return_value = instance instance.get.side_effect = lambda key: { constants.CONFIG_KEY_MANAGER_GUI_MODE: None, constants.CONFIG_KEY_MANAGER_GUI_ENABLED: True, }.get(key) from comfy_cli.command.custom_nodes.cm_cli_util import resolve_manager_gui_mode assert resolve_manager_gui_mode() == "enable-gui" class TestLaunchManagerFlagInjection: @patch("comfy_cli.command.launch.launch_comfyui") @patch("comfy_cli.command.launch._get_manager_flags", return_value=["--enable-manager"]) @patch("comfy_cli.command.launch.workspace_manager") @patch("comfy_cli.command.launch.check_for_updates") @patch("os.chdir") def test_launch_injects_enable_manager( self, mock_chdir, mock_updates, mock_ws, mock_get_flags, mock_launch_comfyui ): mock_ws.workspace_path = "/fake/workspace" mock_ws.workspace_type = "default" mock_ws.config_manager.config = {"DEFAULT": {}} from comfy_cli.command.launch import launch launch(background=False, extra=["--port", "8188"]) args, kwargs = mock_launch_comfyui.call_args extra_arg = args[0] assert "--enable-manager" in extra_arg assert "--port" in extra_arg @patch("comfy_cli.command.launch.launch_comfyui") @patch("comfy_cli.command.launch._get_manager_flags", return_value=[]) @patch("comfy_cli.command.launch.workspace_manager") @patch("comfy_cli.command.launch.check_for_updates") @patch("os.chdir") def test_launch_no_inject_when_disabled( self, mock_chdir, mock_updates, mock_ws, mock_get_flags, mock_launch_comfyui ): mock_ws.workspace_path = "/fake/workspace" mock_ws.workspace_type = "default" mock_ws.config_manager.config = {"DEFAULT": {}} from comfy_cli.command.launch import launch launch(background=False, extra=["--port", "8188"]) args, kwargs = mock_launch_comfyui.call_args extra_arg = args[0] assert "--enable-manager" not in extra_arg @patch("comfy_cli.command.launch.launch_comfyui") @patch("comfy_cli.command.launch._get_manager_flags", return_value=["--enable-manager"]) @patch("comfy_cli.command.launch.workspace_manager") @patch("comfy_cli.command.launch.check_for_updates") @patch("os.chdir") def test_launch_injects_when_extra_is_none( self, mock_chdir, mock_updates, mock_ws, mock_get_flags, mock_launch_comfyui ): mock_ws.workspace_path = "/fake/workspace" mock_ws.workspace_type = "not_default" mock_ws.config_manager.config = {"DEFAULT": {}} from comfy_cli.command.launch import launch launch(background=False, extra=None) args, kwargs = mock_launch_comfyui.call_args extra_arg = args[0] assert extra_arg == ["--enable-manager"] @patch("comfy_cli.command.launch.launch_comfyui") @patch("comfy_cli.command.launch._get_manager_flags", return_value=["--enable-manager", "--disable-manager-ui"]) @patch("comfy_cli.command.launch.workspace_manager") @patch("comfy_cli.command.launch.check_for_updates") @patch("os.chdir") def test_launch_injects_disable_gui_flags( self, mock_chdir, mock_updates, mock_ws, mock_get_flags, mock_launch_comfyui ): mock_ws.workspace_path = "/fake/workspace" mock_ws.workspace_type = "not_default" mock_ws.config_manager.config = {"DEFAULT": {}} from comfy_cli.command.launch import launch launch(background=False, extra=None) args, kwargs = mock_launch_comfyui.call_args extra_arg = args[0] assert "--enable-manager" in extra_arg assert "--disable-manager-ui" in extra_arg @patch("comfy_cli.command.launch.launch_comfyui") @patch( "comfy_cli.command.launch._get_manager_flags", return_value=["--enable-manager", "--enable-manager-legacy-ui"] ) @patch("comfy_cli.command.launch.workspace_manager") @patch("comfy_cli.command.launch.check_for_updates") @patch("os.chdir") def test_launch_injects_legacy_gui_flags( self, mock_chdir, mock_updates, mock_ws, mock_get_flags, mock_launch_comfyui ): mock_ws.workspace_path = "/fake/workspace" mock_ws.workspace_type = "not_default" mock_ws.config_manager.config = {"DEFAULT": {}} from comfy_cli.command.launch import launch launch(background=False, extra=None) args, kwargs = mock_launch_comfyui.call_args extra_arg = args[0] assert "--enable-manager" in extra_arg assert "--enable-manager-legacy-ui" in extra_arg class TestMigrateLegacy: @patch("comfy_cli.command.custom_nodes.command.workspace_manager") def test_migrate_legacy_no_workspace_exits(self, mock_ws, mock_config_manager): """When workspace is not set, migrate-legacy should exit with error.""" mock_ws.workspace_path = None from comfy_cli.command.custom_nodes.command import migrate_legacy with pytest.raises(typer.Exit) as exc_info: migrate_legacy(yes=True) assert exc_info.value.exit_code == 1 mock_config_manager.set.assert_not_called() @patch("comfy_cli.command.custom_nodes.command.workspace_manager") def test_migrate_legacy_with_cli_only_mode(self, mock_ws, mock_config_manager, tmp_path): # Setup: create legacy manager with .enable-cli-only-mode and .git custom_nodes = tmp_path / "custom_nodes" legacy_manager = custom_nodes / "ComfyUI-Manager" legacy_manager.mkdir(parents=True) (legacy_manager / ".git").mkdir() (legacy_manager / ".enable-cli-only-mode").touch() mock_ws.workspace_path = str(tmp_path) from comfy_cli.command.custom_nodes.command import migrate_legacy migrate_legacy(yes=True) mock_config_manager.set.assert_called_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable") # Verify moved to .disabled assert not legacy_manager.exists() assert (custom_nodes / ".disabled" / "ComfyUI-Manager").exists() @patch("comfy_cli.command.custom_nodes.command.subprocess.run") @patch("comfy_cli.command.custom_nodes.command.workspace_manager") def test_migrate_legacy_without_cli_only_mode(self, mock_ws, mock_subprocess_run, mock_config_manager, tmp_path): # Setup: create legacy manager with .git but without .enable-cli-only-mode custom_nodes = tmp_path / "custom_nodes" legacy_manager = custom_nodes / "ComfyUI-Manager" legacy_manager.mkdir(parents=True) (legacy_manager / ".git").mkdir() # Create manager_requirements.txt for successful install (tmp_path / constants.MANAGER_REQUIREMENTS_FILE).write_text("comfyui-manager>=4.1b1") mock_ws.workspace_path = str(tmp_path) mock_subprocess_run.return_value = MagicMock(returncode=0) from comfy_cli.command.custom_nodes.command import migrate_legacy migrate_legacy(yes=True) mock_config_manager.set.assert_called_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, "enable-gui") # Verify moved to .disabled assert not legacy_manager.exists() assert (custom_nodes / ".disabled" / "ComfyUI-Manager").exists() @patch("comfy_cli.command.custom_nodes.command.workspace_manager") def test_migrate_legacy_no_legacy_manager(self, mock_ws, mock_config_manager, tmp_path): # Setup: no legacy manager custom_nodes = tmp_path / "custom_nodes" custom_nodes.mkdir(parents=True) mock_ws.workspace_path = str(tmp_path) from comfy_cli.command.custom_nodes.command import migrate_legacy migrate_legacy(yes=True) # Should not call set when no legacy manager found mock_config_manager.set.assert_not_called() @patch("comfy_cli.command.custom_nodes.command.workspace_manager") def test_migrate_legacy_target_exists(self, mock_ws, mock_config_manager, tmp_path): # Setup: both source and target exist custom_nodes = tmp_path / "custom_nodes" legacy_manager = custom_nodes / "ComfyUI-Manager" legacy_manager.mkdir(parents=True) (legacy_manager / ".git").mkdir() (custom_nodes / ".disabled" / "ComfyUI-Manager").mkdir(parents=True) mock_ws.workspace_path = str(tmp_path) from comfy_cli.command.custom_nodes.command import migrate_legacy with pytest.raises(typer.Exit): migrate_legacy(yes=True) @patch("comfy_cli.command.custom_nodes.command.subprocess.run") @patch("comfy_cli.command.custom_nodes.command.workspace_manager") def test_migrate_legacy_lowercase_directory(self, mock_ws, mock_subprocess_run, mock_config_manager, tmp_path): # Setup: create legacy manager with lowercase name and .git custom_nodes = tmp_path / "custom_nodes" legacy_manager = custom_nodes / "comfyui-manager" # lowercase legacy_manager.mkdir(parents=True) (legacy_manager / ".git").mkdir() # Create manager_requirements.txt for successful install (tmp_path / constants.MANAGER_REQUIREMENTS_FILE).write_text("comfyui-manager>=4.1b1") mock_ws.workspace_path = str(tmp_path) mock_subprocess_run.return_value = MagicMock(returncode=0) from comfy_cli.command.custom_nodes.command import migrate_legacy migrate_legacy(yes=True) mock_config_manager.set.assert_called_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, "enable-gui") # Verify moved to .disabled (preserving original case) assert not legacy_manager.exists() assert (custom_nodes / ".disabled" / "comfyui-manager").exists() @patch("comfy_cli.command.custom_nodes.command.resolve_workspace_python", return_value="/workspace/venv/python") @patch("comfy_cli.command.custom_nodes.command.subprocess.run") @patch("comfy_cli.command.custom_nodes.command.workspace_manager") def test_migrate_legacy_installs_manager_requirements( self, mock_ws, mock_subprocess_run, mock_resolve_python, mock_config_manager, tmp_path ): # Setup: create legacy manager with .git and manager_requirements.txt custom_nodes = tmp_path / "custom_nodes" legacy_manager = custom_nodes / "ComfyUI-Manager" legacy_manager.mkdir(parents=True) (legacy_manager / ".git").mkdir() # Create manager_requirements.txt in workspace root (tmp_path / constants.MANAGER_REQUIREMENTS_FILE).write_text("comfyui-manager>=4.1b1") mock_ws.workspace_path = str(tmp_path) mock_subprocess_run.return_value = MagicMock(returncode=0) from comfy_cli.command.custom_nodes.command import migrate_legacy migrate_legacy(yes=True) # Verify pip install was called with workspace Python, NOT sys.executable mock_subprocess_run.assert_called_once() call_args = mock_subprocess_run.call_args[0][0] assert call_args[0] == "/workspace/venv/python" assert "-m" in call_args assert "pip" in call_args assert "install" in call_args assert "-r" in call_args # Verify the requirements file path is included assert any(constants.MANAGER_REQUIREMENTS_FILE in str(arg) for arg in call_args) @patch("comfy_cli.command.custom_nodes.command.subprocess.run") @patch("comfy_cli.command.custom_nodes.command.workspace_manager") def test_migrate_legacy_no_requirements_file(self, mock_ws, mock_subprocess_run, mock_config_manager, tmp_path): # Setup: create legacy manager with .git but NO manager_requirements.txt custom_nodes = tmp_path / "custom_nodes" legacy_manager = custom_nodes / "ComfyUI-Manager" legacy_manager.mkdir(parents=True) (legacy_manager / ".git").mkdir() mock_ws.workspace_path = str(tmp_path) from comfy_cli.command.custom_nodes.command import migrate_legacy migrate_legacy(yes=True) # Verify pip install was NOT called (no requirements file) mock_subprocess_run.assert_not_called() # When requirements file is missing, install fails → set to disable mock_config_manager.set.assert_called_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable") @patch("comfy_cli.command.custom_nodes.command.workspace_manager") def test_migrate_legacy_not_git_repo(self, mock_ws, mock_config_manager, tmp_path): # Setup: create directory without .git (not a git repo) custom_nodes = tmp_path / "custom_nodes" legacy_manager = custom_nodes / "ComfyUI-Manager" legacy_manager.mkdir(parents=True) # No .git directory mock_ws.workspace_path = str(tmp_path) from comfy_cli.command.custom_nodes.command import migrate_legacy migrate_legacy(yes=True) # Should not migrate non-git directories mock_config_manager.set.assert_not_called() # Directory should still exist (not moved) assert legacy_manager.exists() @patch("comfy_cli.command.custom_nodes.command.workspace_manager") def test_migrate_legacy_skips_symlink(self, mock_ws, mock_config_manager, tmp_path): # Setup: create a symlink instead of real directory custom_nodes = tmp_path / "custom_nodes" custom_nodes.mkdir(parents=True) real_dir = tmp_path / "real-manager" real_dir.mkdir() (real_dir / ".git").mkdir() symlink_path = custom_nodes / "ComfyUI-Manager" symlink_path.symlink_to(real_dir) mock_ws.workspace_path = str(tmp_path) from comfy_cli.command.custom_nodes.command import migrate_legacy migrate_legacy(yes=True) # Should not migrate symlinks mock_config_manager.set.assert_not_called() # Symlink should still exist assert symlink_path.is_symlink() @patch("comfy_cli.command.custom_nodes.command.shutil.move") @patch("comfy_cli.command.custom_nodes.command.workspace_manager") def test_migrate_legacy_move_error(self, mock_ws, mock_move, mock_config_manager, tmp_path): # Setup: create legacy manager with .git custom_nodes = tmp_path / "custom_nodes" legacy_manager = custom_nodes / "ComfyUI-Manager" legacy_manager.mkdir(parents=True) (legacy_manager / ".git").mkdir() (custom_nodes / ".disabled").mkdir() mock_ws.workspace_path = str(tmp_path) mock_move.side_effect = OSError("Permission denied") from comfy_cli.command.custom_nodes.command import migrate_legacy with pytest.raises(typer.Exit): migrate_legacy(yes=True) # Config should not be set on move failure mock_config_manager.set.assert_not_called() @patch("comfy_cli.command.custom_nodes.command.ui.prompt_confirm_action") @patch("comfy_cli.command.custom_nodes.command.workspace_manager") def test_migrate_legacy_user_cancels(self, mock_ws, mock_confirm, mock_config_manager, tmp_path): # Setup: create legacy manager with .git custom_nodes = tmp_path / "custom_nodes" legacy_manager = custom_nodes / "ComfyUI-Manager" legacy_manager.mkdir(parents=True) (legacy_manager / ".git").mkdir() mock_ws.workspace_path = str(tmp_path) mock_confirm.return_value = False # User cancels from comfy_cli.command.custom_nodes.command import migrate_legacy migrate_legacy(yes=False) # Should not migrate when user cancels mock_config_manager.set.assert_not_called() # Directory should still exist assert legacy_manager.exists() class TestInstallSkipManager: """Tests for --skip-manager flag setting config to disable.""" @patch("comfy_cli.command.install.update_node_id_cache") @patch("comfy_cli.command.install.pip_install_manager") @patch("comfy_cli.command.install.pip_install_comfyui_dependencies") @patch("comfy_cli.command.install.workspace_manager") @patch("comfy_cli.command.install.WorkspaceManager") @patch("comfy_cli.command.install.check_comfy_repo") @patch("comfy_cli.command.install.clone_comfyui") @patch("comfy_cli.command.install.ui.prompt_confirm_action") @patch("comfy_cli.config_manager.ConfigManager") @patch("os.path.exists") @patch("os.makedirs") @patch("os.chdir") @patch("comfy_cli.command.install.ensure_workspace_python", return_value="/fake/python") def test_skip_manager_sets_disable_config( self, mock_ensure_python, mock_chdir, mock_makedirs, mock_exists, mock_config_manager_cls, mock_confirm, mock_clone, mock_check_repo, mock_ws_cls, mock_ws, mock_pip_deps, mock_pip_manager, mock_update_cache, ): """When --skip-manager is used, config should be set to disable.""" # Setup mocks mock_exists.side_effect = lambda p: p == "/fake/comfy" # repo exists mock_check_repo.return_value = (True, None) mock_ws.skip_prompting = True mock_config_manager = MagicMock() mock_config_manager_cls.return_value = mock_config_manager from comfy_cli.command.install import execute execute( url="https://github.com/comfyanonymous/ComfyUI", comfy_path="/fake/comfy", restore=False, skip_manager=True, # Key flag version="nightly", ) # Verify config was set to disable mock_config_manager.set.assert_called_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable") # Verify pip_install_manager was NOT called mock_pip_manager.assert_not_called() class TestInstallManagerFailure: """Tests for pip_install_manager failure handling.""" @patch("comfy_cli.command.install.update_node_id_cache") @patch("comfy_cli.command.install.pip_install_manager") @patch("comfy_cli.command.install.pip_install_comfyui_dependencies") @patch("comfy_cli.command.install.workspace_manager") @patch("comfy_cli.command.install.WorkspaceManager") @patch("comfy_cli.command.install.check_comfy_repo") @patch("comfy_cli.command.install.clone_comfyui") @patch("comfy_cli.command.install.ui.prompt_confirm_action") @patch("comfy_cli.config_manager.ConfigManager") @patch("os.path.exists") @patch("os.makedirs") @patch("os.chdir") @patch("comfy_cli.command.install.ensure_workspace_python", return_value="/fake/python") def test_manager_install_failure_sets_disable_config( self, mock_ensure_python, mock_chdir, mock_makedirs, mock_exists, mock_config_manager_cls, mock_confirm, mock_clone, mock_check_repo, mock_ws_cls, mock_ws, mock_pip_deps, mock_pip_manager, mock_update_cache, ): """When pip_install_manager fails, config should be set to disable.""" # Setup mocks mock_exists.side_effect = lambda p: p == "/fake/comfy" # repo exists mock_check_repo.return_value = (True, None) mock_ws.skip_prompting = True mock_pip_manager.return_value = False # Manager installation fails mock_config_manager = MagicMock() mock_config_manager_cls.return_value = mock_config_manager from comfy_cli.command.install import execute execute( url="https://github.com/comfyanonymous/ComfyUI", comfy_path="/fake/comfy", restore=False, skip_manager=False, # Try to install manager version="nightly", ) # Verify pip_install_manager was called mock_pip_manager.assert_called_once() # Verify config was set to disable due to failure mock_config_manager.set.assert_called_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable") @patch("comfy_cli.command.install.update_node_id_cache") @patch("comfy_cli.command.install.pip_install_manager") @patch("comfy_cli.command.install.pip_install_comfyui_dependencies") @patch("comfy_cli.command.install.workspace_manager") @patch("comfy_cli.command.install.WorkspaceManager") @patch("comfy_cli.command.install.check_comfy_repo") @patch("comfy_cli.command.install.clone_comfyui") @patch("comfy_cli.command.install.ui.prompt_confirm_action") @patch("comfy_cli.config_manager.ConfigManager") @patch("os.path.exists") @patch("os.makedirs") @patch("os.chdir") @patch("comfy_cli.command.install.ensure_workspace_python", return_value="/fake/python") def test_manager_install_success_does_not_set_disable( self, mock_ensure_python, mock_chdir, mock_makedirs, mock_exists, mock_config_manager_cls, mock_confirm, mock_clone, mock_check_repo, mock_ws_cls, mock_ws, mock_pip_deps, mock_pip_manager, mock_update_cache, ): """When pip_install_manager succeeds, config should NOT be set to disable.""" # Setup mocks mock_exists.side_effect = lambda p: p == "/fake/comfy" # repo exists mock_check_repo.return_value = (True, None) mock_ws.skip_prompting = True mock_pip_manager.return_value = True # Manager installation succeeds mock_config_manager = MagicMock() mock_config_manager_cls.return_value = mock_config_manager from comfy_cli.command.install import execute execute( url="https://github.com/comfyanonymous/ComfyUI", comfy_path="/fake/comfy", restore=False, skip_manager=False, version="nightly", ) # Verify pip_install_manager was called mock_pip_manager.assert_called_once() # Verify config was NOT set to disable mock_config_manager.set.assert_not_called() @patch("comfy_cli.command.install.DependencyCompiler") @patch("comfy_cli.command.install.update_node_id_cache") @patch("comfy_cli.command.install.pip_install_manager") @patch("comfy_cli.command.install.pip_install_comfyui_dependencies") @patch("comfy_cli.command.install.workspace_manager") @patch("comfy_cli.command.install.WorkspaceManager") @patch("comfy_cli.command.install.check_comfy_repo") @patch("comfy_cli.command.install.clone_comfyui") @patch("comfy_cli.command.install.ui.prompt_confirm_action") @patch("comfy_cli.config_manager.ConfigManager") @patch("os.path.exists") @patch("os.makedirs") @patch("os.chdir") @patch("comfy_cli.command.install.ensure_workspace_python", return_value="/fake/python") def test_fast_deps_manager_failure_sets_disable_config( self, mock_ensure_python, mock_chdir, mock_makedirs, mock_exists, mock_config_manager_cls, mock_confirm, mock_clone, mock_check_repo, mock_ws_cls, mock_ws, mock_pip_deps, mock_pip_manager, mock_update_cache, mock_dep_compiler, ): """When fast_deps=True and pip_install_manager fails, config should be set to disable.""" # Setup mocks mock_exists.side_effect = lambda p: p == "/fake/comfy" mock_check_repo.return_value = (True, None) mock_ws.skip_prompting = True mock_pip_manager.return_value = False # Manager installation fails mock_config_manager = MagicMock() mock_config_manager_cls.return_value = mock_config_manager mock_dep_compiler_instance = MagicMock() mock_dep_compiler.return_value = mock_dep_compiler_instance from comfy_cli.command.install import execute execute( url="https://github.com/comfyanonymous/ComfyUI", comfy_path="/fake/comfy", restore=False, skip_manager=False, version="nightly", fast_deps=True, # Use fast_deps path ) # Verify pip_install_manager was called (fast_deps path) mock_pip_manager.assert_called_once() # Verify config was set to disable due to failure mock_config_manager.set.assert_called_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable") class TestPipInstallManagerCacheClear: """Tests for pip_install_manager cache clearing after successful install.""" @patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli") @patch("comfy_cli.command.install.subprocess.run") @patch("os.path.exists", return_value=True) def test_pip_install_manager_clears_cache_on_success(self, mock_exists, mock_run, mock_find_cm_cli): """When pip install succeeds, find_cm_cli cache should be cleared.""" from comfy_cli.command.install import pip_install_manager # Simulate successful pip install mock_run.return_value = MagicMock(returncode=0, stderr="") # Call pip_install_manager result = pip_install_manager("/fake/repo") # Verify success assert result is True # Verify cache_clear was called on the mock mock_find_cm_cli.cache_clear.assert_called_once() @patch("comfy_cli.command.install.subprocess.run") @patch("os.path.exists", return_value=True) def test_pip_install_manager_no_cache_clear_on_failure(self, mock_exists, mock_run): """When pip install fails, cache should not be affected (function returns early).""" from comfy_cli.command.install import pip_install_manager # Simulate failed pip install mock_run.return_value = MagicMock(returncode=1) # Call pip_install_manager result = pip_install_manager("/fake/repo") # Verify failure assert result is False class TestFillPrintTable: """Tests for WorkspaceManager.fill_print_table() method.""" @pytest.fixture() def mock_workspace_config_manager(self): with patch("comfy_cli.workspace_manager.ConfigManager") as mock_cls: instance = MagicMock() mock_cls.return_value = instance yield instance @patch("comfy_cli.command.custom_nodes.cm_cli_util.resolve_manager_gui_mode", return_value="disable") def test_fill_print_table_disable_mode(self, mock_resolve, mock_workspace_config_manager): """When mode is 'disable', status should show Disabled.""" from comfy_cli.workspace_manager import WorkspaceManager ws = WorkspaceManager() ws.workspace_path = "/fake/workspace" result = ws.fill_print_table() assert len(result) == 3 assert result[0][0] == "Current selected workspace" assert result[1][0] == "Manager" assert "Disabled" in result[1][1] assert result[2][0] == "UV Compile Default" @patch("comfy_cli.command.custom_nodes.cm_cli_util.resolve_manager_gui_mode", return_value="enable-gui") def test_fill_print_table_enable_gui_mode(self, mock_resolve, mock_workspace_config_manager): """When mode is 'enable-gui', status should show GUI Enabled.""" from comfy_cli.workspace_manager import WorkspaceManager ws = WorkspaceManager() ws.workspace_path = "/fake/workspace" result = ws.fill_print_table() assert result[1][0] == "Manager" assert "GUI Enabled" in result[1][1] @patch("comfy_cli.command.custom_nodes.cm_cli_util.resolve_manager_gui_mode", return_value="disable-gui") def test_fill_print_table_disable_gui_mode(self, mock_resolve, mock_workspace_config_manager): """When mode is 'disable-gui', status should show GUI Disabled.""" from comfy_cli.workspace_manager import WorkspaceManager ws = WorkspaceManager() ws.workspace_path = "/fake/workspace" result = ws.fill_print_table() assert result[1][0] == "Manager" assert "GUI Disabled" in result[1][1] @patch("comfy_cli.command.custom_nodes.cm_cli_util.resolve_manager_gui_mode", return_value="enable-legacy-gui") def test_fill_print_table_enable_legacy_gui_mode(self, mock_resolve, mock_workspace_config_manager): """When mode is 'enable-legacy-gui', status should show Legacy GUI.""" from comfy_cli.workspace_manager import WorkspaceManager ws = WorkspaceManager() ws.workspace_path = "/fake/workspace" result = ws.fill_print_table() assert result[1][0] == "Manager" assert "Legacy GUI" in result[1][1] @patch("comfy_cli.command.custom_nodes.cm_cli_util.resolve_manager_gui_mode", return_value="not-installed") def test_fill_print_table_not_installed(self, mock_resolve, mock_workspace_config_manager): """When resolve returns 'not-installed', status should show Not Installed.""" from comfy_cli.workspace_manager import WorkspaceManager ws = WorkspaceManager() ws.workspace_path = "/fake/workspace" result = ws.fill_print_table() assert result[1][0] == "Manager" assert "Not Installed" in result[1][1] @patch("comfy_cli.command.custom_nodes.cm_cli_util.resolve_manager_gui_mode", return_value="unknown-mode") def test_fill_print_table_unknown_mode_defaults_to_enable(self, mock_resolve, mock_workspace_config_manager): """When mode is unknown, status should default to GUI Enabled.""" from comfy_cli.workspace_manager import WorkspaceManager ws = WorkspaceManager() ws.workspace_path = "/fake/workspace" result = ws.fill_print_table() assert result[1][0] == "Manager" assert "GUI Enabled" in result[1][1] @patch("comfy_cli.command.custom_nodes.cm_cli_util.resolve_manager_gui_mode", return_value="enable-gui") def test_fill_print_table_uv_compile_enabled(self, mock_resolve, mock_workspace_config_manager): """When uv_compile_default is True, status should show Enabled.""" mock_workspace_config_manager.get.side_effect = lambda key: { constants.CONFIG_KEY_UV_COMPILE_DEFAULT: "True", }.get(key) from comfy_cli.workspace_manager import WorkspaceManager ws = WorkspaceManager() ws.workspace_path = "/fake/workspace" result = ws.fill_print_table() assert result[2][0] == "UV Compile Default" assert "Enabled" in result[2][1] @patch("comfy_cli.command.custom_nodes.cm_cli_util.resolve_manager_gui_mode", return_value="enable-gui") def test_fill_print_table_uv_compile_disabled(self, mock_resolve, mock_workspace_config_manager): """When uv_compile_default is not set, status should show Disabled.""" mock_workspace_config_manager.get.return_value = None from comfy_cli.workspace_manager import WorkspaceManager ws = WorkspaceManager() ws.workspace_path = "/fake/workspace" result = ws.fill_print_table() assert result[2][0] == "UV Compile Default" assert "Disabled" in result[2][1] @patch("comfy_cli.command.custom_nodes.cm_cli_util.resolve_manager_gui_mode", return_value="enable-gui") def test_fill_print_table_uv_compile_lowercase_true(self, mock_resolve, mock_workspace_config_manager): """When uv_compile_default is 'true' (lowercase), status should show Enabled.""" mock_workspace_config_manager.get.return_value = "true" from comfy_cli.workspace_manager import WorkspaceManager ws = WorkspaceManager() ws.workspace_path = "/fake/workspace" result = ws.fill_print_table() assert result[2][0] == "UV Compile Default" assert "Enabled" in result[2][1] @patch("comfy_cli.command.custom_nodes.cm_cli_util.resolve_manager_gui_mode", return_value="enable-gui") def test_fill_print_table_uv_compile_explicit_false(self, mock_resolve, mock_workspace_config_manager): """When uv_compile_default is 'False', status should show Disabled.""" mock_workspace_config_manager.get.return_value = "False" from comfy_cli.workspace_manager import WorkspaceManager ws = WorkspaceManager() ws.workspace_path = "/fake/workspace" result = ws.fill_print_table() assert result[2][0] == "UV Compile Default" assert "Disabled" in result[2][1] class TestResolveUvCompile: """Tests for _resolve_uv_compile() helper function.""" @pytest.fixture() def mock_resolve_config_manager(self): with patch("comfy_cli.command.custom_nodes.command.ConfigManager") as mock_cls: instance = MagicMock() mock_cls.return_value = instance yield instance def test_explicit_true_returns_true(self, mock_resolve_config_manager): """Explicit --uv-compile overrides everything.""" from comfy_cli.command.custom_nodes.command import _resolve_uv_compile assert _resolve_uv_compile(True) is True def test_explicit_false_returns_false(self, mock_resolve_config_manager): """Explicit --no-uv-compile overrides everything.""" from comfy_cli.command.custom_nodes.command import _resolve_uv_compile assert _resolve_uv_compile(False) is False def test_explicit_true_ignores_config(self, mock_resolve_config_manager): """Explicit flag takes priority over config default.""" mock_resolve_config_manager.get.return_value = "False" from comfy_cli.command.custom_nodes.command import _resolve_uv_compile assert _resolve_uv_compile(True) is True mock_resolve_config_manager.get.assert_not_called() def test_none_with_config_true(self, mock_resolve_config_manager): """None (no flag) + config True → True.""" mock_resolve_config_manager.get.return_value = "True" from comfy_cli.command.custom_nodes.command import _resolve_uv_compile assert _resolve_uv_compile(None) is True def test_none_with_config_false(self, mock_resolve_config_manager): """None (no flag) + config False → False.""" mock_resolve_config_manager.get.return_value = "False" from comfy_cli.command.custom_nodes.command import _resolve_uv_compile assert _resolve_uv_compile(None) is False def test_none_with_no_config(self, mock_resolve_config_manager): """None (no flag) + no config → False.""" mock_resolve_config_manager.get.return_value = None from comfy_cli.command.custom_nodes.command import _resolve_uv_compile assert _resolve_uv_compile(None) is False def test_config_true_overridden_by_fast_deps(self, mock_resolve_config_manager): """Config True + --fast-deps → False (silent override).""" mock_resolve_config_manager.get.return_value = "True" from comfy_cli.command.custom_nodes.command import _resolve_uv_compile assert _resolve_uv_compile(None, fast_deps=True) is False def test_config_true_overridden_by_no_deps(self, mock_resolve_config_manager): """Config True + --no-deps → False (silent override).""" mock_resolve_config_manager.get.return_value = "True" from comfy_cli.command.custom_nodes.command import _resolve_uv_compile assert _resolve_uv_compile(None, no_deps=True) is False def test_config_false_with_fast_deps_stays_false(self, mock_resolve_config_manager): """Config False + --fast-deps → False (no change).""" mock_resolve_config_manager.get.return_value = "False" from comfy_cli.command.custom_nodes.command import _resolve_uv_compile assert _resolve_uv_compile(None, fast_deps=True) is False def test_explicit_true_not_affected_by_fast_deps(self, mock_resolve_config_manager): """Explicit --uv-compile is not silently overridden (mutual exclusivity handled elsewhere).""" from comfy_cli.command.custom_nodes.command import _resolve_uv_compile assert _resolve_uv_compile(True, fast_deps=True) is True class TestUvCompileDefaultCommand: """Tests for comfy manager uv-compile-default command.""" def test_uv_compile_default_enable(self, mock_config_manager): from comfy_cli.command.custom_nodes.command import uv_compile_default uv_compile_default(enabled=True) mock_config_manager.set.assert_called_once_with(constants.CONFIG_KEY_UV_COMPILE_DEFAULT, "True") def test_uv_compile_default_disable(self, mock_config_manager): from comfy_cli.command.custom_nodes.command import uv_compile_default uv_compile_default(enabled=False) mock_config_manager.set.assert_called_once_with(constants.CONFIG_KEY_UV_COMPILE_DEFAULT, "False") class TestFindCmCli: """Tests for find_cm_cli() function.""" def test_find_cm_cli_module_found(self): """When cm_cli module exists, should return True.""" with patch("importlib.util.find_spec") as mock_find_spec: mock_find_spec.return_value = MagicMock() # Non-None means module exists # Clear cache before test from comfy_cli.command.custom_nodes.cm_cli_util import find_cm_cli find_cm_cli.cache_clear() result = find_cm_cli() assert result is True mock_find_spec.assert_called_with("cm_cli") def test_find_cm_cli_module_not_found(self): """When cm_cli module doesn't exist, should return False.""" from comfy_cli.command.custom_nodes import cm_cli_util as _cm_cli_util with ( patch("importlib.util.find_spec") as mock_find_spec, patch.object(_cm_cli_util.workspace_manager, "workspace_path", None), ): mock_find_spec.return_value = None # None means module not found from comfy_cli.command.custom_nodes.cm_cli_util import find_cm_cli find_cm_cli.cache_clear() result = find_cm_cli() assert result is False mock_find_spec.assert_called_with("cm_cli") def test_find_cm_cli_cache_behavior(self): """find_cm_cli should cache results and not call find_spec repeatedly.""" with patch("importlib.util.find_spec") as mock_find_spec: mock_find_spec.return_value = MagicMock() from comfy_cli.command.custom_nodes.cm_cli_util import find_cm_cli find_cm_cli.cache_clear() # Call multiple times result1 = find_cm_cli() result2 = find_cm_cli() result3 = find_cm_cli() # All should return True assert result1 is True assert result2 is True assert result3 is True # find_spec should only be called once due to caching assert mock_find_spec.call_count == 1 class TestPipInstallManagerEdgeCases: """Additional edge case tests for pip_install_manager().""" @patch("comfy_cli.command.install.subprocess.run") @patch("os.path.exists", return_value=False) def test_pip_install_manager_requirements_not_found(self, mock_exists, mock_run): """When requirements file doesn't exist, should return False without calling pip.""" from comfy_cli.command.install import pip_install_manager result = pip_install_manager("/fake/repo") assert result is False # subprocess.run should NOT be called mock_run.assert_not_called() class TestValidateComfyuiManager: """Tests for validate_comfyui_manager() function.""" @patch("comfy_cli.command.custom_nodes.command.find_cm_cli", return_value=False) def test_validate_comfyui_manager_exits_when_not_found(self, mock_find_cm_cli): """When cm-cli is not found, should raise typer.Exit with code 1.""" from comfy_cli.command.custom_nodes.command import validate_comfyui_manager with pytest.raises(typer.Exit) as exc_info: validate_comfyui_manager() assert exc_info.value.exit_code == 1 mock_find_cm_cli.assert_called_once() @patch("comfy_cli.command.custom_nodes.command.find_cm_cli", return_value=True) def test_validate_comfyui_manager_passes_when_found(self, mock_find_cm_cli): """When cm-cli is found, should not raise any exception.""" from comfy_cli.command.custom_nodes.command import validate_comfyui_manager # Should not raise validate_comfyui_manager() mock_find_cm_cli.assert_called_once() ================================================ FILE: tests/comfy_cli/command/test_npm_help.py ================================================ """Tests for install command functionality""" from io import StringIO from unittest.mock import patch import pytest from comfy_cli.command.install import _print_npm_not_found_help class TestPrintNpmNotFoundHelp: """Tests for _print_npm_not_found_help function""" @pytest.fixture def capture_output(self): """Fixture to capture rich console output""" output = StringIO() with patch( "comfy_cli.command.install.rprint", side_effect=lambda *args: output.write(str(args[0]) + "\n" if args else "\n"), ): yield output def test_npm_not_found_help_shows_common_message(self, capture_output): """Test that common npm not found message is shown regardless of OS""" with patch("platform.system", return_value="Linux"): _print_npm_not_found_help("v20.0.0") output_text = capture_output.getvalue() assert "npm is not installed or not found in PATH" in output_text assert "npm is a package manager that usually comes bundled with Node.js" in output_text assert "v20.0.0" in output_text assert "After fixing npm, run your comfy command again" in output_text def test_npm_not_found_help_windows(self, capture_output): """Test Windows-specific instructions""" with patch("platform.system", return_value="Windows"): _print_npm_not_found_help("v18.17.0") output_text = capture_output.getvalue() assert "How to fix this on Windows" in output_text assert "Add or remove programs" in output_text assert "Command Prompt or PowerShell" in output_text def test_npm_not_found_help_macos(self, capture_output): """Test macOS-specific instructions""" with patch("platform.system", return_value="Darwin"): _print_npm_not_found_help("v18.17.0") output_text = capture_output.getvalue() assert "How to fix this on macOS" in output_text assert "Homebrew" in output_text assert "brew install node" in output_text assert ".pkg file" in output_text assert "Cmd+Q" in output_text def test_npm_not_found_help_linux(self, capture_output): """Test Linux-specific instructions""" with patch("platform.system", return_value="Linux"): _print_npm_not_found_help("v18.17.0") output_text = capture_output.getvalue() assert "How to fix this on Linux" in output_text assert "sudo apt" in output_text assert "Ubuntu/Debian" in output_text assert "Fedora" in output_text assert "NodeSource" in output_text def test_npm_not_found_help_unknown_os_falls_back_to_linux(self, capture_output): """Test that unknown OS falls back to Linux instructions""" with patch("platform.system", return_value="FreeBSD"): _print_npm_not_found_help("v18.17.0") output_text = capture_output.getvalue() # Should show Linux instructions as fallback assert "How to fix this on Linux" in output_text ================================================ FILE: tests/comfy_cli/command/test_run.py ================================================ import io import json import os import tempfile import urllib.error from unittest.mock import MagicMock, patch import pytest import typer from websocket import WebSocketException, WebSocketTimeoutException from comfy_cli.command.run import ( WorkflowExecution, execute, fetch_object_info, is_ui_workflow, ) @pytest.fixture def workflow(): return { "1": { "class_type": "EmptyLatentImage", "inputs": {"width": 64, "height": 64, "batch_size": 1}, "_meta": {"title": "Empty Latent"}, }, "2": { "class_type": "PreviewAny", "inputs": {"source": ["1", 0]}, "_meta": {"title": "Preview"}, }, } @pytest.fixture def workflow_file(workflow): with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump(workflow, f) f.flush() yield f.name os.unlink(f.name) @pytest.fixture def mock_execution(workflow): progress = MagicMock() progress.add_task.return_value = 0 return WorkflowExecution( workflow=workflow, host="127.0.0.1", port=8188, verbose=False, progress=progress, local_paths=False, timeout=30, ) def _make_msg(msg_type, prompt_id, **data_fields): return json.dumps({"type": msg_type, "data": {"prompt_id": prompt_id, **data_fields}}) class TestIsUiWorkflow: def test_detects_ui_workflow(self): assert is_ui_workflow({"nodes": [{"id": 1}], "links": []}) def test_rejects_api_workflow(self): assert not is_ui_workflow({"1": {"class_type": "X", "inputs": {}}}) def test_rejects_non_dict(self): assert not is_ui_workflow(["nodes", "links"]) assert not is_ui_workflow(None) def test_requires_both_keys(self): assert not is_ui_workflow({"nodes": []}) assert not is_ui_workflow({"links": []}) def test_rejects_api_workflow_with_nodes_and_links_as_keys(self): # A pathological API workflow where node IDs happen to be the strings # "nodes" and "links" should not be mistaken for UI format. api = { "nodes": {"class_type": "Foo", "inputs": {}}, "links": {"class_type": "Bar", "inputs": {}}, } assert not is_ui_workflow(api) def test_rejects_when_values_are_not_lists(self): assert not is_ui_workflow({"nodes": "string", "links": "string"}) assert not is_ui_workflow({"nodes": 1, "links": 2}) def _make_http_error(code: int, body: bytes = b"") -> urllib.error.HTTPError: return urllib.error.HTTPError( url="http://127.0.0.1:8188/object_info", code=code, msg=f"HTTP {code}", hdrs=None, fp=io.BytesIO(body), ) def _ok_response(body: bytes) -> MagicMock: resp = MagicMock() resp.read.return_value = body resp.__enter__ = MagicMock(return_value=resp) resp.__exit__ = MagicMock(return_value=False) return resp class TestFetchObjectInfo: def test_returns_parsed_json_on_success(self): payload = {"KSampler": {"input": {}, "output_node": False}} with patch( "comfy_cli.command.run.request.urlopen", return_value=_ok_response(json.dumps(payload).encode()), ) as mock_open: result = fetch_object_info("127.0.0.1", 8188, timeout=30) assert result == payload assert mock_open.call_args[0][0] == "http://127.0.0.1:8188/object_info" def test_http_error_exits_cleanly(self): with patch( "comfy_cli.command.run.request.urlopen", side_effect=_make_http_error(500, b"server exploded"), ): with pytest.raises(typer.Exit) as exc_info: fetch_object_info("127.0.0.1", 8188, timeout=30) assert exc_info.value.exit_code == 1 def test_network_error_exits_cleanly(self): with patch( "comfy_cli.command.run.request.urlopen", side_effect=urllib.error.URLError("Connection refused"), ): with pytest.raises(typer.Exit) as exc_info: fetch_object_info("127.0.0.1", 8188, timeout=30) assert exc_info.value.exit_code == 1 def test_timeout_exits_cleanly(self): with patch("comfy_cli.command.run.request.urlopen", side_effect=TimeoutError("timed out")): with pytest.raises(typer.Exit) as exc_info: fetch_object_info("127.0.0.1", 8188, timeout=5) assert exc_info.value.exit_code == 1 def test_invalid_json_exits_cleanly(self): with patch( "comfy_cli.command.run.request.urlopen", return_value=_ok_response(b"not json"), ): with pytest.raises(typer.Exit) as exc_info: fetch_object_info("127.0.0.1", 8188, timeout=30) assert exc_info.value.exit_code == 1 class TestWorkflowExecutionAuth: """X-API-Key is the credential the ComfyUI server forwards to Partner Nodes.""" def _make_exec(self, workflow, api_key=None): progress = MagicMock() progress.add_task.return_value = 0 return WorkflowExecution( workflow=workflow, host="127.0.0.1", port=8188, verbose=False, progress=progress, local_paths=False, timeout=30, api_key=api_key, ) def test_queue_embeds_api_key_in_extra_data(self, workflow): ex = self._make_exec(workflow, api_key="sk-secret") with patch("comfy_cli.command.run.request.urlopen") as mock_open: mock_open.return_value.read.return_value = json.dumps({"prompt_id": "abc"}).encode() ex.queue() req = mock_open.call_args[0][0] body = json.loads(req.data) assert body["extra_data"] == {"api_key_comfy_org": "sk-secret"} def test_queue_does_not_send_x_api_key_header(self, workflow): ex = self._make_exec(workflow, api_key="sk-secret") with patch("comfy_cli.command.run.request.urlopen") as mock_open: mock_open.return_value.read.return_value = json.dumps({"prompt_id": "abc"}).encode() ex.queue() req = mock_open.call_args[0][0] assert req.get_header("X-api-key") is None def test_queue_omits_extra_data_when_no_api_key(self, workflow): ex = self._make_exec(workflow) with patch("comfy_cli.command.run.request.urlopen") as mock_open: mock_open.return_value.read.return_value = json.dumps({"prompt_id": "abc"}).encode() ex.queue() req = mock_open.call_args[0][0] body = json.loads(req.data) assert "extra_data" not in body assert body == {"prompt": workflow, "client_id": ex.client_id} class TestWatchExecution: def test_successful_execution(self, mock_execution): prompt_id = "test-prompt" mock_execution.prompt_id = prompt_id messages = [ _make_msg("executing", prompt_id, node="1"), _make_msg("executed", prompt_id, node="1"), _make_msg("executing", prompt_id, node="2"), _make_msg("executed", prompt_id, node="2"), _make_msg("executing", prompt_id, node=None), ] mock_ws = MagicMock() mock_ws.recv.side_effect = messages mock_execution.ws = mock_ws mock_execution.watch_execution() assert len(mock_execution.remaining_nodes) == 0 def test_skips_other_prompt_messages(self, mock_execution): prompt_id = "my-prompt" mock_execution.prompt_id = prompt_id messages = [ _make_msg("executing", "other-prompt", node="1"), _make_msg("executing", prompt_id, node=None), ] mock_ws = MagicMock() mock_ws.recv.side_effect = messages mock_execution.ws = mock_ws mock_execution.watch_execution() assert "1" in mock_execution.remaining_nodes def test_unknown_node_ids_do_not_crash(self, mock_execution): prompt_id = "test-prompt" mock_execution.prompt_id = prompt_id messages = [ _make_msg("executing", prompt_id, node="1"), _make_msg("executing", prompt_id, node="406.0.0.428"), json.dumps( {"type": "progress", "data": {"prompt_id": prompt_id, "node": "406.0.0.428", "value": 5, "max": 10}} ), _make_msg("executed", prompt_id, node="406.0.0.428"), json.dumps({"type": "execution_cached", "data": {"prompt_id": prompt_id, "nodes": ["999"]}}), _make_msg("executing", prompt_id, node=None), ] mock_ws = MagicMock() mock_ws.recv.side_effect = messages mock_execution.ws = mock_ws mock_execution.watch_execution() def test_unknown_node_ids_verbose(self, workflow): prompt_id = "test-prompt" progress = MagicMock() progress.add_task.return_value = 0 execution = WorkflowExecution( workflow=workflow, host="127.0.0.1", port=8188, verbose=True, progress=progress, local_paths=False, timeout=30, ) execution.prompt_id = prompt_id messages = [ _make_msg("executing", prompt_id, node="406.0.0.428"), json.dumps({"type": "execution_cached", "data": {"prompt_id": prompt_id, "nodes": ["999"]}}), _make_msg("executing", prompt_id, node=None), ] mock_ws = MagicMock() mock_ws.recv.side_effect = messages execution.ws = mock_ws execution.watch_execution() def test_collects_image_outputs(self, mock_execution): prompt_id = "test-prompt" mock_execution.prompt_id = prompt_id executed_msg = json.dumps( { "type": "executed", "data": { "prompt_id": prompt_id, "node": "2", "output": { "images": [{"filename": "result.png", "subfolder": "", "type": "output"}], }, }, } ) messages = [ _make_msg("executing", prompt_id, node="2"), executed_msg, _make_msg("executing", prompt_id, node=None), ] mock_ws = MagicMock() mock_ws.recv.side_effect = messages mock_execution.ws = mock_ws mock_execution.watch_execution() assert len(mock_execution.outputs) == 1 assert "result.png" in mock_execution.outputs[0] class TestExecuteErrorHandling: def _run_execute_expect_exit(self, workflow_file, **overrides): kwargs = dict(host="127.0.0.1", port=8188, wait=True, verbose=False, local_paths=False, timeout=30) kwargs.update(overrides) with pytest.raises(typer.Exit) as exc_info: execute(workflow_file, **kwargs) return exc_info.value.exit_code def test_timeout_exits_with_code_1(self, workflow_file): with ( patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), patch("comfy_cli.command.run.ExecutionProgress"), patch("comfy_cli.command.run.WorkflowExecution") as MockExec, ): mock_exec = MagicMock() MockExec.return_value = mock_exec mock_exec.watch_execution.side_effect = WebSocketTimeoutException("timed out") code = self._run_execute_expect_exit(workflow_file) assert code == 1 def test_connection_error_exits_with_code_1(self, workflow_file): with ( patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), patch("comfy_cli.command.run.ExecutionProgress"), patch("comfy_cli.command.run.WorkflowExecution") as MockExec, ): mock_exec = MagicMock() MockExec.return_value = mock_exec mock_exec.connect.side_effect = ConnectionError("Connection refused") code = self._run_execute_expect_exit(workflow_file) assert code == 1 def test_websocket_exception_exits_with_code_1(self, workflow_file): with ( patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), patch("comfy_cli.command.run.ExecutionProgress"), patch("comfy_cli.command.run.WorkflowExecution") as MockExec, ): mock_exec = MagicMock() MockExec.return_value = mock_exec mock_exec.watch_execution.side_effect = WebSocketException("Connection lost") code = self._run_execute_expect_exit(workflow_file) assert code == 1 def test_successful_execution(self, workflow_file): with ( patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), patch("comfy_cli.command.run.ExecutionProgress") as MockProgress, patch("comfy_cli.command.run.WorkflowExecution") as MockExec, ): mock_progress = MagicMock() MockProgress.return_value = mock_progress mock_exec = MagicMock() MockExec.return_value = mock_exec mock_exec.outputs = [] execute(workflow_file, host="127.0.0.1", port=8188, wait=True, timeout=30) mock_exec.connect.assert_called_once() mock_exec.queue.assert_called_once() mock_exec.watch_execution.assert_called_once() def test_file_not_found_exits(self): with pytest.raises(typer.Exit) as exc_info: execute("/nonexistent/workflow.json", host="127.0.0.1", port=8188) assert exc_info.value.exit_code == 1 def test_rejects_invalid_workflow_format(self): bad = {"1": {"no_class_type_here": "X"}} with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump(bad, f) f.flush() path = f.name try: with patch("comfy_cli.command.run.check_comfy_server_running", return_value=True): with pytest.raises(typer.Exit) as exc_info: execute(path, host="127.0.0.1", port=8188) assert exc_info.value.exit_code == 1 finally: os.unlink(path) def test_rejects_malformed_json(self): with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: f.write("{ this is not valid json") f.flush() path = f.name try: with patch("comfy_cli.command.run.check_comfy_server_running", return_value=True): with pytest.raises(typer.Exit) as exc_info: execute(path, host="127.0.0.1", port=8188) assert exc_info.value.exit_code == 1 finally: os.unlink(path) def test_rejects_unreadable_file(self): with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: f.write("{}") path = f.name try: real_open = open def fake_open(file, *args, **kwargs): if file == path: raise PermissionError(13, "Permission denied", path) return real_open(file, *args, **kwargs) with ( patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), patch("builtins.open", side_effect=fake_open), ): with pytest.raises(typer.Exit) as exc_info: execute(path, host="127.0.0.1", port=8188) assert exc_info.value.exit_code == 1 finally: os.unlink(path) def test_progress_stopped_on_error(self, workflow_file): with ( patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), patch("comfy_cli.command.run.ExecutionProgress") as MockProgress, patch("comfy_cli.command.run.WorkflowExecution") as MockExec, ): mock_progress = MagicMock() MockProgress.return_value = mock_progress mock_exec = MagicMock() MockExec.return_value = mock_exec mock_exec.watch_execution.side_effect = WebSocketTimeoutException("timed out") with pytest.raises(typer.Exit): execute(workflow_file, host="127.0.0.1", port=8188, wait=True, timeout=30) mock_progress.stop.assert_called() class TestExecuteUiWorkflow: UI = { "nodes": [ { "id": 1, "type": "EmptyLatentImage", "inputs": [], "outputs": [{"name": "LATENT", "type": "LATENT", "links": [10]}], "widgets_values": [512, 512, 1], "mode": 0, }, { "id": 2, "type": "PreviewImage", "inputs": [{"name": "images", "link": 10}], "outputs": [], "mode": 0, }, ], "links": [[10, 1, 0, 2, 0, "IMAGE"]], } OBJECT_INFO = { "EmptyLatentImage": { "input": { "required": { "width": ["INT", {"default": 512}], "height": ["INT", {"default": 512}], "batch_size": ["INT", {"default": 1}], } }, "input_order": {"required": ["width", "height", "batch_size"]}, "output_node": False, "display_name": "Empty Latent Image", }, "PreviewImage": { "input": {"required": {"images": ["IMAGE"]}}, "input_order": {"required": ["images"]}, "output_node": True, "display_name": "Preview Image", }, } @pytest.fixture def ui_workflow_file(self): with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump(self.UI, f) f.flush() path = f.name yield path os.unlink(path) def test_ui_workflow_is_converted_then_executed(self, ui_workflow_file): with ( patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), patch("comfy_cli.command.run.fetch_object_info", return_value=self.OBJECT_INFO) as mock_fetch, patch("comfy_cli.command.run.ExecutionProgress"), patch("comfy_cli.command.run.WorkflowExecution") as MockExec, ): mock_exec = MagicMock() MockExec.return_value = mock_exec mock_exec.outputs = [] execute(ui_workflow_file, host="127.0.0.1", port=8188, wait=True, timeout=30) mock_fetch.assert_called_once_with("127.0.0.1", 8188, 30) api_workflow = MockExec.call_args.args[0] assert set(api_workflow) == {"1", "2"} assert api_workflow["1"]["class_type"] == "EmptyLatentImage" assert api_workflow["2"]["inputs"]["images"] == ["1", 0] mock_exec.queue.assert_called_once() def test_ui_workflow_exits_when_server_not_running(self, ui_workflow_file): with ( patch("comfy_cli.command.run.check_comfy_server_running", return_value=False), patch("comfy_cli.command.run.fetch_object_info") as mock_fetch, ): with pytest.raises(typer.Exit) as exc_info: execute(ui_workflow_file, host="127.0.0.1", port=8188) assert exc_info.value.exit_code == 1 mock_fetch.assert_not_called() def test_ui_workflow_exits_cleanly_on_unexpected_converter_crash(self, ui_workflow_file): # If the experimental converter crashes with an unexpected error, the # CLI should still exit with code 1 and a friendly message — not let a # Python traceback escape to the user. with ( patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), patch("comfy_cli.command.run.fetch_object_info", return_value=self.OBJECT_INFO), patch( "comfy_cli.command.run.convert_ui_to_api", side_effect=RuntimeError("simulated converter bug"), ), patch("comfy_cli.command.run.WorkflowExecution") as MockExec, ): with pytest.raises(typer.Exit) as exc_info: execute(ui_workflow_file, host="127.0.0.1", port=8188, wait=True, timeout=30) assert exc_info.value.exit_code == 1 MockExec.assert_not_called() def test_ui_workflow_plumbs_api_key_through_to_execution(self, ui_workflow_file): with ( patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), patch("comfy_cli.command.run.fetch_object_info", return_value=self.OBJECT_INFO) as mock_fetch, patch("comfy_cli.command.run.ExecutionProgress"), patch("comfy_cli.command.run.WorkflowExecution") as MockExec, ): mock_exec = MagicMock() MockExec.return_value = mock_exec mock_exec.outputs = [] execute(ui_workflow_file, host="127.0.0.1", port=8188, wait=True, timeout=30, api_key="sk-test") mock_fetch.assert_called_once_with("127.0.0.1", 8188, 30) assert MockExec.call_args.kwargs["api_key"] == "sk-test" def test_ui_workflow_exits_when_conversion_yields_nothing(self): # All nodes are UI-only (Note/PrimitiveNode/Reroute/GetNode/SetNode) and # therefore stripped by the converter → execute() should bail before # ever instantiating WorkflowExecution. empty_ui = { "nodes": [ {"id": 1, "type": "Note", "inputs": [], "outputs": [], "widgets_values": ["x"]}, {"id": 2, "type": "Reroute", "inputs": [{"link": None}], "outputs": [{"links": []}]}, ], "links": [], } with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump(empty_ui, f) f.flush() path = f.name try: with ( patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), patch("comfy_cli.command.run.fetch_object_info", return_value=self.OBJECT_INFO), patch("comfy_cli.command.run.WorkflowExecution") as MockExec, ): with pytest.raises(typer.Exit) as exc_info: execute(path, host="127.0.0.1", port=8188, wait=True, timeout=30) assert exc_info.value.exit_code == 1 MockExec.assert_not_called() finally: os.unlink(path) ================================================ FILE: tests/comfy_cli/conftest.py ================================================ import os import pytest @pytest.fixture(autouse=True) def _preserve_cwd(): """Restore the working directory after every test. Several functions in comfy_cli.command.install (execute, pip_install_comfyui_dependencies) call os.chdir() as a side effect. Without this fixture the changed CWD leaks into subsequent tests and can cause hard-to-debug failures. """ original = os.getcwd() yield os.chdir(original) ================================================ FILE: tests/comfy_cli/fixtures/sd15_expected_api.json ================================================ { "4": { "inputs": { "ckpt_name": "v1-5-pruned-emaonly-fp16.safetensors" }, "class_type": "CheckpointLoaderSimple", "_meta": { "title": "Load Checkpoint" } }, "3": { "inputs": { "seed": 685468484323813, "steps": 20, "cfg": 8, "sampler_name": "euler", "scheduler": "normal", "denoise": 1, "model": [ "4", 0 ], "positive": [ "6", 0 ], "negative": [ "7", 0 ], "latent_image": [ "5", 0 ] }, "class_type": "KSampler", "_meta": { "title": "KSampler" } }, "8": { "inputs": { "samples": [ "3", 0 ], "vae": [ "4", 2 ] }, "class_type": "VAEDecode", "_meta": { "title": "VAE Decode" } }, "9": { "inputs": { "filename_prefix": "SD1.5", "images": [ "8", 0 ] }, "class_type": "SaveImage", "_meta": { "title": "Save Image" } }, "7": { "inputs": { "text": "text, watermark", "clip": [ "4", 1 ] }, "class_type": "CLIPTextEncode", "_meta": { "title": "CLIP Text Encode (Prompt)" } }, "5": { "inputs": { "width": 512, "height": 512, "batch_size": 1 }, "class_type": "EmptyLatentImage", "_meta": { "title": "Empty Latent Image" } }, "6": { "inputs": { "text": "beautiful scenery nature glass bottle landscape, purple galaxy bottle,", "clip": [ "4", 1 ] }, "class_type": "CLIPTextEncode", "_meta": { "title": "CLIP Text Encode (Prompt)" } } } ================================================ FILE: tests/comfy_cli/fixtures/sd15_object_info.json ================================================ { "CLIPTextEncode": { "input": { "required": { "text": [ "STRING", { "multiline": true, "dynamicPrompts": true, "tooltip": "The text to be encoded." } ], "clip": [ "CLIP", { "tooltip": "The CLIP model used for encoding the text." } ] } }, "input_order": { "required": [ "text", "clip" ] }, "is_input_list": false, "output": [ "CONDITIONING" ], "output_is_list": [ false ], "output_name": [ "CONDITIONING" ], "name": "CLIPTextEncode", "display_name": "CLIP Text Encode (Prompt)", "description": "Encodes a text prompt using a CLIP model into an embedding that can be used to guide the diffusion model towards generating specific images.", "python_module": "nodes", "category": "conditioning", "output_node": false, "has_intermediate_output": false, "output_tooltips": [ "A conditioning containing the embedded text used to guide the diffusion model." ], "search_aliases": [ "text", "prompt", "text prompt", "positive prompt", "negative prompt", "encode text", "text encoder", "encode prompt" ] }, "CheckpointLoaderSimple": { "input": { "required": { "ckpt_name": [ [ "sd_xl_turbo_1.0_fp16.safetensors", "v1-5-pruned-emaonly-fp16.safetensors" ], { "tooltip": "The name of the checkpoint (model) to load." } ] } }, "input_order": { "required": [ "ckpt_name" ] }, "is_input_list": false, "output": [ "MODEL", "CLIP", "VAE" ], "output_is_list": [ false, false, false ], "output_name": [ "MODEL", "CLIP", "VAE" ], "name": "CheckpointLoaderSimple", "display_name": "Load Checkpoint", "description": "Loads a diffusion model checkpoint, diffusion models are used to denoise latents.", "python_module": "nodes", "category": "loaders", "output_node": false, "has_intermediate_output": false, "output_tooltips": [ "The model used for denoising latents.", "The CLIP model used for encoding text prompts.", "The VAE model used for encoding and decoding images to and from latent space." ], "search_aliases": [ "load model", "checkpoint", "model loader", "load checkpoint", "ckpt", "model" ] }, "EmptyLatentImage": { "input": { "required": { "width": [ "INT", { "default": 512, "min": 16, "max": 16384, "step": 8, "tooltip": "The width of the latent images in pixels." } ], "height": [ "INT", { "default": 512, "min": 16, "max": 16384, "step": 8, "tooltip": "The height of the latent images in pixels." } ], "batch_size": [ "INT", { "default": 1, "min": 1, "max": 4096, "tooltip": "The number of latent images in the batch." } ] } }, "input_order": { "required": [ "width", "height", "batch_size" ] }, "is_input_list": false, "output": [ "LATENT" ], "output_is_list": [ false ], "output_name": [ "LATENT" ], "name": "EmptyLatentImage", "display_name": "Empty Latent Image", "description": "Create a new batch of empty latent images to be denoised via sampling.", "python_module": "nodes", "category": "latent", "output_node": false, "has_intermediate_output": false, "output_tooltips": [ "The empty latent image batch." ], "search_aliases": [ "empty", "empty latent", "new latent", "create latent", "blank latent", "blank" ] }, "KSampler": { "input": { "required": { "model": [ "MODEL", { "tooltip": "The model used for denoising the input latent." } ], "seed": [ "INT", { "default": 0, "min": 0, "max": 18446744073709551615, "control_after_generate": true, "tooltip": "The random seed used for creating the noise." } ], "steps": [ "INT", { "default": 20, "min": 1, "max": 10000, "tooltip": "The number of steps used in the denoising process." } ], "cfg": [ "FLOAT", { "default": 8.0, "min": 0.0, "max": 100.0, "step": 0.1, "round": 0.01, "tooltip": "The Classifier-Free Guidance scale balances creativity and adherence to the prompt. Higher values result in images more closely matching the prompt however too high values will negatively impact quality." } ], "sampler_name": [ [ "euler", "euler_cfg_pp", "euler_ancestral", "euler_ancestral_cfg_pp", "heun", "heunpp2", "exp_heun_2_x0", "exp_heun_2_x0_sde", "dpm_2", "dpm_2_ancestral", "lms", "dpm_fast", "dpm_adaptive", "dpmpp_2s_ancestral", "dpmpp_2s_ancestral_cfg_pp", "dpmpp_sde", "dpmpp_sde_gpu", "dpmpp_2m", "dpmpp_2m_cfg_pp", "dpmpp_2m_sde", "dpmpp_2m_sde_gpu", "dpmpp_2m_sde_heun", "dpmpp_2m_sde_heun_gpu", "dpmpp_3m_sde", "dpmpp_3m_sde_gpu", "ddpm", "lcm", "ipndm", "ipndm_v", "deis", "res_multistep", "res_multistep_cfg_pp", "res_multistep_ancestral", "res_multistep_ancestral_cfg_pp", "gradient_estimation", "gradient_estimation_cfg_pp", "er_sde", "seeds_2", "seeds_3", "sa_solver", "sa_solver_pece", "ddim", "uni_pc", "uni_pc_bh2" ], { "tooltip": "The algorithm used when sampling, this can affect the quality, speed, and style of the generated output." } ], "scheduler": [ [ "simple", "sgm_uniform", "karras", "exponential", "ddim_uniform", "beta", "normal", "linear_quadratic", "kl_optimal" ], { "tooltip": "The scheduler controls how noise is gradually removed to form the image." } ], "positive": [ "CONDITIONING", { "tooltip": "The conditioning describing the attributes you want to include in the image." } ], "negative": [ "CONDITIONING", { "tooltip": "The conditioning describing the attributes you want to exclude from the image." } ], "latent_image": [ "LATENT", { "tooltip": "The latent image to denoise." } ], "denoise": [ "FLOAT", { "default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "The amount of denoising applied, lower values will maintain the structure of the initial image allowing for image to image sampling." } ] } }, "input_order": { "required": [ "model", "seed", "steps", "cfg", "sampler_name", "scheduler", "positive", "negative", "latent_image", "denoise" ] }, "is_input_list": false, "output": [ "LATENT" ], "output_is_list": [ false ], "output_name": [ "LATENT" ], "name": "KSampler", "display_name": "KSampler", "description": "Uses the provided model, positive and negative conditioning to denoise the latent image.", "python_module": "nodes", "category": "sampling", "output_node": false, "has_intermediate_output": false, "output_tooltips": [ "The denoised latent." ], "search_aliases": [ "sampler", "sample", "generate", "denoise", "diffuse", "txt2img", "img2img" ] }, "SaveImage": { "input": { "required": { "images": [ "IMAGE", { "tooltip": "The images to save." } ], "filename_prefix": [ "STRING", { "default": "ComfyUI", "tooltip": "The prefix for the file to save. This may include formatting information such as %date:yyyy-MM-dd% or %Empty Latent Image.width% to include values from nodes." } ] }, "hidden": { "prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO" } }, "input_order": { "required": [ "images", "filename_prefix" ], "hidden": [ "prompt", "extra_pnginfo" ] }, "is_input_list": false, "output": [], "output_is_list": [], "output_name": [], "name": "SaveImage", "display_name": "Save Image", "description": "Saves the input images to your ComfyUI output directory.", "python_module": "nodes", "category": "image", "output_node": true, "has_intermediate_output": false, "search_aliases": [ "save", "save image", "export image", "output image", "write image", "download" ], "essentials_category": "Basics" }, "VAEDecode": { "input": { "required": { "samples": [ "LATENT", { "tooltip": "The latent to be decoded." } ], "vae": [ "VAE", { "tooltip": "The VAE model used for decoding the latent." } ] } }, "input_order": { "required": [ "samples", "vae" ] }, "is_input_list": false, "output": [ "IMAGE" ], "output_is_list": [ false ], "output_name": [ "IMAGE" ], "name": "VAEDecode", "display_name": "VAE Decode", "description": "Decodes latent images back into pixel space images.", "python_module": "nodes", "category": "latent", "output_node": false, "has_intermediate_output": false, "output_tooltips": [ "The decoded image." ], "search_aliases": [ "decode", "decode latent", "latent to image", "render latent" ] } } ================================================ FILE: tests/comfy_cli/fixtures/sd15_ui_workflow.json ================================================ { "id": "2ba0b800-2f13-4f21-b8d6-c6cdb0152cae", "revision": 0, "last_node_id": 16, "last_link_id": 9, "nodes": [ { "id": 4, "type": "CheckpointLoaderSimple", "pos": [ 10, 300 ], "size": [ 320, 154.65625 ], "flags": {}, "order": 0, "mode": 0, "inputs": [], "outputs": [ { "name": "MODEL", "type": "MODEL", "slot_index": 0, "links": [ 1 ] }, { "name": "CLIP", "type": "CLIP", "slot_index": 1, "links": [ 3, 5 ] }, { "name": "VAE", "type": "VAE", "slot_index": 2, "links": [ 8 ] } ], "properties": { "Node name for S&R": "CheckpointLoaderSimple", "cnr_id": "comfy-core", "ver": "0.3.65", "models": [ { "name": "v1-5-pruned-emaonly-fp16.safetensors", "url": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors?download=true", "directory": "checkpoints" } ] }, "widgets_values": [ "v1-5-pruned-emaonly-fp16.safetensors" ] }, { "id": 3, "type": "KSampler", "pos": [ 920, 170 ], "size": [ 320, 480 ], "flags": {}, "order": 8, "mode": 0, "inputs": [ { "name": "model", "type": "MODEL", "link": 1 }, { "name": "positive", "type": "CONDITIONING", "link": 4 }, { "name": "negative", "type": "CONDITIONING", "link": 6 }, { "name": "latent_image", "type": "LATENT", "link": 2 } ], "outputs": [ { "name": "LATENT", "type": "LATENT", "slot_index": 0, "links": [ 7 ] } ], "properties": { "Node name for S&R": "KSampler", "cnr_id": "comfy-core", "ver": "0.3.65" }, "widgets_values": [ 685468484323813, "randomize", 20, 8, "euler", "normal", 1 ] }, { "id": 8, "type": "VAEDecode", "pos": [ 1000, 710 ], "size": [ 225, 96 ], "flags": {}, "order": 9, "mode": 0, "inputs": [ { "name": "samples", "type": "LATENT", "link": 7 }, { "name": "vae", "type": "VAE", "link": 8 } ], "outputs": [ { "name": "IMAGE", "type": "IMAGE", "slot_index": 0, "links": [ 9 ] } ], "properties": { "Node name for S&R": "VAEDecode", "cnr_id": "comfy-core", "ver": "0.3.65" }, "widgets_values": [] }, { "id": 9, "type": "SaveImage", "pos": [ 1270, 170 ], "size": [ 470, 560 ], "flags": {}, "order": 10, "mode": 0, "inputs": [ { "name": "images", "type": "IMAGE", "link": 9 } ], "outputs": [], "properties": { "cnr_id": "comfy-core", "ver": "0.3.65" }, "widgets_values": [ "SD1.5" ] }, { "id": 7, "type": "CLIPTextEncode", "pos": [ 430, 530 ], "size": [ 420, 170 ], "flags": {}, "order": 7, "mode": 0, "inputs": [ { "name": "clip", "type": "CLIP", "link": 5 } ], "outputs": [ { "name": "CONDITIONING", "type": "CONDITIONING", "slot_index": 0, "links": [ 6 ] } ], "properties": { "Node name for S&R": "CLIPTextEncode", "cnr_id": "comfy-core", "ver": "0.3.65" }, "widgets_values": [ "text, watermark" ], "color": "#223", "bgcolor": "#335" }, { "id": 5, "type": "EmptyLatentImage", "pos": [ 490, 900 ], "size": [ 320, 168 ], "flags": {}, "order": 1, "mode": 0, "inputs": [], "outputs": [ { "name": "LATENT", "type": "LATENT", "slot_index": 0, "links": [ 2 ] } ], "properties": { "Node name for S&R": "EmptyLatentImage", "cnr_id": "comfy-core", "ver": "0.3.65" }, "widgets_values": [ 512, 512, 1 ] }, { "id": 6, "type": "CLIPTextEncode", "pos": [ 420, 220 ], "size": [ 430, 260 ], "flags": {}, "order": 6, "mode": 0, "inputs": [ { "name": "clip", "type": "CLIP", "link": 3 } ], "outputs": [ { "name": "CONDITIONING", "type": "CONDITIONING", "slot_index": 0, "links": [ 4 ] } ], "properties": { "Node name for S&R": "CLIPTextEncode", "cnr_id": "comfy-core", "ver": "0.3.65" }, "widgets_values": [ "beautiful scenery nature glass bottle landscape, purple galaxy bottle," ], "color": "#232", "bgcolor": "#353" }, { "id": 15, "type": "MarkdownNote", "pos": [ 400, -320 ], "size": [ 470, 430 ], "flags": {}, "order": 2, "mode": 0, "inputs": [], "outputs": [], "title": "Note: Prompt", "properties": {}, "widgets_values": [ "**Prompts usually have two types:**\n\n* **Positive Prompt:** Tells the model what you *want* to see.\n* **Negative Prompt:** Tells the model what you *don\u2019t want* to see.\n\nThink of it as:
\n\ud83d\udc49 Positive = \u201cDo this\u201d
\n\ud83d\udc49 Negative = \u201cDon\u2019t do this\u201d\n\n\nDifferent models may interpret prompts differently.
\nSome prefer short, simple phrases; others respond well to detailed descriptions or styles.\nExperiment to see how each model reacts.\n\nAbout SD1.5:
\nStable Diffusion 1.5 is one of the most popular base models.\nIt works best with short, clear prompts and simple concepts, and it has a natural, realistic visual style.\n" ], "color": "#432", "bgcolor": "#000" }, { "id": 14, "type": "MarkdownNote", "pos": [ 1270, 780 ], "size": [ 470, 130 ], "flags": {}, "order": 3, "mode": 0, "inputs": [], "outputs": [], "title": "Note: Output", "properties": {}, "widgets_values": [ "Image will auto-save to the `ComfyUI/output` folder. You can also right-click on the `Save Image` node and then use the menu to save the image locally." ], "color": "#432", "bgcolor": "#000" }, { "id": 13, "type": "MarkdownNote", "pos": [ 460, 1180 ], "size": [ 330, 163.953125 ], "flags": {}, "order": 4, "mode": 0, "inputs": [], "outputs": [], "title": "Note: Image size", "properties": {}, "widgets_values": [ "Different models are trained based on datasets of different image sizes. This workflow is using Stable Diffusion 1.5, which is trained based on 512x512 datasets. So, it doesn't perform well on large image sizes." ], "color": "#432", "bgcolor": "#000" }, { "id": 11, "type": "MarkdownNote", "pos": [ -470, 160 ], "size": [ 400, 530.890625 ], "flags": {}, "order": 5, "mode": 0, "inputs": [], "outputs": [], "title": "Note: Model link", "properties": {}, "widgets_values": [ "[Tutorial](https://docs.comfy.org/tutorials/basic/text-to-image)\n\n\n## Model link\n\n**checkpoints**\n\n- [v1-5-pruned-emaonly-fp16.safetensors](https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors?download=true)\n\n\nModel Storage Location\n\n```\n\ud83d\udcc2 ComfyUI/\n\u251c\u2500\u2500 \ud83d\udcc2 models/\n\u2502 \u2514\u2500\u2500 \ud83d\udcc2 checkpoints/\n\u2502 \u2514\u2500\u2500 v1-5-pruned-emaonly-fp16.safetensors\n```\n\n\n## Report issue\nIf you have any problems running this workflow, please report template-related issues via this link: [report the template issue here](https://github.com/Comfy-Org/workflow_templates/issues)" ], "color": "#432", "bgcolor": "#000" } ], "links": [ [ 1, 4, 0, 3, 0, "MODEL" ], [ 2, 5, 0, 3, 3, "LATENT" ], [ 3, 4, 1, 6, 0, "CLIP" ], [ 4, 6, 0, 3, 1, "CONDITIONING" ], [ 5, 4, 1, 7, 0, "CLIP" ], [ 6, 7, 0, 3, 2, "CONDITIONING" ], [ 7, 3, 0, 8, 0, "LATENT" ], [ 8, 4, 2, 8, 1, "VAE" ], [ 9, 8, 0, 9, 0, "IMAGE" ] ], "groups": [ { "id": 1, "title": "Step 1 - Load model", "bounding": [ -40, 130, 420, 470 ], "color": "#3f789e", "font_size": 24, "flags": {} }, { "id": 2, "title": "Step 3 - Image size", "bounding": [ 400, 800, 480, 310 ], "color": "#3f789e", "font_size": 24, "flags": {} }, { "id": 3, "title": "Step 2 - Prompt", "bounding": [ 400, 130, 480, 640 ], "color": "#3f789e", "font_size": 24, "flags": {} } ], "config": {}, "extra": { "ds": { "scale": 0.5131581182307069, "offset": [ 979.5226642853634, 273.924658465434 ] }, "frontendVersion": "1.42.15", "VHS_latentpreview": false, "VHS_latentpreviewrate": 0, "VHS_MetadataImage": true, "VHS_KeepIntermediate": true }, "version": 0.4 } ================================================ FILE: tests/comfy_cli/registry/test_api.py ================================================ import unittest from unittest.mock import MagicMock, patch from comfy_cli.registry import PyProjectConfig from comfy_cli.registry.api import RegistryAPI from comfy_cli.registry.types import ComfyConfig, License, ProjectConfig, URLs class TestRegistryAPI(unittest.TestCase): def setUp(self): self.registry_api = RegistryAPI() self.node_config = PyProjectConfig( project=ProjectConfig( name="test_node", description="A test node", version="0.1.0", requires_python=">= 3.9", dependencies=["dep1", "dep2"], license=License(file="LICENSE"), urls=URLs(repository="https://github.com/test/test_node"), ), tool_comfy=ComfyConfig( publisher_id="123", display_name="Test Node", icon="https://example.com/icon.png", ), ) self.token = "dummy_token" @patch("os.getenv") def test_determine_base_url_dev(self, mock_getenv): mock_getenv.return_value = "dev" self.assertEqual(self.registry_api.determine_base_url(), "http://localhost:8080") @patch("os.getenv") def test_determine_base_url_prod(self, mock_getenv): mock_getenv.return_value = "prod" self.assertEqual(self.registry_api.determine_base_url(), "https://api.comfy.org") @patch("requests.post") def test_publish_node_version_success(self, mock_post): mock_response = MagicMock() mock_response.status_code = 201 mock_response.json.return_value = { "node_version": { "id": "test_node", "version": "0.1.0", "changelog": "", "dependencies": ["dep1", "dep2"], "deprecated": False, "downloadUrl": "https://example.com/download", }, "signedUrl": "https://example.com/signed", } mock_post.return_value = mock_response response = self.registry_api.publish_node_version(self.node_config, self.token) self.assertEqual(response.node_version.id, "test_node") self.assertEqual(response.node_version.version, "0.1.0") self.assertEqual(response.signedUrl, "https://example.com/signed") @patch("requests.post") def test_publish_node_version_failure(self, mock_post): mock_response = MagicMock() mock_response.status_code = 400 mock_response.text = "Bad Request" mock_post.return_value = mock_response with self.assertRaises(Exception) as context: self.registry_api.publish_node_version(self.node_config, self.token) self.assertIn("Failed to publish node version", str(context.exception)) @patch("requests.get") def test_list_all_nodes_success(self, mock_get): mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { "nodes": [ { "id": "node1", "name": "Node 1", "description": "First node", "author": "Author 1", "license": "MIT", "icon": "https://example.com/icon1.png", "repository": "https://github.com/test/node1", "tags": ["tag1", "tag2"], "latest_version": { "id": "node1", "version": "1.0.0", "changelog": "", "dependencies": ["dep1"], "deprecated": False, "downloadUrl": "https://example.com/download1", }, } ] } mock_get.return_value = mock_response nodes = self.registry_api.list_all_nodes() self.assertEqual(len(nodes), 1) self.assertEqual(nodes[0].id, "node1") self.assertEqual(nodes[0].name, "Node 1") @patch("requests.get") def test_list_all_nodes_failure(self, mock_get): mock_response = MagicMock() mock_response.status_code = 500 mock_response.text = "Internal Server Error" mock_get.return_value = mock_response with self.assertRaises(Exception) as context: self.registry_api.list_all_nodes() self.assertIn("Failed to retrieve nodes", str(context.exception)) @patch("requests.get") def test_install_node_success(self, mock_get): mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { "id": "node1", "version": "1.0.0", "changelog": "", "dependencies": ["dep1"], "deprecated": False, "downloadUrl": "https://example.com/download1", } mock_get.return_value = mock_response node_version = self.registry_api.install_node("node1") self.assertEqual(node_version.id, "node1") self.assertEqual(node_version.version, "1.0.0") @patch("requests.get") def test_install_node_failure(self, mock_get): mock_response = MagicMock() mock_response.status_code = 404 mock_response.text = "Not Found" mock_get.return_value = mock_response with self.assertRaises(Exception) as context: self.registry_api.install_node("node1") self.assertIn("Failed to install node", str(context.exception)) ================================================ FILE: tests/comfy_cli/registry/test_config_parser.py ================================================ import subprocess from unittest.mock import mock_open, patch import pytest import tomlkit from comfy_cli.registry.config_parser import ( _strip_url_credentials, extract_node_configuration, initialize_project_config, validate_and_extract_accelerator_classifiers, validate_and_extract_os_classifiers, validate_version, ) from comfy_cli.registry.types import ( License, Model, PyProjectConfig, URLs, ) @pytest.fixture def mock_toml_data(): return { "project": { "name": "test-project", "description": "A test project", "version": "1.0.0", "requires-python": ">=3.7", "dependencies": ["requests"], "license": {"file": "LICENSE"}, "urls": { "Homepage": "https://example.com", "Documentation": "https://docs.example.com", "Repository": "https://github.com/example/test-project", "Issues": "https://github.com/example/test-project/issues", }, }, "tool": { "comfy": { "PublisherId": "test-publisher", "DisplayName": "Test Project", "Icon": "icon.png", "Banner": "https://example.com/banner.png", "Models": [ { "location": "model1.bin", "model_url": "https://example.com/model1", }, { "location": "model2.bin", "model_url": "https://example.com/model2", }, ], } }, } def test_extract_node_configuration_success(mock_toml_data): with ( patch("os.path.isfile", return_value=True), patch("builtins.open", mock_open()), patch("tomlkit.load", return_value=mock_toml_data), ): result = extract_node_configuration("fake_path.toml") assert isinstance(result, PyProjectConfig) assert result.project.name == "test-project" assert result.project.description == "A test project" assert result.project.version == "1.0.0" assert result.project.requires_python == ">=3.7" assert result.project.dependencies == ["requests"] assert result.project.license == License(file="LICENSE") assert result.project.urls == URLs( homepage="https://example.com", documentation="https://docs.example.com", repository="https://github.com/example/test-project", issues="https://github.com/example/test-project/issues", ) assert result.tool_comfy.publisher_id == "test-publisher" assert result.tool_comfy.display_name == "Test Project" assert result.tool_comfy.icon == "icon.png" assert result.tool_comfy.banner_url == "https://example.com/banner.png" assert len(result.tool_comfy.models) == 2 assert result.tool_comfy.models[0] == Model(location="model1.bin", model_url="https://example.com/model1") @pytest.mark.parametrize( "license_str", ["MIT", "Apache-2.0", "GPL-3.0-or-later", "MIT License"], ) def test_extract_node_configuration_license_spdx_string(license_str): mock_data = { "project": { "license": license_str, }, } with ( patch("os.path.isfile", return_value=True), patch("builtins.open", mock_open()), patch("tomlkit.load", return_value=mock_data), ): result = extract_node_configuration("fake_path.toml") assert result is not None, "Expected PyProjectConfig, got None" assert isinstance(result, PyProjectConfig) assert result.project.license == License(text=license_str) def test_extract_node_configuration_license_text_dict(): mock_data = { "project": { "license": {"text": "MIT License\n\nCopyright (c) 2023 Example Corp\n\nPermission is hereby granted..."}, }, } with ( patch("os.path.isfile", return_value=True), patch("builtins.open", mock_open()), patch("tomlkit.load", return_value=mock_data), ): result = extract_node_configuration("fake_path.toml") assert result is not None, "Expected PyProjectConfig, got None" assert isinstance(result, PyProjectConfig) assert result.project.license == License( text="MIT License\n\nCopyright (c) 2023 Example Corp\n\nPermission is hereby granted..." ) def test_extract_node_configuration_with_os_classifiers(): mock_data = { "project": { "classifiers": [ "Operating System :: OS Independent", "Operating System :: Microsoft :: Windows", "Programming Language :: Python :: 3", "Topic :: Software Development", ] } } with ( patch("os.path.isfile", return_value=True), patch("builtins.open", mock_open()), patch("tomlkit.load", return_value=mock_data), ): result = extract_node_configuration("fake_path.toml") assert result is not None assert len(result.project.supported_os) == 2 assert "OS Independent" in result.project.supported_os assert "Microsoft :: Windows" in result.project.supported_os def test_extract_node_configuration_with_accelerator_classifiers(): mock_data = { "project": { "classifiers": [ "Environment :: GPU :: NVIDIA CUDA", "Environment :: GPU :: AMD ROCm", "Environment :: GPU :: Intel Arc", "Environment :: NPU :: Huawei Ascend", "Environment :: GPU :: Apple Metal", "Programming Language :: Python :: 3", "Topic :: Software Development", ] } } with ( patch("os.path.isfile", return_value=True), patch("builtins.open", mock_open()), patch("tomlkit.load", return_value=mock_data), ): result = extract_node_configuration("fake_path.toml") assert result is not None assert len(result.project.supported_accelerators) == 5 assert "GPU :: NVIDIA CUDA" in result.project.supported_accelerators assert "GPU :: AMD ROCm" in result.project.supported_accelerators assert "GPU :: Intel Arc" in result.project.supported_accelerators assert "NPU :: Huawei Ascend" in result.project.supported_accelerators assert "GPU :: Apple Metal" in result.project.supported_accelerators def test_extract_node_configuration_with_comfyui_version(): mock_data = {"project": {"dependencies": ["packge1>=2.0.0", "comfyui-frontend-package>=1.2.3", "package2>=1.0.0"]}} with ( patch("os.path.isfile", return_value=True), patch("builtins.open", mock_open()), patch("tomlkit.load", return_value=mock_data), ): result = extract_node_configuration("fake_path.toml") assert result is not None assert result.project.supported_comfyui_frontend_version == ">=1.2.3" assert len(result.project.dependencies) == 2 assert "comfyui-frontend-package>=1.2.3" not in result.project.dependencies assert "packge1>=2.0.0" in result.project.dependencies assert "package2>=1.0.0" in result.project.dependencies def test_extract_node_configuration_with_requires_comfyui(): mock_data = {"project": {}, "tool": {"comfy": {"requires-comfyui": "2.0.0"}}} with ( patch("os.path.isfile", return_value=True), patch("builtins.open", mock_open()), patch("tomlkit.load", return_value=mock_data), ): result = extract_node_configuration("fake_path.toml") assert result is not None assert result.project.supported_comfyui_version == "2.0.0" def _write_pyproject(tmp_path, body: str) -> str: """Write a pyproject.toml in tmp_path and return its absolute path as a string.""" p = tmp_path / "pyproject.toml" p.write_text(body) return str(p) def test_dynamic_version_resolved_from_double_quoted_literal(tmp_path): (tmp_path / "pkg").mkdir() (tmp_path / "pkg" / "__init__.py").write_text('__version__ = "1.2.3"\n') path = _write_pyproject( tmp_path, '[project]\nname = "x"\ndynamic = ["version"]\n\n[tool.comfy.version]\npath = "pkg/__init__.py"\n', ) result = extract_node_configuration(path) assert result is not None assert result.project.version == "1.2.3" def test_dynamic_version_resolved_from_VERSION_name(tmp_path): (tmp_path / "_version.py").write_text('VERSION = "2.0.0"\n') path = _write_pyproject( tmp_path, '[project]\nname = "x"\ndynamic = ["version"]\n\n[tool.comfy.version]\npath = "_version.py"\n', ) result = extract_node_configuration(path) assert result is not None assert result.project.version == "2.0.0" def test_dynamic_version_resolved_from_single_quotes(tmp_path): (tmp_path / "_v.py").write_text("__version__ = '0.9.1'\n") path = _write_pyproject( tmp_path, '[project]\nname = "x"\ndynamic = ["version"]\n\n[tool.comfy.version]\npath = "_v.py"\n', ) result = extract_node_configuration(path) assert result is not None assert result.project.version == "0.9.1" def test_dynamic_version_resolved_with_type_annotation(tmp_path): (tmp_path / "_v.py").write_text('__version__: str = "3.4.5"\n') path = _write_pyproject( tmp_path, '[project]\nname = "x"\ndynamic = ["version"]\n\n[tool.comfy.version]\npath = "_v.py"\n', ) result = extract_node_configuration(path) assert result is not None assert result.project.version == "3.4.5" def test_dynamic_version_ignores_commented_line(tmp_path): # The `^` anchor ensures a commented-out line is not matched. (tmp_path / "_v.py").write_text('# __version__ = "9.9.9"\n__version__ = "1.0.0"\n') path = _write_pyproject( tmp_path, '[project]\nname = "x"\ndynamic = ["version"]\n\n[tool.comfy.version]\npath = "_v.py"\n', ) result = extract_node_configuration(path) assert result is not None assert result.project.version == "1.0.0" def test_dynamic_version_first_match_wins(tmp_path): (tmp_path / "_v.py").write_text('__version__ = "1.0.0"\n__version__ = "2.0.0"\n') path = _write_pyproject( tmp_path, '[project]\nname = "x"\ndynamic = ["version"]\n\n[tool.comfy.version]\npath = "_v.py"\n', ) result = extract_node_configuration(path) assert result is not None assert result.project.version == "1.0.0" def test_static_version_wins_over_tool_comfy_version(tmp_path): # Defensive: if a user accidentally has both, the static `project.version` # wins without ever reading the file (no warning, no resolution). (tmp_path / "_v.py").write_text('__version__ = "9.9.9"\n') path = _write_pyproject( tmp_path, '[project]\nname = "x"\nversion = "1.0.0"\n\n[tool.comfy.version]\npath = "_v.py"\n', ) result = extract_node_configuration(path) assert result is not None assert result.project.version == "1.0.0" @patch("typer.echo") def test_dynamic_version_without_tool_comfy_version_warns(mock_echo, tmp_path): path = _write_pyproject(tmp_path, '[project]\nname = "x"\ndynamic = ["version"]\n') result = extract_node_configuration(path) assert result is not None assert result.project.version == "" assert any("[tool.comfy.version].path" in str(c) for c in mock_echo.call_args_list) @patch("typer.echo") def test_dynamic_version_absolute_path_rejected(mock_echo, tmp_path): path = _write_pyproject( tmp_path, '[project]\nname = "x"\ndynamic = ["version"]\n\n[tool.comfy.version]\npath = "/etc/passwd"\n', ) result = extract_node_configuration(path) assert result is not None assert result.project.version == "" assert any("must be relative" in str(c) for c in mock_echo.call_args_list) @patch("typer.echo") def test_dynamic_version_windows_absolute_path_rejected(mock_echo, tmp_path): # Ensure a Windows-style absolute path is also rejected when tests run # on POSIX (and vice versa) — the check is OS-agnostic. path = _write_pyproject( tmp_path, '[project]\nname = "x"\ndynamic = ["version"]\n\n[tool.comfy.version]\npath = \'C:\\Windows\\version.py\'\n', ) result = extract_node_configuration(path) assert result is not None assert result.project.version == "" assert any("must be relative" in str(c) for c in mock_echo.call_args_list) @patch("typer.echo") def test_dynamic_version_path_traversal_rejected(mock_echo, tmp_path): path = _write_pyproject( tmp_path, '[project]\nname = "x"\ndynamic = ["version"]\n\n[tool.comfy.version]\npath = "../../etc/passwd"\n', ) result = extract_node_configuration(path) assert result is not None assert result.project.version == "" assert any("inside the project directory" in str(c) for c in mock_echo.call_args_list) @patch("typer.echo") def test_dynamic_version_missing_file_warns(mock_echo, tmp_path): path = _write_pyproject( tmp_path, '[project]\nname = "x"\ndynamic = ["version"]\n\n[tool.comfy.version]\npath = "does_not_exist.py"\n', ) result = extract_node_configuration(path) assert result is not None assert result.project.version == "" assert any("could not read" in str(c) for c in mock_echo.call_args_list) @patch("typer.echo") def test_dynamic_version_no_match_warns(mock_echo, tmp_path): (tmp_path / "_v.py").write_text('other_var = "1.2.3"\nsome_other = 42\n') path = _write_pyproject( tmp_path, '[project]\nname = "x"\ndynamic = ["version"]\n\n[tool.comfy.version]\npath = "_v.py"\n', ) result = extract_node_configuration(path) assert result is not None assert result.project.version == "" assert any("could not find" in str(c) for c in mock_echo.call_args_list) def test_dynamic_version_handles_utf8_bom(tmp_path): # Windows editors that write a UTF-8 BOM must not defeat the `^` anchor. (tmp_path / "_v.py").write_bytes(b'\xef\xbb\xbf__version__ = "1.2.3"\n') path = _write_pyproject( tmp_path, '[project]\nname = "x"\ndynamic = ["version"]\n\n[tool.comfy.version]\npath = "_v.py"\n', ) result = extract_node_configuration(path) assert result is not None assert result.project.version == "1.2.3" @patch("typer.echo") def test_dynamic_version_invalid_utf8_warns(mock_echo, tmp_path): # Non-UTF-8 content must not crash the parser (UnicodeDecodeError is a # ValueError, not an OSError — must be caught explicitly). (tmp_path / "_v.py").write_bytes(b"\xff\xfe\x00\x00garbage bytes") path = _write_pyproject( tmp_path, '[project]\nname = "x"\ndynamic = ["version"]\n\n[tool.comfy.version]\npath = "_v.py"\n', ) result = extract_node_configuration(path) assert result is not None assert result.project.version == "" assert any("could not read" in str(c) for c in mock_echo.call_args_list) @patch("typer.echo") def test_dynamic_version_scalar_tool_comfy_version_warns(mock_echo, tmp_path): # User misplaced a scalar version under [tool.comfy] instead of [project]. # Warning should name the shape problem, not the path problem. path = _write_pyproject( tmp_path, '[project]\nname = "x"\ndynamic = ["version"]\n\n[tool.comfy]\nversion = "1.2.3"\n', ) result = extract_node_configuration(path) assert result is not None assert result.project.version == "" assert any("must be a table" in str(c) for c in mock_echo.call_args_list) @patch("typer.echo") def test_malformed_dynamic_scalar_string_warns(mock_echo, tmp_path): # User wrote `dynamic = "version"` (scalar) instead of `dynamic = ["version"]`. # Silent-skip would leave them confused; warn explicitly. (tmp_path / "_v.py").write_text('__version__ = "1.0.0"\n') path = _write_pyproject( tmp_path, '[project]\nname = "x"\ndynamic = "version"\n\n[tool.comfy.version]\npath = "_v.py"\n', ) result = extract_node_configuration(path) assert result is not None assert result.project.version == "" assert any("must be an array of strings" in str(c) for c in mock_echo.call_args_list) def test_dynamic_version_indented_only_does_not_match(tmp_path): # Regex anchor `^` must reject indented `__version__` assignments (inside # classes/functions). File has ONLY the indented form — expect no match # and empty version. (tmp_path / "_v.py").write_text('class Foo:\n __version__ = "1.2.3"\n') path = _write_pyproject( tmp_path, '[project]\nname = "x"\ndynamic = ["version"]\n\n[tool.comfy.version]\npath = "_v.py"\n', ) result = extract_node_configuration(path) assert result is not None assert result.project.version == "" def test_dynamic_version_trailing_inline_comment_resolves(tmp_path): # `__version__ = "1.2.3" # stable` must resolve to "1.2.3" (regex stops # capture at the closing quote, trailing comment ignored). (tmp_path / "_v.py").write_text('__version__ = "1.2.3" # stable release\n') path = _write_pyproject( tmp_path, '[project]\nname = "x"\ndynamic = ["version"]\n\n[tool.comfy.version]\npath = "_v.py"\n', ) result = extract_node_configuration(path) assert result is not None assert result.project.version == "1.2.3" @patch("typer.echo") def test_dynamic_version_path_is_directory_warns(mock_echo, tmp_path): # `path` pointing at a directory must degrade gracefully (IsADirectoryError # is an OSError subclass) and surface a "could not read" warning. (tmp_path / "subdir").mkdir() path = _write_pyproject( tmp_path, '[project]\nname = "x"\ndynamic = ["version"]\n\n[tool.comfy.version]\npath = "subdir"\n', ) result = extract_node_configuration(path) assert result is not None assert result.project.version == "" assert any("could not read" in str(c) for c in mock_echo.call_args_list) def test_padded_static_version_is_stripped(tmp_path): # Static `version = " 1.0.0 "` must be normalized — registries should not # receive whitespace padding. path = _write_pyproject(tmp_path, '[project]\nname = "x"\nversion = " 1.0.0 "\n') result = extract_node_configuration(path) assert result is not None assert result.project.version == "1.0.0" @patch("typer.echo") def test_dynamic_version_non_string_path_warns_as_type_error(mock_echo, tmp_path): # A non-string `path` value must produce a type warning, not a misleading # "could not read file `42`" OS error. path = _write_pyproject( tmp_path, '[project]\nname = "x"\ndynamic = ["version"]\n\n[tool.comfy.version]\npath = 42\n', ) result = extract_node_configuration(path) assert result is not None assert result.project.version == "" assert any("must be a string" in str(c) for c in mock_echo.call_args_list) # Must NOT fall through to an OS "could not read" warning assert not any("could not read" in str(c) for c in mock_echo.call_args_list) @patch("typer.echo") def test_static_version_happy_path_emits_no_version_warnings(mock_echo, tmp_path): # Regression guard for the common case: static version, no dynamic, no # [tool.comfy.version]. Must not emit ANY version/dynamic-related warning # (only unrelated warnings like the pre-existing "License..." one are allowed). path = _write_pyproject(tmp_path, '[project]\nname = "x"\nversion = "1.2.3"\n') result = extract_node_configuration(path) assert result is not None assert result.project.version == "1.2.3" noisy = [ str(c) for c in mock_echo.call_args_list if "version" in str(c).lower() or "dynamic" in str(c).lower() or "tool.comfy" in str(c).lower() ] assert noisy == [], f"Unexpected version/dynamic warnings on happy path: {noisy}" # --- Fix K: non-dict `project` / `tool` degrade gracefully --- def test_malformed_toml_does_not_crash(tmp_path): # Invalid TOML (syntax error) must not crash the parser — scanning # contexts would lose the whole pack inventory otherwise. (tmp_path / "pyproject.toml").write_text('[project\nname = "x"\n') # missing `]` result = extract_node_configuration(str(tmp_path / "pyproject.toml")) assert result is None # graceful None return, no exception def test_pyproject_with_utf8_bom_parses_successfully(tmp_path): # Windows editors (e.g., Notepad with legacy settings, Visual Studio) write # a UTF-8 BOM on save. `encoding="utf-8-sig"` strips it transparently; without # this, tomlkit sees `` as the first character and reports a cryptic # `Empty key at line 1 col 0`. (tmp_path / "pyproject.toml").write_bytes(b'\xef\xbb\xbf[project]\nname = "x"\nversion = "1.0.0"\n') result = extract_node_configuration(str(tmp_path / "pyproject.toml")) assert result is not None assert result.project.name == "x" assert result.project.version == "1.0.0" def test_pyproject_with_invalid_utf8_returns_none_gracefully(tmp_path): # `UnicodeDecodeError` is a `ValueError`, not an `OSError`, so it must be in # the except tuple explicitly. Without it, a pyproject.toml with non-UTF-8 # bytes raises a raw traceback instead of the friendly error shown for every # other file-read failure. (tmp_path / "pyproject.toml").write_bytes(b'[project]\nname = "x"\nx = "\xff\xfe garbage"\n') result = extract_node_configuration(str(tmp_path / "pyproject.toml")) assert result is None @patch("typer.echo") def test_static_version_non_string_scalar_rejected(mock_echo, tmp_path): # PEP 621 requires `version` to be a string. A non-string scalar must now # produce a typed warning, not be silently coerced via `str()`. path = _write_pyproject(tmp_path, '[project]\nname = "x"\nversion = 1\n') result = extract_node_configuration(path) assert result is not None assert result.project.version == "" call_strs = [str(c) for c in mock_echo.call_args_list] assert any("`project.version` must be a string" in s for s in call_strs) @patch("typer.echo") def test_static_version_array_rejected(mock_echo, tmp_path): # Without the type check, `str(['1', '2'])` would POST `"['1', '2']"` to # the registry. Must be rejected. path = _write_pyproject(tmp_path, '[project]\nname = "x"\nversion = ["1", "2"]\n') result = extract_node_configuration(path) assert result is not None assert result.project.version == "" call_strs = [str(c) for c in mock_echo.call_args_list] assert any("`project.version` must be a string" in s for s in call_strs) @patch("typer.echo") def test_static_version_inline_table_rejected(mock_echo, tmp_path): # Users conflating PEP 621 static and our `[tool.comfy.version]` might write # `version = { path = "_v.py" }`. Without the type check this POSTed as # `"{'path': '_v.py'}"`. Catch it up front. path = _write_pyproject(tmp_path, '[project]\nname = "x"\nversion = { path = "_v.py" }\n') result = extract_node_configuration(path) assert result is not None assert result.project.version == "" call_strs = [str(c) for c in mock_echo.call_args_list] assert any("`project.version` must be a string" in s for s in call_strs) @patch("typer.echo") def test_project_scalar_at_root_does_not_crash(mock_echo, tmp_path): # Malformed TOML: `project = "hello"` at root. Must not crash — used to # raise AttributeError: 'String' object has no attribute 'get'. path = _write_pyproject(tmp_path, 'project = "hello"\n[tool.comfy]\nPublisherId = "x"\n') result = extract_node_configuration(path) assert result is not None assert result.project.version == "" assert any("`project` in pyproject.toml must be a table" in str(c) for c in mock_echo.call_args_list) @pytest.mark.parametrize("value", ["0", "0.0", "false", "[]", "{}"]) @patch("typer.echo") def test_static_version_falsy_non_string_rejected(mock_echo, tmp_path, value): # Regression guard: the type check must fire for FALSY non-strings too # (`version = 0`, `version = 0.0`, `version = false`, `version = []`, # `version = {}`). Earlier the truthy check (`if static_version:`) gated # the isinstance check, so these silently fell through to the dynamic # branch and the user only saw the downstream "project version is empty" # error. path = _write_pyproject(tmp_path, f'[project]\nname = "x"\nversion = {value}\n') result = extract_node_configuration(path) assert result is not None assert result.project.version == "" call_strs = [str(c) for c in mock_echo.call_args_list] assert any("`project.version` must be a string" in s for s in call_strs), ( f"value={value}: no type warning; saw {call_strs}" ) # --- Fix A (padded-static): strip semantics, already tested; add padded-dynamic variant --- def test_dynamic_version_padded_literal_is_stripped(tmp_path): # `__version__ = " 1.2.3 "` — the `.strip()` in _resolve_dynamic_version # already handles this, pinning the behavior here for regression safety. (tmp_path / "_v.py").write_text('__version__ = " 1.2.3 "\n') path = _write_pyproject( tmp_path, '[project]\nname = "x"\ndynamic = ["version"]\n\n[tool.comfy.version]\npath = "_v.py"\n', ) result = extract_node_configuration(path) assert result is not None assert result.project.version == "1.2.3" # --- Fix N: falsy-but-typed path values must trigger the type warning --- @patch("typer.echo") def test_dynamic_version_empty_path_string_warns_as_not_set(mock_echo, tmp_path): # `path = ""` explicitly set to empty string — equivalent to unset. path = _write_pyproject( tmp_path, '[project]\nname = "x"\ndynamic = ["version"]\n\n[tool.comfy.version]\npath = ""\n', ) result = extract_node_configuration(path) assert result is not None assert result.project.version == "" assert any("path` is not set" in str(c) for c in mock_echo.call_args_list) @patch("typer.echo") def test_dynamic_version_table_missing_path_key_warns_as_not_set(mock_echo, tmp_path): # `[tool.comfy.version]` table exists but has no `path` key. path = _write_pyproject( tmp_path, '[project]\nname = "x"\ndynamic = ["version"]\n\n[tool.comfy.version]\n', ) result = extract_node_configuration(path) assert result is not None assert result.project.version == "" assert any("path` is not set" in str(c) for c in mock_echo.call_args_list) @patch("typer.echo") def test_falsy_nonstring_path_values_warn_as_type_mismatch(mock_echo, tmp_path): # `path = 0 / false / [] / {}` are all truthy-falsy edge cases. They must # produce a "must be a string" warning, not the misleading "path is not set". for falsy in ["0", "false", "[]", "{}"]: mock_echo.reset_mock() path = _write_pyproject( tmp_path, f'[project]\nname = "x"\ndynamic = ["version"]\n\n[tool.comfy.version]\npath = {falsy}\n', ) result = extract_node_configuration(path) assert result is not None assert result.project.version == "" call_strs = [str(c) for c in mock_echo.call_args_list] assert any("must be a string" in s for s in call_strs), f"falsy={falsy}: no type warning" assert not any("path` is not set" in s for s in call_strs), f"falsy={falsy}: got misleading 'not set' warning" # --- Backslash in value: regex rejects, surfaces as "could not find" --- @patch("typer.echo") def test_dynamic_version_backslash_in_value_not_matched(mock_echo, tmp_path): # The regex excludes `\` from the value class entirely, so any escape # sequence in the literal causes the regex to fail to match. Users get # a clear "could not find" warning rather than a silently misinterpreted # value. PEP 440 versions are ASCII-only so this is a clean fail-closed # contract; users with auto-generated `__version__` containing escapes # must clean up their source. (tmp_path / "_v.py").write_text('__version__ = "1.0\\n"\n') path = _write_pyproject( tmp_path, '[project]\nname = "x"\ndynamic = ["version"]\n\n[tool.comfy.version]\npath = "_v.py"\n', ) result = extract_node_configuration(path) assert result is not None assert result.project.version == "" assert any("could not find" in str(c) for c in mock_echo.call_args_list) # --- H2 (Round 5): adjacent-string-literal concatenation detection --- @patch("typer.echo") def test_dynamic_version_adjacent_literals_double_quote_warns(mock_echo, tmp_path): # Python evaluates `"1." "2.3"` as `"1.2.3"` via implicit concatenation. # Without this check, the regex captures only `"1."` and we silently POST # the wrong version. The same-line look-ahead rejects concatenation so the # publish-layer guard exits 1 instead of shipping `"1."` to the registry. (tmp_path / "_v.py").write_text('__version__ = "1." "2.3"\n') path = _write_pyproject( tmp_path, '[project]\nname = "x"\ndynamic = ["version"]\n\n[tool.comfy.version]\npath = "_v.py"\n', ) result = extract_node_configuration(path) assert result is not None assert result.project.version == "" call_strs = [str(c) for c in mock_echo.call_args_list] assert any("adjacent-string-literal concatenation" in s for s in call_strs), ( f"no concatenation warning; saw {call_strs}" ) @patch("typer.echo") def test_dynamic_version_adjacent_literals_no_whitespace_warns(mock_echo, tmp_path): # Python accepts `"1.""2.3"` (no whitespace between) — still concatenation. (tmp_path / "_v.py").write_text('__version__ = "1.""2.3"\n') path = _write_pyproject( tmp_path, '[project]\nname = "x"\ndynamic = ["version"]\n\n[tool.comfy.version]\npath = "_v.py"\n', ) result = extract_node_configuration(path) assert result is not None assert result.project.version == "" call_strs = [str(c) for c in mock_echo.call_args_list] assert any("adjacent-string-literal concatenation" in s for s in call_strs) @patch("typer.echo") def test_dynamic_version_adjacent_literals_single_quote_warns(mock_echo, tmp_path): # Detection must fire for single-quoted literals too. (tmp_path / "_v.py").write_text("__version__ = '1.' '2.3'\n") path = _write_pyproject( tmp_path, '[project]\nname = "x"\ndynamic = ["version"]\n\n[tool.comfy.version]\npath = "_v.py"\n', ) result = extract_node_configuration(path) assert result is not None assert result.project.version == "" call_strs = [str(c) for c in mock_echo.call_args_list] assert any("adjacent-string-literal concatenation" in s for s in call_strs) @patch("typer.echo") def test_dynamic_version_adjacent_literals_mixed_quotes_warns(mock_echo, tmp_path): # Python allows mixing quote styles across adjacent literals: `"1." '2.3'` # evaluates to `"1.2.3"`. The check must inspect for ANY quote, not just # the matched quote, so a `"..." '...'` or `'...' "..."` pair still fires. (tmp_path / "_v.py").write_text("__version__ = \"1.\" '2.3'\n") path = _write_pyproject( tmp_path, '[project]\nname = "x"\ndynamic = ["version"]\n\n[tool.comfy.version]\npath = "_v.py"\n', ) result = extract_node_configuration(path) assert result is not None assert result.project.version == "" call_strs = [str(c) for c in mock_echo.call_args_list] assert any("adjacent-string-literal concatenation" in s for s in call_strs) def test_dynamic_version_semicolon_after_literal_still_resolves(tmp_path): # Negative control: `; x = 1` on the same line must NOT trigger the # concatenation check (`;` isn't a quote). Pins the narrow look-ahead # so a future broadening to "any trailing content" can't silently # regress this case. (tmp_path / "_v.py").write_text('__version__ = "1.2.3"; x = 1\n') path = _write_pyproject( tmp_path, '[project]\nname = "x"\ndynamic = ["version"]\n\n[tool.comfy.version]\npath = "_v.py"\n', ) result = extract_node_configuration(path) assert result is not None assert result.project.version == "1.2.3" def test_validate_and_extract_os_classifiers_valid(): """Test OS validation with valid classifiers.""" classifiers = [ "Operating System :: Microsoft :: Windows", "Operating System :: POSIX :: Linux", "Operating System :: MacOS", "Operating System :: OS Independent", "Programming Language :: Python :: 3", ] result = validate_and_extract_os_classifiers(classifiers) expected = ["Microsoft :: Windows", "POSIX :: Linux", "MacOS", "OS Independent"] assert result == expected @patch("typer.echo") def test_validate_and_extract_os_classifiers_invalid(mock_echo): """Test OS validation with invalid classifiers.""" classifiers = [ "Operating System :: Microsoft :: Windows", "Operating System :: Linux", # Invalid - should be "POSIX :: Linux" "Programming Language :: Python :: 3", ] result = validate_and_extract_os_classifiers(classifiers) assert result == [] mock_echo.assert_called_once() assert "Invalid Operating System classifier found" in mock_echo.call_args[0][0] def test_validate_and_extract_accelerator_classifiers_valid(): """Test accelerator validation with valid classifiers.""" classifiers = [ "Environment :: GPU :: NVIDIA CUDA", "Environment :: GPU :: AMD ROCm", "Environment :: GPU :: Intel Arc", "Environment :: NPU :: Huawei Ascend", "Environment :: GPU :: Apple Metal", "Programming Language :: Python :: 3", ] result = validate_and_extract_accelerator_classifiers(classifiers) expected = [ "GPU :: NVIDIA CUDA", "GPU :: AMD ROCm", "GPU :: Intel Arc", "NPU :: Huawei Ascend", "GPU :: Apple Metal", ] assert result == expected @patch("typer.echo") def test_validate_and_extract_accelerator_classifiers_invalid(mock_echo): """Test accelerator validation with invalid classifiers.""" classifiers = [ "Environment :: GPU :: NVIDIA CUDA", "Environment :: GPU :: Invalid GPU", # Invalid "Programming Language :: Python :: 3", ] result = validate_and_extract_accelerator_classifiers(classifiers) assert result == [] mock_echo.assert_called_once() assert "Invalid Environment classifier found" in mock_echo.call_args[0][0] def test_validate_version_valid(): """Test version validation with valid versions.""" valid_versions = [ "1.1.1", ">=1.0.0", "==2.1.0-beta", "1.5.2", "~=3.0.0", "!=1.2.3", ">2.0.0", "<3.0.0", "<=4.0.0", "<>1.0.0", "=1.0.0", "1.0.0-alpha1", ">=1.0.0,<2.0.0", "==1.2.3,!=1.2.4", ">=1.0.0,<=2.0.0,!=1.5.0", "1.0.0,2.0.0", ">1.0.0,<2.0.0,!=1.5.0-beta", ] for version in valid_versions: result = validate_version(version, "test_field") assert result == version, f"Version {version} should be valid" @patch("typer.echo") def test_validate_version_invalid(mock_echo): """Test version validation with invalid versions.""" invalid_versions = [ "1.0", # Missing patch version ">=abc", # Invalid version format "invalid-version", # Completely invalid "1.0.0.0", # Too many version parts ">>1.0.0", # Invalid operator ">=1.0.0,invalid", "1.0,2.0.0", ">=1.0.0,>=abc", ] for version in invalid_versions: result = validate_version(version, "test_field") assert result == "", f"Version {version} should be invalid" assert mock_echo.call_count == len(invalid_versions) @pytest.mark.parametrize( "url, expected", [ ("https://github.com/user/repo.git", "https://github.com/user/repo.git"), ("https://ghp_xxxx@github.com/user/repo.git", "https://github.com/user/repo.git"), ("https://user:ghp_xxxx@github.com/user/repo.git", "https://github.com/user/repo.git"), ("https://oauth2:token@gitlab.com:8443/user/repo.git", "https://gitlab.com:8443/user/repo.git"), ("git@github.com:user/repo.git", "git@github.com:user/repo.git"), ("https://user:@github.com/user/repo.git", "https://github.com/user/repo.git"), ("https://:pass@github.com/user/repo.git", "https://github.com/user/repo.git"), ("http://token@example.com/repo.git", "http://example.com/repo.git"), ("https://user:pass@[::1]:8080/repo.git", "https://[::1]:8080/repo.git"), ("git://github.com/user/repo.git", "git://github.com/user/repo.git"), ("https://github.com:443/user/repo.git", "https://github.com:443/user/repo.git"), ("ssh://git@github.com/user/repo.git", "ssh://git@github.com/user/repo.git"), ], ) def test_strip_url_credentials(url, expected): assert _strip_url_credentials(url) == expected def test_initialize_project_config_strips_credentials(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) subprocess.run(["git", "init"], cwd=tmp_path, check=True, capture_output=True) subprocess.run( ["git", "remote", "add", "origin", "https://ghp_secret@github.com/user/ComfyUI-MyNode.git"], cwd=tmp_path, check=True, capture_output=True, ) initialize_project_config() with open(tmp_path / "pyproject.toml") as f: data = tomlkit.parse(f.read()) urls = data["project"]["urls"] assert urls["Repository"] == "https://github.com/user/ComfyUI-MyNode" assert urls["Documentation"] == "https://github.com/user/ComfyUI-MyNode/wiki" assert urls["Bug Tracker"] == "https://github.com/user/ComfyUI-MyNode/issues" assert "ghp_secret" not in tomlkit.dumps(data) def test_initialize_project_config_clean_https(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) subprocess.run(["git", "init"], cwd=tmp_path, check=True, capture_output=True) subprocess.run( ["git", "remote", "add", "origin", "https://github.com/user/ComfyUI-MyNode.git"], cwd=tmp_path, check=True, capture_output=True, ) initialize_project_config() with open(tmp_path / "pyproject.toml") as f: data = tomlkit.parse(f.read()) urls = data["project"]["urls"] assert urls["Repository"] == "https://github.com/user/ComfyUI-MyNode" assert urls["Documentation"] == "https://github.com/user/ComfyUI-MyNode/wiki" assert urls["Bug Tracker"] == "https://github.com/user/ComfyUI-MyNode/issues" def test_initialize_project_config_ssh_remote(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) subprocess.run(["git", "init"], cwd=tmp_path, check=True, capture_output=True) subprocess.run( ["git", "remote", "add", "origin", "git@github.com:user/ComfyUI-TestNode.git"], cwd=tmp_path, check=True, capture_output=True, ) initialize_project_config() with open(tmp_path / "pyproject.toml") as f: data = tomlkit.parse(f.read()) urls = data["project"]["urls"] assert urls["Repository"] == "https://github.com/user/ComfyUI-TestNode" assert urls["Documentation"] == "https://github.com/user/ComfyUI-TestNode/wiki" assert urls["Bug Tracker"] == "https://github.com/user/ComfyUI-TestNode/issues" assert data["project"]["name"] == "testnode" assert data["tool"]["comfy"]["DisplayName"] == "ComfyUI-TestNode" # Issue #431: requirements.txt → pyproject.toml migration must produce # valid PEP 508 dependency specifiers. Inline comments, full-line comments, # and pip-specific options (-r, -e, --index-url, ...) are not valid deps. def _init_git_repo_with_reqs(tmp_path, requirements_content: str) -> None: subprocess.run(["git", "init"], cwd=tmp_path, check=True, capture_output=True) subprocess.run( ["git", "remote", "add", "origin", "https://github.com/user/ComfyUI-TestNode.git"], cwd=tmp_path, check=True, capture_output=True, ) (tmp_path / "requirements.txt").write_text(requirements_content) def test_initialize_project_config_strips_inline_comments(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) _init_git_repo_with_reqs( tmp_path, "matplotlib>=3.3.0 # For visualization\nnumpy>=1.0 # trailing\n", ) initialize_project_config() with open(tmp_path / "pyproject.toml") as f: data = tomlkit.parse(f.read()) deps = [str(d) for d in data["project"]["dependencies"]] assert deps == ["matplotlib>=3.3.0", "numpy>=1.0"] def test_initialize_project_config_skips_full_line_comments(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) _init_git_repo_with_reqs( tmp_path, "# heading comment\nfoo>=1.0\n # indented comment\nbar\n", ) initialize_project_config() with open(tmp_path / "pyproject.toml") as f: data = tomlkit.parse(f.read()) deps = [str(d) for d in data["project"]["dependencies"]] assert deps == ["foo>=1.0", "bar"] def test_initialize_project_config_skips_pip_options(tmp_path, monkeypatch, capsys): # `-r`, `-e`, `-c`, `--index-url`, `--extra-index-url`, `--find-links` # are pip-requirements-file syntax, not PEP 508 dep specifiers. They must # not land in [project.dependencies] where downstream build tools will # error trying to parse them. Each skipped line must also produce a # visible warning so silent data loss is avoided. monkeypatch.chdir(tmp_path) _init_git_repo_with_reqs( tmp_path, "-r other.txt\n" "-e .\n" "--index-url https://pypi.org/simple\n" "--extra-index-url https://example.com/simple\n" "--find-links ./local-wheels\n" "foo>=1.0\n", ) initialize_project_config() with open(tmp_path / "pyproject.toml") as f: data = tomlkit.parse(f.read()) deps = [str(d) for d in data["project"]["dependencies"]] assert deps == ["foo>=1.0"] out = capsys.readouterr().out for dropped in ["-r other.txt", "-e .", "--index-url", "--extra-index-url", "--find-links"]: assert dropped in out, f"missing skip warning for {dropped!r}" def test_initialize_project_config_preserves_vcs_subdirectory_fragment(tmp_path, monkeypatch): # Regression guard against a naive `split("#")[0]` fix — VCS fragments # must survive because `#` is only a comment marker when preceded by # whitespace (pip's rule). monkeypatch.chdir(tmp_path) _init_git_repo_with_reqs( tmp_path, "git+https://github.com/org/mono.git#subdirectory=pkg\n", ) initialize_project_config() with open(tmp_path / "pyproject.toml") as f: data = tomlkit.parse(f.read()) deps = [str(d) for d in data["project"]["dependencies"]] assert deps == ["git+https://github.com/org/mono.git#subdirectory=pkg"] def test_initialize_project_config_vcs_with_inline_comment(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) _init_git_repo_with_reqs( tmp_path, "git+https://github.com/org/mono.git#subdirectory=pkg # monorepo dep\n", ) initialize_project_config() with open(tmp_path / "pyproject.toml") as f: data = tomlkit.parse(f.read()) deps = [str(d) for d in data["project"]["dependencies"]] assert deps == ["git+https://github.com/org/mono.git#subdirectory=pkg"] ================================================ FILE: tests/comfy_cli/test_aria2_download.py ================================================ """Tests for aria2 RPC download support.""" import sys from types import ModuleType from unittest.mock import MagicMock, Mock, patch import pytest from comfy_cli import constants from comfy_cli.file_utils import DownloadException, _download_file_aria2, download_file # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture() def aria2_env(monkeypatch): """Set the aria2 environment variables.""" monkeypatch.setenv(constants.ARIA2_SERVER_ENV_KEY, "http://localhost:6800") monkeypatch.setenv(constants.ARIA2_SECRET_ENV_KEY, "mysecret") @pytest.fixture() def fake_aria2p(): """Inject a fake aria2p module into sys.modules so import aria2p succeeds.""" mod = ModuleType("aria2p") mod.Client = MagicMock() mod.API = MagicMock() saved = sys.modules.get("aria2p", _SENTINEL := object()) sys.modules["aria2p"] = mod yield mod if saved is _SENTINEL: sys.modules.pop("aria2p", None) else: sys.modules["aria2p"] = saved @pytest.fixture() def mock_aria2_success(aria2_env, fake_aria2p): """Mock aria2p with a download that completes immediately. The mock ``add_uris`` side-effect creates the target file on disk so that the post-download verification in ``_download_file_aria2`` passes. """ mock_download = Mock() mock_download.total_length = 1024 mock_download.completed_length = 1024 mock_download.is_complete = True mock_download.has_failed = False mock_download.is_removed = False mock_download.update = Mock() mock_api = Mock() def _add_uris(_uris, options=None): if options: import pathlib pathlib.Path(options["dir"], options["out"]).touch() return mock_download mock_api.add_uris.side_effect = _add_uris fake_aria2p.API.return_value = mock_api yield { "api": mock_api, "client_cls": fake_aria2p.Client, "api_cls": fake_aria2p.API, "download": mock_download, } # --------------------------------------------------------------------------- # TestAria2Download — unit tests for _download_file_aria2 # --------------------------------------------------------------------------- class TestAria2Download: def test_success(self, tmp_path, mock_aria2_success): """Happy path: aria2 download completes successfully.""" target = tmp_path / "model.safetensors" _download_file_aria2("http://example.com/model.safetensors", target) mock_aria2_success["api"].add_uris.assert_called_once() call_args = mock_aria2_success["api"].add_uris.call_args assert call_args[0][0] == ["http://example.com/model.safetensors"] opts = call_args[1]["options"] assert opts["dir"] == str(tmp_path) assert opts["out"] == "model.safetensors" def test_passes_headers(self, tmp_path, mock_aria2_success): """CivitAI auth headers are forwarded as aria2 header option.""" target = tmp_path / "model.bin" headers = {"Authorization": "Bearer tok123", "Content-Type": "application/json"} _download_file_aria2("http://example.com/model.bin", target, headers=headers) opts = mock_aria2_success["api"].add_uris.call_args[1]["options"] assert "header" in opts assert "Authorization: Bearer tok123" in opts["header"] assert "Content-Type: application/json" in opts["header"] def test_no_headers(self, tmp_path, mock_aria2_success): """When no headers provided, 'header' key is absent from options.""" target = tmp_path / "model.bin" _download_file_aria2("http://example.com/model.bin", target) opts = mock_aria2_success["api"].add_uris.call_args[1]["options"] assert "header" not in opts def test_missing_server_env_raises(self, tmp_path, fake_aria2p, monkeypatch): """Error when COMFYUI_MANAGER_ARIA2_SERVER is not set.""" monkeypatch.delenv(constants.ARIA2_SERVER_ENV_KEY, raising=False) monkeypatch.delenv(constants.ARIA2_SECRET_ENV_KEY, raising=False) with pytest.raises(DownloadException, match=constants.ARIA2_SERVER_ENV_KEY): _download_file_aria2("http://example.com/f.bin", tmp_path / "f.bin") def test_import_error_raises(self, tmp_path, aria2_env): """Error when aria2p package is not installed.""" with patch.dict(sys.modules, {"aria2p": None}): with pytest.raises(DownloadException, match="aria2p is required"): _download_file_aria2("http://example.com/f.bin", tmp_path / "f.bin") def test_download_failure_raises(self, tmp_path, aria2_env, fake_aria2p): """Error when aria2 reports download failed.""" mock_download = Mock() mock_download.total_length = 0 mock_download.completed_length = 0 mock_download.is_complete = False mock_download.has_failed = True mock_download.is_removed = False mock_download.error_message = "403 Forbidden" mock_download.error_code = "3" mock_download.update = Mock() mock_api = Mock() mock_api.add_uris.return_value = mock_download fake_aria2p.API.return_value = mock_api with pytest.raises(DownloadException, match="403 Forbidden"): _download_file_aria2("http://example.com/f.bin", tmp_path / "f.bin") def test_download_removed_raises(self, tmp_path, aria2_env, fake_aria2p): """Error when aria2 download is removed during progress.""" mock_download = Mock() mock_download.total_length = 0 mock_download.completed_length = 0 mock_download.is_complete = False mock_download.has_failed = False mock_download.is_removed = True mock_download.update = Mock() mock_api = Mock() mock_api.add_uris.return_value = mock_download fake_aria2p.API.return_value = mock_api with pytest.raises(DownloadException, match="removed"): _download_file_aria2("http://example.com/f.bin", tmp_path / "f.bin") def test_server_url_parsing(self, tmp_path, fake_aria2p, monkeypatch): """Server URL is correctly parsed into host and port.""" monkeypatch.setenv(constants.ARIA2_SERVER_ENV_KEY, "http://myserver:6800") monkeypatch.setenv(constants.ARIA2_SECRET_ENV_KEY, "") mock_download = Mock( total_length=100, completed_length=100, is_complete=True, has_failed=False, is_removed=False, update=Mock(), ) mock_api = Mock() mock_api.add_uris.return_value = mock_download fake_aria2p.API.return_value = mock_api target = tmp_path / "f.bin" target.touch() _download_file_aria2("http://example.com/f.bin", target) fake_aria2p.Client.assert_called_once_with(host="http://myserver", port=6800, secret="") def test_server_url_default_port(self, tmp_path, fake_aria2p, monkeypatch): """Default port 6800 is used when not specified in URL.""" monkeypatch.setenv(constants.ARIA2_SERVER_ENV_KEY, "http://myserver") monkeypatch.setenv(constants.ARIA2_SECRET_ENV_KEY, "") mock_download = Mock( total_length=100, completed_length=100, is_complete=True, has_failed=False, is_removed=False, update=Mock(), ) mock_api = Mock() mock_api.add_uris.return_value = mock_download fake_aria2p.API.return_value = mock_api target = tmp_path / "f.bin" target.touch() _download_file_aria2("http://example.com/f.bin", target) fake_aria2p.Client.assert_called_once_with(host="http://myserver", port=6800, secret="") def test_server_url_without_scheme(self, tmp_path, fake_aria2p, monkeypatch): """Server URL without scheme gets http:// prepended.""" monkeypatch.setenv(constants.ARIA2_SERVER_ENV_KEY, "myserver:6800") monkeypatch.setenv(constants.ARIA2_SECRET_ENV_KEY, "") mock_download = Mock( total_length=100, completed_length=100, is_complete=True, has_failed=False, is_removed=False, update=Mock(), ) mock_api = Mock() mock_api.add_uris.return_value = mock_download fake_aria2p.API.return_value = mock_api target = tmp_path / "f.bin" target.touch() _download_file_aria2("http://example.com/f.bin", target) fake_aria2p.Client.assert_called_once_with(host="http://myserver", port=6800, secret="") def test_secret_passed_to_client(self, tmp_path, fake_aria2p, monkeypatch): """Secret from env var is passed to aria2p.Client.""" monkeypatch.setenv(constants.ARIA2_SERVER_ENV_KEY, "http://localhost:6800") monkeypatch.setenv(constants.ARIA2_SECRET_ENV_KEY, "supersecret") mock_download = Mock( total_length=100, completed_length=100, is_complete=True, has_failed=False, is_removed=False, update=Mock(), ) mock_api = Mock() mock_api.add_uris.return_value = mock_download fake_aria2p.API.return_value = mock_api target = tmp_path / "f.bin" target.touch() _download_file_aria2("http://example.com/f.bin", target) fake_aria2p.Client.assert_called_once_with(host="http://localhost", port=6800, secret="supersecret") def test_malformed_server_url_raises(self, tmp_path, fake_aria2p, monkeypatch): """Malformed server URL with unparseable hostname raises clear error.""" monkeypatch.setenv(constants.ARIA2_SERVER_ENV_KEY, "://") monkeypatch.setenv(constants.ARIA2_SECRET_ENV_KEY, "") with pytest.raises(DownloadException, match="cannot parse hostname"): _download_file_aria2("http://example.com/f.bin", tmp_path / "f.bin") def test_update_connection_error_raises(self, tmp_path, aria2_env, fake_aria2p): """Connection drop during polling raises DownloadException.""" mock_download = Mock() mock_download.total_length = 0 mock_download.completed_length = 0 mock_download.is_complete = False mock_download.has_failed = False mock_download.is_removed = False mock_download.update = Mock(side_effect=ConnectionError("RPC server gone")) mock_api = Mock() mock_api.add_uris.return_value = mock_download fake_aria2p.API.return_value = mock_api with pytest.raises(DownloadException, match="Lost connection to aria2"): _download_file_aria2("http://example.com/f.bin", tmp_path / "f.bin") def test_file_missing_after_download_raises(self, tmp_path, aria2_env, fake_aria2p): """Error when aria2 reports success but file is not on disk.""" target = tmp_path / "subdir" / "model.safetensors" mock_download = Mock( total_length=100, completed_length=100, is_complete=True, has_failed=False, is_removed=False, update=Mock(), ) mock_api = Mock() mock_api.add_uris.return_value = mock_download fake_aria2p.API.return_value = mock_api with pytest.raises(DownloadException, match="file not found at expected path"): _download_file_aria2("http://example.com/model.safetensors", target) # --------------------------------------------------------------------------- # TestDownloadFileDispatch — dispatch tests for download_file # --------------------------------------------------------------------------- class TestDownloadFileDispatch: def test_default_downloader_uses_httpx(self, tmp_path): """When downloader is not specified, httpx is used.""" mock_response = Mock() mock_response.status_code = 200 mock_response.headers = {"Content-Length": "4"} mock_response.iter_bytes.return_value = [b"data"] mock_response.__enter__ = Mock(return_value=mock_response) mock_response.__exit__ = Mock(return_value=None) with patch("httpx.stream", return_value=mock_response) as mock_stream: download_file("http://example.com/f.bin", tmp_path / "f.bin") mock_stream.assert_called_once() def test_downloader_httpx_explicit(self, tmp_path): """When downloader='httpx', httpx is used.""" mock_response = Mock() mock_response.status_code = 200 mock_response.headers = {"Content-Length": "4"} mock_response.iter_bytes.return_value = [b"data"] mock_response.__enter__ = Mock(return_value=mock_response) mock_response.__exit__ = Mock(return_value=None) with patch("httpx.stream", return_value=mock_response) as mock_stream: download_file("http://example.com/f.bin", tmp_path / "f.bin", downloader="httpx") mock_stream.assert_called_once() def test_downloader_aria2_dispatches(self, tmp_path): """When downloader='aria2', aria2 backend is used.""" with patch("comfy_cli.file_utils._download_file_aria2") as mock_aria2: download_file("http://example.com/f.bin", tmp_path / "f.bin", downloader="aria2") mock_aria2.assert_called_once_with("http://example.com/f.bin", tmp_path / "f.bin", None) def test_invalid_downloader_raises(self, tmp_path): """Invalid downloader value raises DownloadException.""" with pytest.raises(DownloadException, match="Unknown downloader"): download_file("http://example.com/f.bin", tmp_path / "f.bin", downloader="foobar") ================================================ FILE: tests/comfy_cli/test_cm_cli_python_resolution.py ================================================ import subprocess import sys import textwrap import time from unittest.mock import MagicMock, patch import pytest from comfy_cli.command.custom_nodes import cm_cli_util def _setup_cm_cli(tmp_path, script_body): """Create a stub script and return its path.""" stub_script = tmp_path / "stub_cm_cli.py" stub_script.write_text(textwrap.dedent(script_body)) (tmp_path / "config").mkdir(exist_ok=True) return stub_script def _run(tmp_path, args, *, fast_deps=False, raise_on_error=False): """Call execute_cm_cli with standard patches for workspace/config. Patches the cmd construction to run the stub script instead of `python -m cm_cli`. """ stub_script = tmp_path / "stub_cm_cli.py" original_popen = subprocess.Popen def _patched_popen(cmd, **kwargs): # Replace `python -m cm_cli ` with `python ` if len(cmd) >= 3 and cmd[1] == "-m" and cmd[2] == "cm_cli": cmd = [cmd[0], str(stub_script)] + cmd[3:] return original_popen(cmd, **kwargs) with ( patch( "comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python", return_value=sys.executable, ), patch.object(cm_cli_util.workspace_manager, "workspace_path", str(tmp_path)), patch.object(cm_cli_util.workspace_manager, "set_recent_workspace"), patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") as MockConfig, patch("comfy_cli.command.custom_nodes.cm_cli_util.check_comfy_repo", return_value=(True, None)), patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=True), patch("comfy_cli.command.custom_nodes.cm_cli_util.subprocess.Popen", side_effect=_patched_popen), ): MockConfig.return_value.get_config_path.return_value = str(tmp_path / "config") return cm_cli_util.execute_cm_cli(args, fast_deps=fast_deps, raise_on_error=raise_on_error) class TestExecuteCmCli: def test_uses_resolved_python(self, tmp_path): _setup_cm_cli(tmp_path, 'print("ok")') mock_proc = MagicMock() mock_proc.stdout = iter(["ok\n"]) mock_proc.wait.return_value = 0 with ( patch( "comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python", return_value="/resolved/python", ) as mock_resolve, patch.object(cm_cli_util.workspace_manager, "workspace_path", str(tmp_path)), patch.object(cm_cli_util.workspace_manager, "set_recent_workspace"), patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") as MockConfig, patch("comfy_cli.command.custom_nodes.cm_cli_util.check_comfy_repo", return_value=(True, None)), patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=True), patch("comfy_cli.command.custom_nodes.cm_cli_util.subprocess.Popen", return_value=mock_proc) as mock_popen, ): MockConfig.return_value.get_config_path.return_value = str(tmp_path / "config") cm_cli_util.execute_cm_cli(["show", "installed"]) mock_resolve.assert_called_once_with(str(tmp_path)) cmd = mock_popen.call_args[0][0] assert cmd[0] == "/resolved/python" def test_fast_deps_passes_python_to_compiler(self, tmp_path): _setup_cm_cli(tmp_path, 'print("ok")') mock_proc = MagicMock() mock_proc.stdout = iter(["ok\n"]) mock_proc.wait.return_value = 0 with ( patch( "comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python", return_value="/resolved/python", ), patch.object(cm_cli_util.workspace_manager, "workspace_path", str(tmp_path)), patch.object(cm_cli_util.workspace_manager, "set_recent_workspace"), patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") as MockConfig, patch("comfy_cli.command.custom_nodes.cm_cli_util.check_comfy_repo", return_value=(True, None)), patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=True), patch("comfy_cli.command.custom_nodes.cm_cli_util.subprocess.Popen", return_value=mock_proc), patch("comfy_cli.command.custom_nodes.cm_cli_util.DependencyCompiler") as MockCompiler, ): MockConfig.return_value.get_config_path.return_value = str(tmp_path / "config") mock_instance = MagicMock() MockCompiler.return_value = mock_instance cm_cli_util.execute_cm_cli(["install", "some-node"], fast_deps=True) MockCompiler.assert_called_once() assert MockCompiler.call_args[1]["executable"] == "/resolved/python" def test_stdout_returned_and_streamed(self, tmp_path, capsys): _setup_cm_cli( tmp_path, """\ print("line 1") print("line 2") print("line 3") """, ) result = _run(tmp_path, ["test"]) assert result == "line 1\nline 2\nline 3\n" captured = capsys.readouterr() assert "line 1\nline 2\nline 3\n" in captured.out @pytest.mark.parametrize("returncode", [1, 2]) def test_expected_error_codes_return_none(self, tmp_path, returncode): _setup_cm_cli( tmp_path, f"""\ import sys sys.exit({returncode}) """, ) result = _run(tmp_path, ["test"]) assert result is None def test_unexpected_error_code_raises(self, tmp_path): _setup_cm_cli( tmp_path, """\ import sys sys.exit(42) """, ) with pytest.raises(subprocess.CalledProcessError) as exc_info: _run(tmp_path, ["test"]) assert exc_info.value.returncode == 42 def test_raise_on_error_overrides_silent_return(self, tmp_path): _setup_cm_cli( tmp_path, """\ import sys print("output before fail") sys.exit(1) """, ) with pytest.raises(subprocess.CalledProcessError) as exc_info: _run(tmp_path, ["test"], raise_on_error=True) assert exc_info.value.returncode == 1 assert "output before fail" in exc_info.value.output def test_output_streams_incrementally(self, tmp_path): _setup_cm_cli( tmp_path, """\ import time for i in range(3): print(f"line {i}") time.sleep(0.3) """, ) timestamps = [] original_write = sys.stdout.write def recording_write(s): if s.startswith("line "): timestamps.append(time.monotonic()) return original_write(s) with patch("sys.stdout") as mock_stdout: mock_stdout.write = recording_write mock_stdout.flush = lambda: None _run(tmp_path, ["test"]) assert len(timestamps) == 3 assert timestamps[2] - timestamps[0] >= 0.4 def test_pythonunbuffered_set_in_env(self, tmp_path): _setup_cm_cli(tmp_path, 'print("ok")') mock_proc = MagicMock() mock_proc.stdout = iter(["ok\n"]) mock_proc.wait.return_value = 0 with ( patch( "comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python", return_value=sys.executable, ), patch.object(cm_cli_util.workspace_manager, "workspace_path", str(tmp_path)), patch.object(cm_cli_util.workspace_manager, "set_recent_workspace"), patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") as MockConfig, patch("comfy_cli.command.custom_nodes.cm_cli_util.check_comfy_repo", return_value=(True, None)), patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=True), patch("comfy_cli.command.custom_nodes.cm_cli_util.subprocess.Popen", return_value=mock_proc) as mock_popen, ): MockConfig.return_value.get_config_path.return_value = str(tmp_path / "config") cm_cli_util.execute_cm_cli(["show", "installed"]) env = mock_popen.call_args[1]["env"] assert env["PYTHONUNBUFFERED"] == "1" ================================================ FILE: tests/comfy_cli/test_cmdline_python_resolution.py ================================================ from unittest.mock import MagicMock, patch from comfy_cli import cmdline class TestUpdateComfy: def test_uses_resolved_python(self, tmp_path): with ( patch("comfy_cli.cmdline.resolve_workspace_python", return_value="/resolved/python") as mock_resolve, patch.object(cmdline.workspace_manager, "workspace_path", str(tmp_path)), patch("comfy_cli.cmdline.os.chdir"), patch("comfy_cli.cmdline.subprocess.run") as mock_run, patch("comfy_cli.cmdline.custom_nodes.command.update_node_id_cache"), ): cmdline.update(target="comfy") mock_resolve.assert_called_once_with(str(tmp_path)) pip_call = None for c in mock_run.call_args_list: cmd = c[0][0] if "-m" in cmd and "pip" in cmd: pip_call = cmd break assert pip_call is not None, "pip install call not found" assert pip_call[0] == "/resolved/python" def test_update_comfy_succeeds_when_cm_cli_missing(self, tmp_path): """Regression test for #403: comfy update must not crash when cm-cli is absent.""" with ( patch("comfy_cli.cmdline.resolve_workspace_python", return_value="/resolved/python"), patch.object(cmdline.workspace_manager, "workspace_path", str(tmp_path)), patch("comfy_cli.cmdline.os.chdir"), patch("comfy_cli.cmdline.subprocess.run"), patch( "comfy_cli.cmdline.custom_nodes.command.update_node_id_cache", side_effect=FileNotFoundError("cm-cli not found"), ) as mock_cache, ): cmdline.update(target="comfy") mock_cache.assert_called_once() class TestDependency: def test_passes_python_to_compiler(self, tmp_path): with ( patch("comfy_cli.cmdline.resolve_workspace_python", return_value="/resolved/python") as mock_resolve, patch.object(cmdline.workspace_manager, "get_workspace_path", return_value=(str(tmp_path), None)), patch("comfy_cli.cmdline.DependencyCompiler") as MockCompiler, ): mock_instance = MagicMock() MockCompiler.return_value = mock_instance cmdline.dependency() mock_resolve.assert_called_once_with(str(tmp_path)) MockCompiler.assert_called_once() assert MockCompiler.call_args[1]["executable"] == "/resolved/python" ================================================ FILE: tests/comfy_cli/test_config_manager.py ================================================ import configparser import os from unittest.mock import patch import pytest from comfy_cli import constants from comfy_cli.config_manager import ConfigManager # Unwrap the singleton to access the original class for testing. # The singleton decorator stores the original class as the 'cls' # free variable in the wrapper closure. _ConfigManagerCls = ConfigManager.__closure__[0].cell_contents def _make_config_manager(config_dir, is_running_val=True): with ( patch.object(_ConfigManagerCls, "get_config_path", return_value=str(config_dir)), patch("comfy_cli.config_manager.is_running", return_value=is_running_val), ): return _ConfigManagerCls() @pytest.fixture def config_mgr(tmp_path): config_dir = tmp_path / "comfy-cli" config_dir.mkdir() yield _make_config_manager(config_dir) class TestLoad: def test_creates_tmp_directory(self, tmp_path): config_dir = tmp_path / "comfy-cli" config_dir.mkdir() _make_config_manager(config_dir) assert (config_dir / "tmp").is_dir() def test_reads_existing_config(self, tmp_path): config_dir = tmp_path / "comfy-cli" config_dir.mkdir() (config_dir / "config.ini").write_text( f"[DEFAULT]\n{constants.CONFIG_KEY_DEFAULT_WORKSPACE} = /path/to/comfy\n" ) mgr = _make_config_manager(config_dir) assert mgr.get(constants.CONFIG_KEY_DEFAULT_WORKSPACE) == "/path/to/comfy" def test_parses_background_info(self, tmp_path): config_dir = tmp_path / "comfy-cli" config_dir.mkdir() (config_dir / "config.ini").write_text( f"[DEFAULT]\n{constants.CONFIG_KEY_BACKGROUND} = ('localhost', 8188, 12345)\n" ) mgr = _make_config_manager(config_dir, is_running_val=True) assert mgr.background == ("localhost", 8188, 12345) def test_removes_background_when_stale_pid(self, tmp_path): config_dir = tmp_path / "comfy-cli" config_dir.mkdir() (config_dir / "config.ini").write_text( f"[DEFAULT]\n{constants.CONFIG_KEY_BACKGROUND} = ('localhost', 8188, 99999)\n" ) mgr = _make_config_manager(config_dir, is_running_val=False) assert mgr.background is None assert constants.CONFIG_KEY_BACKGROUND not in mgr.config["DEFAULT"] class TestWriteConfig: def test_creates_directory_if_missing(self, tmp_path): config_dir = tmp_path / "new-dir" with patch.object(_ConfigManagerCls, "get_config_path", return_value=str(config_dir)): mgr = _ConfigManagerCls.__new__(_ConfigManagerCls) mgr.config = configparser.ConfigParser() mgr.background = None mgr.write_config() assert (config_dir / "config.ini").exists() def test_set_persists_to_file(self, config_mgr): config_mgr.set("my_key", "my_value") parser = configparser.ConfigParser() parser.read(config_mgr.get_config_file_path()) assert parser["DEFAULT"]["my_key"] == "my_value" class TestGetBool: def test_missing_key_returns_none(self, config_mgr): assert config_mgr.get_bool("nonexistent") is None class TestGetOrOverride: def test_set_value_wins(self, config_mgr): config_mgr.config["DEFAULT"]["k"] = "from_config" with patch.dict(os.environ, {"EK": "from_env"}): assert config_mgr.get_or_override("EK", "k", set_value="from_cli") == "from_cli" def test_env_var_wins_over_config(self, config_mgr): config_mgr.config["DEFAULT"]["k"] = "from_config" with patch.dict(os.environ, {"EK": "from_env"}): assert config_mgr.get_or_override("EK", "k") == "from_env" def test_config_is_fallback(self, config_mgr): config_mgr.config["DEFAULT"]["k"] = "from_config" env = os.environ.copy() env.pop("EK", None) with patch.dict(os.environ, env, clear=True): assert config_mgr.get_or_override("EK", "k") == "from_config" def test_empty_set_value_returns_none(self, config_mgr): assert config_mgr.get_or_override("EK", "k", set_value="") is None def test_empty_env_var_returns_none(self, config_mgr): with patch.dict(os.environ, {"EK": ""}): assert config_mgr.get_or_override("EK", "k") is None def test_set_value_is_persisted(self, config_mgr): config_mgr.get_or_override("EK", "k", set_value="saved") assert config_mgr.get("k") == "saved" def test_all_missing_returns_none(self, config_mgr): env = os.environ.copy() env.pop("EK", None) with patch.dict(os.environ, env, clear=True): assert config_mgr.get_or_override("EK", "k") is None class TestGetEnvData: def test_full_config(self, config_mgr): config_mgr.config["DEFAULT"][constants.CONFIG_KEY_DEFAULT_WORKSPACE] = "/my/ws" config_mgr.config["DEFAULT"][constants.CONFIG_KEY_DEFAULT_LAUNCH_EXTRAS] = "--cpu" config_mgr.config["DEFAULT"][constants.CONFIG_KEY_RECENT_WORKSPACE] = "/recent" config_mgr.config["DEFAULT"][constants.CONFIG_KEY_ENABLE_TRACKING] = "true" config_mgr.config["DEFAULT"][constants.CONFIG_KEY_BACKGROUND] = "('localhost', 8188, 42)" config_mgr.background = ("localhost", 8188, 42) data = dict(config_mgr.get_env_data()) assert data["Default ComfyUI workspace"] == "/my/ws" assert data["Default ComfyUI launch extra options"] == "--cpu" assert data["Recent ComfyUI workspace"] == "/recent" assert data["Tracking Analytics"] == "Enabled" assert "localhost:8188" in data["Background ComfyUI"] assert "42" in data["Background ComfyUI"] def test_empty_config(self, config_mgr): data = dict(config_mgr.get_env_data()) assert data["Default ComfyUI workspace"] == "No default ComfyUI workspace" assert data["Recent ComfyUI workspace"] == "No recent run" assert "Tracking Analytics" not in data assert "None" in data["Default ComfyUI launch extra options"] def test_launch_extras_only_read_when_workspace_set(self, config_mgr): config_mgr.config["DEFAULT"][constants.CONFIG_KEY_DEFAULT_LAUNCH_EXTRAS] = "--gpu" data = dict(config_mgr.get_env_data()) assert "None" in data["Default ComfyUI launch extra options"] class TestRemoveBackground: def test_clears_background(self, config_mgr): config_mgr.config["DEFAULT"][constants.CONFIG_KEY_BACKGROUND] = "('h', 1, 2)" config_mgr.background = ("h", 1, 2) config_mgr.remove_background() assert config_mgr.background is None assert constants.CONFIG_KEY_BACKGROUND not in config_mgr.config["DEFAULT"] ================================================ FILE: tests/comfy_cli/test_cuda_detect.py ================================================ import subprocess from unittest.mock import MagicMock, patch import pytest from comfy_cli.cuda_detect import ( DEFAULT_CUDA_TAG, PYTORCH_CUDA_WHEELS, _detect_via_ctypes, _detect_via_nvidia_smi, _load_libcuda, detect_cuda_driver_version, resolve_cuda_wheel, ) class TestDetectViaCtypes: def test_happy_path(self): lib = MagicMock() lib.cuInit.return_value = 0 def fake_get(ptr): ptr._obj.value = 13000 return 0 lib.cuDriverGetVersion.side_effect = fake_get with patch("comfy_cli.cuda_detect._load_libcuda", return_value=lib): assert _detect_via_ctypes() == 13000 @pytest.mark.parametrize( "raw,expected", [ (12060, (12, 6)), (11080, (11, 8)), (13010, (13, 1)), (13000, (13, 0)), (12080, (12, 8)), ], ) def test_version_decoding(self, raw, expected): lib = MagicMock() lib.cuInit.return_value = 0 def fake_get(ptr): ptr._obj.value = raw return 0 lib.cuDriverGetVersion.side_effect = fake_get with patch("comfy_cli.cuda_detect._load_libcuda", return_value=lib): result = _detect_via_ctypes() assert result == raw major = result // 1000 minor = (result % 1000) // 10 assert (major, minor) == expected def test_library_not_found(self): with patch("comfy_cli.cuda_detect._load_libcuda", side_effect=OSError("not found")): assert _detect_via_ctypes() is None def test_cuinit_fails(self): lib = MagicMock() lib.cuInit.return_value = 100 with patch("comfy_cli.cuda_detect._load_libcuda", return_value=lib): assert _detect_via_ctypes() is None class TestDetectViaNvidiaSmi: def test_happy_path(self): output = ( "Mon Mar 30 12:00:00 2026\n" "+-------------------------+\n" "| NVIDIA-SMI 560.35.03 Driver Version: 560.35.03 CUDA Version: 12.6 |\n" ) with patch("comfy_cli.cuda_detect.subprocess.check_output", return_value=output): assert _detect_via_nvidia_smi() == (12, 6) def test_cuda_13(self): output = "| NVIDIA-SMI 570.00 Driver Version: 570.00 CUDA Version: 13.0 |\n" with patch("comfy_cli.cuda_detect.subprocess.check_output", return_value=output): assert _detect_via_nvidia_smi() == (13, 0) def test_not_found(self): with patch("comfy_cli.cuda_detect.subprocess.check_output", side_effect=FileNotFoundError): assert _detect_via_nvidia_smi() is None def test_parse_failure(self): with patch("comfy_cli.cuda_detect.subprocess.check_output", return_value="some random output"): assert _detect_via_nvidia_smi() is None def test_timeout(self): with patch( "comfy_cli.cuda_detect.subprocess.check_output", side_effect=subprocess.TimeoutExpired("nvidia-smi", 10), ): assert _detect_via_nvidia_smi() is None class TestDetectCudaDriverVersion: def test_ctypes_success_skips_smi(self): lib = MagicMock() lib.cuInit.return_value = 0 def fake_get(ptr): ptr._obj.value = 12060 return 0 lib.cuDriverGetVersion.side_effect = fake_get with ( patch("comfy_cli.cuda_detect._load_libcuda", return_value=lib), patch("comfy_cli.cuda_detect._detect_via_nvidia_smi") as mock_smi, ): result = detect_cuda_driver_version() assert result == (12, 6) mock_smi.assert_not_called() def test_ctypes_fails_falls_back_to_smi(self): with ( patch("comfy_cli.cuda_detect._load_libcuda", side_effect=OSError), patch("comfy_cli.cuda_detect._detect_via_nvidia_smi", return_value=(13, 0)), ): assert detect_cuda_driver_version() == (13, 0) def test_both_fail(self): with ( patch("comfy_cli.cuda_detect._load_libcuda", side_effect=OSError), patch("comfy_cli.cuda_detect._detect_via_nvidia_smi", return_value=None), ): assert detect_cuda_driver_version() is None def test_cuda_visible_devices_restored(self): import os with ( patch.dict(os.environ, {"CUDA_VISIBLE_DEVICES": "0,1"}), patch("comfy_cli.cuda_detect._load_libcuda", side_effect=OSError), patch("comfy_cli.cuda_detect._detect_via_nvidia_smi", return_value=None), ): detect_cuda_driver_version() assert os.environ["CUDA_VISIBLE_DEVICES"] == "0,1" def test_cuda_visible_devices_empty_string(self): import os lib = MagicMock() env_during_call = {} def capturing_cuInit(val): env_during_call["CUDA_VISIBLE_DEVICES"] = os.environ.get("CUDA_VISIBLE_DEVICES", "UNSET") return 0 lib.cuInit.side_effect = capturing_cuInit def fake_get(ptr): ptr._obj.value = 13000 return 0 lib.cuDriverGetVersion.side_effect = fake_get with ( patch.dict(os.environ, {"CUDA_VISIBLE_DEVICES": ""}), patch("comfy_cli.cuda_detect._load_libcuda", return_value=lib), ): result = detect_cuda_driver_version() assert result == (13, 0) assert env_during_call["CUDA_VISIBLE_DEVICES"] == "UNSET" assert os.environ["CUDA_VISIBLE_DEVICES"] == "" class TestResolveCudaWheel: @pytest.mark.parametrize( "driver_version,expected", [ ((13, 0), "cu130"), ((12, 9), "cu129"), ((12, 8), "cu128"), ((12, 7), "cu126"), ((12, 6), "cu126"), ((12, 5), "cu124"), ((12, 4), "cu124"), ((12, 1), "cu121"), ((12, 0), "cu118"), ((11, 8), "cu118"), ], ) def test_mapping(self, driver_version, expected): assert resolve_cuda_wheel(driver_version) == expected def test_driver_too_old(self): assert resolve_cuda_wheel((11, 7)) is None assert resolve_cuda_wheel((10, 0)) is None def test_very_new_driver(self): assert resolve_cuda_wheel((14, 0)) == "cu130" assert resolve_cuda_wheel((15, 5)) == "cu130" def test_exact_match_preferred(self): assert resolve_cuda_wheel((13, 0)) == "cu130" assert resolve_cuda_wheel((12, 6)) == "cu126" class TestLoadLibcuda: def test_linux_paths_tried_in_order(self): calls = [] def tracking_cdll(path): calls.append(path) raise OSError("not found") with ( patch("comfy_cli.cuda_detect.platform.system", return_value="Linux"), patch("comfy_cli.cuda_detect.ctypes.CDLL", side_effect=tracking_cdll), pytest.raises(OSError), ): _load_libcuda() assert calls == [ "libcuda.so.1", "/usr/lib/wsl/lib/libcuda.so.1", "/usr/lib64/nvidia/libcuda.so.1", "/usr/lib/x86_64-linux-gnu/libcuda.so.1", ] def test_windows_path(self): calls = [] def tracking_cdll(path): calls.append(path) raise OSError("not found") with ( patch("comfy_cli.cuda_detect.platform.system", return_value="Windows"), patch("comfy_cli.cuda_detect.ctypes.CDLL", side_effect=tracking_cdll), pytest.raises(OSError), ): _load_libcuda() assert calls == ["nvcuda.dll"] def test_first_success_wins(self): mock_lib = MagicMock() def first_success(path): if path == "libcuda.so.1": return mock_lib raise OSError("not found") with ( patch("comfy_cli.cuda_detect.platform.system", return_value="Linux"), patch("comfy_cli.cuda_detect.ctypes.CDLL", side_effect=first_success), ): result = _load_libcuda() assert result is mock_lib class TestConstants: def test_wheels_in_descending_order(self): def parse_tag(tag): digits = tag[2:] return int(digits[0:2]), int(digits[2:]) versions = [parse_tag(t) for t in PYTORCH_CUDA_WHEELS] assert versions == sorted(versions, reverse=True) def test_default_tag_is_in_wheel_list(self): assert DEFAULT_CUDA_TAG in PYTORCH_CUDA_WHEELS ================================================ FILE: tests/comfy_cli/test_cuda_detect_real.py ================================================ """Real-hardware integration tests for CUDA auto-detection. These tests call the detection functions without mocks, exercising the actual ctypes/nvidia-smi code paths on machines with NVIDIA drivers. Automatically skipped when nvidia-smi is not available (i.e. no NVIDIA GPU). Runs on GPU CI runners (run-on-gpu.yml) and any dev machine with a GPU. """ import shutil import subprocess import pytest from comfy_cli.cuda_detect import ( PYTORCH_CUDA_WHEELS, _detect_via_nvidia_smi, detect_cuda_driver_version, resolve_cuda_wheel, ) _has_nvidia_smi = shutil.which("nvidia-smi") is not None pytestmark = pytest.mark.skipif( not _has_nvidia_smi, reason="nvidia-smi not found — no NVIDIA GPU available", ) def _nvidia_smi_cuda_version() -> tuple[int, int] | None: """Parse CUDA version directly from nvidia-smi for cross-checking.""" try: out = subprocess.check_output(["nvidia-smi"], text=True, timeout=10, stderr=subprocess.DEVNULL) except (FileNotFoundError, subprocess.SubprocessError): return None import re m = re.search(r"CUDA Version:\s*(\d+)\.(\d+)", out) return (int(m.group(1)), int(m.group(2))) if m else None class TestRealDetection: def test_detect_returns_valid_tuple(self): result = detect_cuda_driver_version() assert result is not None, "detect_cuda_driver_version() returned None on a machine with nvidia-smi" major, minor = result assert isinstance(major, int) assert isinstance(minor, int) assert major >= 11, f"Unexpected CUDA major version: {major}" def test_detect_matches_nvidia_smi(self): smi_version = _nvidia_smi_cuda_version() assert smi_version is not None detected = detect_cuda_driver_version() assert detected is not None assert detected == smi_version, ( f"detect_cuda_driver_version() returned {detected} but nvidia-smi reports {smi_version}" ) def test_nvidia_smi_fallback_works(self): result = _detect_via_nvidia_smi() assert result is not None, "_detect_via_nvidia_smi() returned None despite nvidia-smi being available" major, minor = result assert major >= 11 def test_resolve_wheel_for_detected_driver(self): detected = detect_cuda_driver_version() assert detected is not None tag = resolve_cuda_wheel(detected) assert tag is not None, f"resolve_cuda_wheel({detected}) returned None — driver too old for any wheel?" assert tag in PYTORCH_CUDA_WHEELS def test_resolved_wheel_version_not_greater_than_driver(self): detected = detect_cuda_driver_version() assert detected is not None drv_major, drv_minor = detected tag = resolve_cuda_wheel(detected) assert tag is not None digits = tag[2:] whl_major = int(digits[0:2]) whl_minor = int(digits[2:]) assert (whl_major, whl_minor) <= (drv_major, drv_minor), ( f"Wheel {tag} requires CUDA {whl_major}.{whl_minor} but driver only supports {drv_major}.{drv_minor}" ) ================================================ FILE: tests/comfy_cli/test_custom_nodes_python_resolution.py ================================================ import os from unittest.mock import patch from comfy_cli.command.custom_nodes import command class TestGetInstalledPackages: def test_uses_resolved_python(self): command.pip_map = None with ( patch( "comfy_cli.command.custom_nodes.command.resolve_workspace_python", return_value="/resolved/python", ), patch.object(command.workspace_manager, "workspace_path", "/fake/workspace"), patch( "comfy_cli.command.custom_nodes.command.subprocess.check_output", return_value="Package Version\n------ -------\npip 24.0\n", ) as mock_check_output, ): command.get_installed_packages() cmd = mock_check_output.call_args[0][0] assert cmd[0] == "/resolved/python" assert cmd == ["/resolved/python", "-m", "pip", "list"] command.pip_map = None class TestExecuteInstallScript: def test_pip_uses_resolved_python(self, tmp_path): (tmp_path / "requirements.txt").write_text("somepackage\n") with ( patch( "comfy_cli.command.custom_nodes.command.resolve_workspace_python", return_value="/resolved/python", ), patch.object(command.workspace_manager, "workspace_path", str(tmp_path)), patch("comfy_cli.command.custom_nodes.command.subprocess.check_call") as mock_check_call, ): command.execute_install_script(str(tmp_path)) mock_check_call.assert_called() cmd = mock_check_call.call_args[0][0] assert cmd[0] == "/resolved/python" assert "-m" in cmd and "pip" in cmd def test_install_py_uses_resolved_python(self, tmp_path): (tmp_path / "install.py").write_text("print('install')\n") with ( patch( "comfy_cli.command.custom_nodes.command.resolve_workspace_python", return_value="/resolved/python", ), patch.object(command.workspace_manager, "workspace_path", str(tmp_path)), patch("comfy_cli.command.custom_nodes.command.subprocess.check_call") as mock_check_call, ): command.execute_install_script(str(tmp_path)) mock_check_call.assert_called() cmd = mock_check_call.call_args[0][0] assert cmd == ["/resolved/python", "install.py"] def test_inline_comment_not_passed_to_pip(self, tmp_path): # Issue #431 regression: inline comments in requirements.txt must not # survive into the argv handed to pip. Pre-fix, the raw line was passed # verbatim (e.g. "matplotlib>=3.3.0 # note") and pip rejected it. bad_spec = "matplotlib>=3.3.0 # For visualization" (tmp_path / "requirements.txt").write_text(f"{bad_spec}\n") with ( patch( "comfy_cli.command.custom_nodes.command.resolve_workspace_python", return_value="/resolved/python", ), patch.object(command.workspace_manager, "workspace_path", str(tmp_path)), patch("comfy_cli.command.custom_nodes.command.subprocess.check_call") as mock_check_call, ): command.execute_install_script(str(tmp_path)) for call in mock_check_call.call_args_list: argv = call[0][0] assert bad_spec not in argv, f"raw comment-laden spec leaked into pip argv: {argv!r}" def test_uses_pip_install_r(self, tmp_path): # Option C: delegate requirements-file parsing to pip via `-r `. # This lets pip handle inline comments, line continuations, VCS URL # fragments, env markers, -e, -r, --index-url, etc. requirements_path = tmp_path / "requirements.txt" requirements_path.write_text("numpy>=1.0\n") with ( patch( "comfy_cli.command.custom_nodes.command.resolve_workspace_python", return_value="/resolved/python", ), patch.object(command.workspace_manager, "workspace_path", str(tmp_path)), patch("comfy_cli.command.custom_nodes.command.subprocess.check_call") as mock_check_call, ): command.execute_install_script(str(tmp_path)) mock_check_call.assert_called_once() argv = mock_check_call.call_args[0][0] assert argv == ["/resolved/python", "-m", "pip", "install", "-r", str(requirements_path)] def test_requirements_path_is_absolute_when_repo_path_is_relative(self, tmp_path, monkeypatch): # try_install_script runs pip with cwd=repo_path. If requirements_path # were relative, pip would resolve `-r /requirements.txt` against # that cwd, producing a doubled path like //requirements.txt. # Guard: the `-r` target must be absolute regardless of the input. (tmp_path / "requirements.txt").write_text("numpy>=1.0\n") monkeypatch.chdir(tmp_path.parent) relative_repo = tmp_path.name # e.g. "test_requirements_path..." relative to cwd with ( patch( "comfy_cli.command.custom_nodes.command.resolve_workspace_python", return_value="/resolved/python", ), patch.object(command.workspace_manager, "workspace_path", str(tmp_path)), patch("comfy_cli.command.custom_nodes.command.subprocess.check_call") as mock_check_call, ): command.execute_install_script(relative_repo) argv = mock_check_call.call_args[0][0] target = argv[-1] assert os.path.isabs(target), f"-r target is not absolute: {target!r}" assert target == str(tmp_path / "requirements.txt") class TestUpdateNodeIdCache: def test_uses_resolved_python(self, tmp_path): cm_cli_path = tmp_path / "custom_nodes" / "ComfyUI-Manager" / "cm-cli.py" cm_cli_path.parent.mkdir(parents=True) cm_cli_path.touch() config_path = tmp_path / "config" config_path.mkdir() with ( patch( "comfy_cli.command.custom_nodes.command.resolve_workspace_python", return_value="/resolved/python", ), patch.object(command.workspace_manager, "workspace_path", str(tmp_path)), patch("comfy_cli.command.custom_nodes.command.ConfigManager") as MockConfig, patch("comfy_cli.command.custom_nodes.command.subprocess.run") as mock_run, ): MockConfig.return_value.get_config_path.return_value = str(config_path) command.update_node_id_cache() cmd = mock_run.call_args[0][0] assert cmd[0] == "/resolved/python" ================================================ FILE: tests/comfy_cli/test_env_checker.py ================================================ import os import sys from types import SimpleNamespace from unittest.mock import patch import pytest import requests from comfy_cli.env_checker import EnvChecker, check_comfy_server_running, format_python_version _EnvCheckerCls = EnvChecker.__closure__[0].cell_contents class TestFormatPythonVersion: def test_modern_python(self): v = SimpleNamespace(major=3, minor=12, micro=1) assert format_python_version(v) == "3.12.1" def test_python_39_is_modern(self): v = SimpleNamespace(major=3, minor=9, micro=0) assert format_python_version(v) == "3.9.0" def test_python_38_is_old(self): v = SimpleNamespace(major=3, minor=8, micro=5) result = format_python_version(v) assert "bold red" in result assert "3.8.5" in result def test_python_37_is_old(self): v = SimpleNamespace(major=3, minor=7, micro=0) result = format_python_version(v) assert "bold red" in result class TestCheckComfyServerRunning: @patch("comfy_cli.env_checker.requests.get") def test_server_running(self, mock_get): mock_get.return_value.status_code = 200 assert check_comfy_server_running() is True @patch("comfy_cli.env_checker.requests.get") def test_server_not_running(self, mock_get): mock_get.side_effect = requests.exceptions.ConnectionError() assert check_comfy_server_running() is False @patch("comfy_cli.env_checker.requests.get") def test_non_200_status(self, mock_get): mock_get.return_value.status_code = 500 assert check_comfy_server_running() is False @patch("comfy_cli.env_checker.requests.get") def test_custom_port_and_host(self, mock_get): mock_get.return_value.status_code = 200 check_comfy_server_running(port=9999, host="0.0.0.0") mock_get.assert_called_with("http://0.0.0.0:9999/history") class TestEnvChecker: @pytest.fixture def checker(self): inst = _EnvCheckerCls.__new__(_EnvCheckerCls) inst.python_version = sys.version_info inst.virtualenv_path = None inst.conda_env = None return inst def test_check_detects_virtualenv(self, checker): with patch.dict(os.environ, {"VIRTUAL_ENV": "/path/to/venv"}): checker.check() assert checker.virtualenv_path == "/path/to/venv" def test_check_detects_conda(self, checker): with patch.dict(os.environ, {"CONDA_DEFAULT_ENV": "myenv"}): checker.check() assert checker.conda_env == "myenv" def test_check_no_isolated_env(self, checker): env = os.environ.copy() env.pop("VIRTUAL_ENV", None) env.pop("CONDA_DEFAULT_ENV", None) with patch.dict(os.environ, env, clear=True): checker.check() assert checker.virtualenv_path is None assert checker.conda_env is None def test_get_isolated_env_prefers_venv(self, checker): checker.virtualenv_path = "/venv" checker.conda_env = "conda" assert checker.get_isolated_env() == "/venv" def test_get_isolated_env_falls_back_to_conda(self, checker): checker.conda_env = "conda" assert checker.get_isolated_env() == "conda" @patch("comfy_cli.env_checker.check_comfy_server_running", return_value=True) @patch("comfy_cli.env_checker.ConfigManager") def test_fill_print_table_server_running(self, mock_cm, mock_server, checker): mock_cm.return_value.get_env_data.return_value = [] data = dict(checker.fill_print_table()) assert "Yes" in data["Comfy Server Running"] @patch("comfy_cli.env_checker.check_comfy_server_running", return_value=False) @patch("comfy_cli.env_checker.ConfigManager") def test_fill_print_table_server_not_running(self, mock_cm, mock_server, checker): mock_cm.return_value.get_env_data.return_value = [] data = dict(checker.fill_print_table()) assert "No" in data["Comfy Server Running"] ================================================ FILE: tests/comfy_cli/test_file_utils.py ================================================ import zipfile from comfy_cli import file_utils def test_zip_files_respects_comfyignore(tmp_path, monkeypatch): project_dir = tmp_path (project_dir / "keep.txt").write_text("keep", encoding="utf-8") (project_dir / "ignore.log").write_text("ignore", encoding="utf-8") ignored_dir = project_dir / "ignored_dir" ignored_dir.mkdir() (ignored_dir / "nested.txt").write_text("nested", encoding="utf-8") (project_dir / ".comfyignore").write_text("*.log\nignored_dir/\n", encoding="utf-8") zip_path = project_dir / "node.zip" monkeypatch.chdir(project_dir) monkeypatch.setattr( file_utils, "list_git_tracked_files", lambda base_path=".": [ "keep.txt", "ignore.log", "ignored_dir/nested.txt", ], ) file_utils.zip_files(str(zip_path)) with zipfile.ZipFile(zip_path, "r") as zf: names = set(zf.namelist()) assert "keep.txt" in names assert "ignore.log" not in names assert not any(name.startswith("ignored_dir/") for name in names) def test_zip_files_force_include_overrides_ignore(tmp_path, monkeypatch): project_dir = tmp_path include_dir = project_dir / "include_me" include_dir.mkdir() (include_dir / "data.json").write_text("{}", encoding="utf-8") (project_dir / "other.txt").write_text("ok", encoding="utf-8") (project_dir / ".comfyignore").write_text("include_me/\n", encoding="utf-8") zip_path = project_dir / "node.zip" monkeypatch.chdir(project_dir) monkeypatch.setattr( file_utils, "list_git_tracked_files", lambda base_path=".": [ "other.txt", "include_me/data.json", ], ) file_utils.zip_files(str(zip_path), includes=["include_me"]) with zipfile.ZipFile(zip_path, "r") as zf: names = set(zf.namelist()) assert "include_me/data.json" in names assert "other.txt" in names def test_zip_files_without_git_falls_back_to_walk(tmp_path, monkeypatch): project_dir = tmp_path (project_dir / "file.txt").write_text("data", encoding="utf-8") zip_path = project_dir / "node.zip" monkeypatch.chdir(project_dir) monkeypatch.setattr(file_utils, "list_git_tracked_files", lambda base_path=".": []) file_utils.zip_files(str(zip_path)) with zipfile.ZipFile(zip_path, "r") as zf: names = set(zf.namelist()) assert "file.txt" in names assert "node.zip" not in names ================================================ FILE: tests/comfy_cli/test_global_python_install.py ================================================ """Integration tests for the global-Python (Docker / bare-metal) install path. Covers the scenario where comfy-cli is installed via ``pip install`` or ``uv pip install`` into the system Python (no virtualenv). In this case ``sys.prefix == sys.base_prefix``, and comfy-cli must install ComfyUI dependencies into the same global environment instead of creating a workspace ``.venv``. See https://github.com/Comfy-Org/comfy-cli/issues/393 """ import os import sys from pathlib import Path from unittest.mock import MagicMock, patch import pytest from comfy_cli.command import install from comfy_cli.resolve_python import ensure_workspace_python def _clean_env(): """Context manager that removes VIRTUAL_ENV / CONDA_PREFIX for the block.""" keys = ("VIRTUAL_ENV", "CONDA_PREFIX") saved = {k: os.environ.pop(k, None) for k in keys} class _Ctx: def __enter__(self): return self def __exit__(self, *a): for k, v in saved.items(): if v is not None: os.environ[k] = v return _Ctx() class TestGlobalPythonDetection: """ensure_workspace_python must return sys.executable when running from the system Python and must NOT create a .venv.""" def test_global_python_skips_venv_creation(self, tmp_path): workspace = tmp_path / "workspace" workspace.mkdir() with ( _clean_env(), patch("comfy_cli.resolve_python.sys") as mock_sys, patch("comfy_cli.resolve_python._is_externally_managed", return_value=False), ): mock_sys.executable = "/usr/bin/python3" mock_sys.prefix = "/usr" mock_sys.base_prefix = "/usr" result = ensure_workspace_python(str(workspace)) assert result == "/usr/bin/python3" assert not (workspace / ".venv").exists() assert not (workspace / "venv").exists() def test_isolated_env_creates_venv(self, tmp_path): workspace = tmp_path / "workspace" workspace.mkdir() with _clean_env(), patch("comfy_cli.resolve_python.sys") as mock_sys: mock_sys.executable = sys.executable mock_sys.prefix = "/home/user/.local/pipx/venvs/comfy-cli" mock_sys.base_prefix = "/usr" result = ensure_workspace_python(str(workspace)) assert (workspace / ".venv").is_dir() assert ".venv" in result class TestGlobalPythonInstallExecute: """install.execute with --fast-deps must pass through the global Python to DependencyCompiler (no .venv indirection).""" def _run_execute(self, tmp_path, *, fast_deps, python="/usr/bin/python3"): repo_dir = str(tmp_path) with ( patch("comfy_cli.command.install.ensure_workspace_python", return_value=python) as mock_ensure, patch("comfy_cli.command.install.clone_comfyui"), patch("comfy_cli.command.install.check_comfy_repo", return_value=(True, None)), patch("comfy_cli.command.install.pip_install_comfyui_dependencies") as mock_pip, patch("comfy_cli.command.install.DependencyCompiler") as MockCompiler, patch("comfy_cli.command.install.WorkspaceManager"), patch("comfy_cli.config_manager.ConfigManager"), patch.object(install.workspace_manager, "skip_prompting", True), patch.object(install.workspace_manager, "setup_workspace_manager"), ): MockCompiler.Install_Build_Deps = MagicMock() MockCompiler.return_value = MagicMock() install.execute( url="https://github.com/comfyanonymous/ComfyUI.git", comfy_path=repo_dir, restore=False, skip_manager=True, version="nightly", fast_deps=fast_deps, ) return mock_ensure, mock_pip, MockCompiler def test_fast_deps_global_python_skips_install_build_deps(self, tmp_path): mock_ensure, mock_pip, MockCompiler = self._run_execute(tmp_path, fast_deps=True, python=sys.executable) mock_ensure.assert_called_once_with(str(tmp_path)) mock_pip.assert_not_called() MockCompiler.Install_Build_Deps.assert_not_called() assert MockCompiler.call_args[1]["executable"] == sys.executable def test_fast_deps_venv_python_calls_install_build_deps(self, tmp_path): mock_ensure, mock_pip, MockCompiler = self._run_execute( tmp_path, fast_deps=True, python="/workspace/.venv/bin/python" ) mock_pip.assert_not_called() MockCompiler.Install_Build_Deps.assert_called_once_with(executable="/workspace/.venv/bin/python") assert MockCompiler.call_args[1]["executable"] == "/workspace/.venv/bin/python" def test_non_fast_deps_uses_global_python(self, tmp_path): mock_ensure, mock_pip, MockCompiler = self._run_execute(tmp_path, fast_deps=False) mock_ensure.assert_called_once_with(str(tmp_path)) mock_pip.assert_called_once() assert mock_pip.call_args[1]["python"] == "/usr/bin/python3" MockCompiler.assert_not_called() @pytest.mark.skipif( os.environ.get("TEST_TORCH_BACKEND") != "true", reason="Set TEST_TORCH_BACKEND=true to run integration tests that call uv pip compile", ) class TestDependencyCompilerGlobalPython: """Integration tests: run the real DependencyCompiler compile step (no mocks, requires network) using the current Python as if it were a global install. Verifies the compiled output contains expected packages and correct index URLs.""" @pytest.fixture() def workspace(self, tmp_path): ws = tmp_path / "workspace" ws.mkdir() (ws / "custom_nodes").mkdir() (ws / "requirements.txt").write_text("pyyaml\nrequests\n") return ws def test_compile_produces_complete_output(self, workspace): from comfy_cli.uv import DependencyCompiler dep = DependencyCompiler( cwd=str(workspace), executable=sys.executable, gpu=None, outDir=str(workspace), ) dep.compile_deps() compiled = Path(dep.out).read_text() pkg_lines = [ ln.split("==")[0].strip().lower() for ln in compiled.splitlines() if "==" in ln and not ln.strip().startswith("#") ] assert "pyyaml" in pkg_lines assert "requests" in pkg_lines assert "--index-url" in compiled def test_compile_nvidia_resolves_torch(self, workspace): (workspace / "requirements.txt").write_text("torch\npyyaml\n") from comfy_cli.constants import GPU_OPTION from comfy_cli.uv import DependencyCompiler dep = DependencyCompiler( cwd=str(workspace), executable=sys.executable, gpu=GPU_OPTION.NVIDIA, cuda_version="12.6", outDir=str(workspace), ) dep.compile_deps() compiled = Path(dep.out).read_text().lower() assert "torch==" in compiled assert "pyyaml==" in compiled assert "https://pypi.org/simple" in compiled def test_install_targets_correct_python(self, workspace): from comfy_cli.uv import DependencyCompiler dep = DependencyCompiler( cwd=str(workspace), executable=sys.executable, gpu=None, outDir=str(workspace), ) dep.compile_deps() with patch("comfy_cli.uv._check_call") as mock_call: dep.install_deps() cmd = mock_call.call_args[1].get("cmd") or mock_call.call_args[0][0] assert cmd[0] == str(Path(sys.executable).expanduser().absolute()) assert "--requirement" in cmd ================================================ FILE: tests/comfy_cli/test_install.py ================================================ from unittest.mock import MagicMock, patch import pytest from comfy_cli.command.install import pip_install_manager, validate_version def test_validate_version_nightly(): assert validate_version("nightly") == "nightly" assert validate_version("NIGHTLY") == "nightly" def test_validate_version_latest(): assert validate_version("latest") == "latest" assert validate_version("LATEST") == "latest" def test_validate_version_valid_semver(): assert validate_version("1.2.3") == "1.2.3" assert validate_version("v1.2.3") == "1.2.3" assert validate_version("1.2.3-alpha") == "1.2.3-alpha" def test_validate_version_invalid(): with pytest.raises(ValueError): validate_version("invalid_version") def test_validate_version_empty(): with pytest.raises(ValueError): validate_version("") class TestPipInstallManager: @patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli") @patch("comfy_cli.command.install.subprocess.run") @patch("os.path.exists", return_value=True) def test_success(self, mock_exists, mock_run, mock_find): mock_run.return_value = MagicMock(returncode=0) result = pip_install_manager("/fake/repo") assert result is True mock_run.assert_called_once() @patch("os.path.exists", return_value=False) def test_missing_requirements_file(self, mock_exists): result = pip_install_manager("/fake/repo") assert result is False @patch("comfy_cli.command.install.subprocess.run") @patch("os.path.exists", return_value=True) def test_pip_failure(self, mock_exists, mock_run): mock_run.return_value = MagicMock(returncode=1, stderr="some error") result = pip_install_manager("/fake/repo") assert result is False @patch("comfy_cli.command.install.subprocess.run") @patch("os.path.exists", return_value=True) def test_pip_failure_no_stderr(self, mock_exists, mock_run): mock_run.return_value = MagicMock(returncode=1, stderr="") result = pip_install_manager("/fake/repo") assert result is False # Run the tests if __name__ == "__main__": pytest.main([__file__]) ================================================ FILE: tests/comfy_cli/test_install_python_resolution.py ================================================ import sys from unittest.mock import MagicMock, patch import pytest from comfy_cli import constants from comfy_cli.command import install from comfy_cli.constants import GPU_OPTION class TestPipInstallComfyuiDependencies: def test_uses_python_param_cpu(self, tmp_path): repo_dir = str(tmp_path) (tmp_path / "requirements.txt").write_text("some-package\n") with patch("comfy_cli.command.install.subprocess.run", return_value=MagicMock(returncode=0)) as mock_run: install.pip_install_comfyui_dependencies( repo_dir, gpu=None, plat=None, cuda_version=None, skip_torch_or_directml=False, skip_requirement=False, python="/resolved/python", ) for c in mock_run.call_args_list: cmd = c[0][0] assert cmd[0] == "/resolved/python", f"Expected /resolved/python but got {cmd[0]} in {cmd}" assert cmd[0] != sys.executable class TestPipInstallManager: def test_uses_python_param(self, tmp_path): (tmp_path / "manager_requirements.txt").write_text("comfyui-manager\n") with ( patch("comfy_cli.command.install.subprocess.run", return_value=MagicMock(returncode=0)) as mock_run, patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli") as mock_find, ): mock_find.cache_clear = MagicMock() install.pip_install_manager(str(tmp_path), python="/resolved/python") cmd = mock_run.call_args[0][0] assert cmd[0] == "/resolved/python" class TestExecute: def test_calls_ensure_and_passes_resolved_python(self, tmp_path): repo_dir = str(tmp_path) with ( patch("comfy_cli.command.install.ensure_workspace_python", return_value="/resolved/python") as mock_ensure, patch("comfy_cli.command.install.clone_comfyui"), patch("comfy_cli.command.install.check_comfy_repo", return_value=(True, None)), patch("comfy_cli.command.install.pip_install_comfyui_dependencies") as mock_pip_deps, patch("comfy_cli.command.install.WorkspaceManager"), patch("comfy_cli.config_manager.ConfigManager"), patch.object(install.workspace_manager, "skip_prompting", True), patch.object(install.workspace_manager, "setup_workspace_manager"), ): install.execute( url="https://github.com/test/test.git", comfy_path=repo_dir, restore=False, skip_manager=True, version="nightly", ) mock_ensure.assert_called_once_with(repo_dir) mock_pip_deps.assert_called_once() assert mock_pip_deps.call_args[1]["python"] == "/resolved/python" def test_fast_deps_passes_python_to_dependency_compiler(self, tmp_path): repo_dir = str(tmp_path) with ( patch("comfy_cli.command.install.ensure_workspace_python", return_value="/resolved/python"), patch("comfy_cli.command.install.clone_comfyui"), patch("comfy_cli.command.install.check_comfy_repo", return_value=(True, None)), patch("comfy_cli.command.install.DependencyCompiler") as MockCompiler, patch("comfy_cli.command.install.WorkspaceManager"), patch("comfy_cli.config_manager.ConfigManager"), patch.object(install.workspace_manager, "skip_prompting", True), patch.object(install.workspace_manager, "setup_workspace_manager"), ): MockCompiler.Install_Build_Deps = MagicMock() mock_instance = MagicMock() MockCompiler.return_value = mock_instance install.execute( url="https://github.com/test/test.git", comfy_path=repo_dir, restore=False, skip_manager=True, version="nightly", fast_deps=True, ) MockCompiler.Install_Build_Deps.assert_called_once_with(executable="/resolved/python") MockCompiler.assert_called_once() assert MockCompiler.call_args[1]["executable"] == "/resolved/python" assert MockCompiler.call_args[1].get("skip_torch") in (None, False) def test_fast_deps_forwards_skip_torch(self, tmp_path): repo_dir = str(tmp_path) with ( patch("comfy_cli.command.install.ensure_workspace_python", return_value="/resolved/python"), patch("comfy_cli.command.install.clone_comfyui"), patch("comfy_cli.command.install.check_comfy_repo", return_value=(True, None)), patch("comfy_cli.command.install.DependencyCompiler") as MockCompiler, patch("comfy_cli.command.install.WorkspaceManager"), patch("comfy_cli.config_manager.ConfigManager"), patch.object(install.workspace_manager, "skip_prompting", True), patch.object(install.workspace_manager, "setup_workspace_manager"), ): MockCompiler.Install_Build_Deps = MagicMock() mock_instance = MagicMock() MockCompiler.return_value = mock_instance install.execute( url="https://github.com/test/test.git", manager_url="https://github.com/test/manager.git", comfy_path=repo_dir, restore=False, skip_manager=True, version="nightly", fast_deps=True, skip_torch_or_directml=True, ) assert MockCompiler.call_args[1]["skip_torch"] is True def test_fast_deps_cuda_tag_converted_to_dotted_version(self, tmp_path): repo_dir = str(tmp_path) with ( patch("comfy_cli.command.install.ensure_workspace_python", return_value="/resolved/python"), patch("comfy_cli.command.install.clone_comfyui"), patch("comfy_cli.command.install.check_comfy_repo", return_value=(True, None)), patch("comfy_cli.command.install.DependencyCompiler") as MockCompiler, patch("comfy_cli.command.install.WorkspaceManager"), patch("comfy_cli.config_manager.ConfigManager"), patch.object(install.workspace_manager, "skip_prompting", True), patch.object(install.workspace_manager, "setup_workspace_manager"), ): MockCompiler.Install_Build_Deps = MagicMock() MockCompiler.return_value = MagicMock() install.execute( url="https://github.com/test/test.git", comfy_path=repo_dir, restore=False, skip_manager=True, version="nightly", fast_deps=True, gpu=GPU_OPTION.NVIDIA, cuda_tag="cu130", ) assert MockCompiler.call_args[1]["cuda_version"] == "13.0" def test_fast_deps_explicit_cuda_version_no_tag(self, tmp_path): repo_dir = str(tmp_path) with ( patch("comfy_cli.command.install.ensure_workspace_python", return_value="/resolved/python"), patch("comfy_cli.command.install.clone_comfyui"), patch("comfy_cli.command.install.check_comfy_repo", return_value=(True, None)), patch("comfy_cli.command.install.DependencyCompiler") as MockCompiler, patch("comfy_cli.command.install.WorkspaceManager"), patch("comfy_cli.config_manager.ConfigManager"), patch.object(install.workspace_manager, "skip_prompting", True), patch.object(install.workspace_manager, "setup_workspace_manager"), ): MockCompiler.Install_Build_Deps = MagicMock() MockCompiler.return_value = MagicMock() install.execute( url="https://github.com/test/test.git", comfy_path=repo_dir, restore=False, skip_manager=True, version="nightly", fast_deps=True, gpu=GPU_OPTION.NVIDIA, cuda_version=constants.CUDAVersion.v12_6, ) assert MockCompiler.call_args[1]["cuda_version"] == "12.6" class TestAutoDetectIntegration: def test_auto_detected_cuda_tag_used(self, tmp_path): repo_dir = str(tmp_path) (tmp_path / "requirements.txt").write_text("some-package\n") with patch("comfy_cli.command.install.subprocess.run", return_value=MagicMock(returncode=0)) as mock_run: install.pip_install_comfyui_dependencies( repo_dir, gpu=GPU_OPTION.NVIDIA, plat=constants.OS.LINUX, cuda_version=None, skip_torch_or_directml=False, skip_requirement=False, python="/usr/bin/python", cuda_tag="cu130", ) cmd = _get_torch_install_cmd(mock_run.call_args_list) assert "https://download.pytorch.org/whl/cu130" in cmd def test_auto_detect_failure_falls_back(self, tmp_path): repo_dir = str(tmp_path) (tmp_path / "requirements.txt").write_text("some-package\n") with patch("comfy_cli.command.install.subprocess.run", return_value=MagicMock(returncode=0)) as mock_run: install.pip_install_comfyui_dependencies( repo_dir, gpu=GPU_OPTION.NVIDIA, plat=constants.OS.LINUX, cuda_version=None, skip_torch_or_directml=False, skip_requirement=False, python="/usr/bin/python", cuda_tag=None, ) cmd = _get_torch_install_cmd(mock_run.call_args_list) assert "https://download.pytorch.org/whl/cu126" in cmd def test_explicit_cuda_version_used_when_no_tag(self, tmp_path): repo_dir = str(tmp_path) (tmp_path / "requirements.txt").write_text("some-package\n") with patch("comfy_cli.command.install.subprocess.run", return_value=MagicMock(returncode=0)) as mock_run: install.pip_install_comfyui_dependencies( repo_dir, gpu=GPU_OPTION.NVIDIA, plat=constants.OS.LINUX, cuda_version=constants.CUDAVersion.v11_8, skip_torch_or_directml=False, skip_requirement=False, python="/usr/bin/python", cuda_tag=None, ) cmd = _get_torch_install_cmd(mock_run.call_args_list) assert "https://download.pytorch.org/whl/cu118" in cmd def test_cuda_tag_takes_precedence_over_enum(self, tmp_path): repo_dir = str(tmp_path) (tmp_path / "requirements.txt").write_text("some-package\n") with patch("comfy_cli.command.install.subprocess.run", return_value=MagicMock(returncode=0)) as mock_run: install.pip_install_comfyui_dependencies( repo_dir, gpu=GPU_OPTION.NVIDIA, plat=constants.OS.LINUX, cuda_version=constants.CUDAVersion.v11_8, skip_torch_or_directml=False, skip_requirement=False, python="/usr/bin/python", cuda_tag="cu130", ) cmd = _get_torch_install_cmd(mock_run.call_args_list) assert "https://download.pytorch.org/whl/cu130" in cmd def _get_torch_install_cmd(calls): """Find the subprocess.run call that installs torch packages.""" for c in calls: cmd = c[0][0] if "torch" in cmd and "requirements.txt" not in cmd: return cmd return None class TestTorchInstallCommands: @pytest.mark.parametrize( "rocm_version,expected_url", [ (constants.ROCmVersion.v7_1, "https://download.pytorch.org/whl/rocm7.1"), (constants.ROCmVersion.v7_0, "https://download.pytorch.org/whl/rocm7.0"), (constants.ROCmVersion.v6_3, "https://download.pytorch.org/whl/rocm6.3"), (constants.ROCmVersion.v6_2, "https://download.pytorch.org/whl/rocm6.2"), (constants.ROCmVersion.v6_1, "https://download.pytorch.org/whl/rocm6.1"), ], ) def test_amd_uses_index_url_with_rocm_version(self, tmp_path, rocm_version, expected_url): repo_dir = str(tmp_path) (tmp_path / "requirements.txt").write_text("some-package\n") with patch("comfy_cli.command.install.subprocess.run", return_value=MagicMock(returncode=0)) as mock_run: install.pip_install_comfyui_dependencies( repo_dir, gpu=GPU_OPTION.AMD, plat=constants.OS.LINUX, cuda_version=constants.CUDAVersion.v12_6, skip_torch_or_directml=False, skip_requirement=False, python="/usr/bin/python", rocm_version=rocm_version, ) cmd = _get_torch_install_cmd(mock_run.call_args_list) assert "--index-url" in cmd assert "--extra-index-url" not in cmd assert expected_url in cmd @pytest.mark.parametrize( "cuda_version,expected_url", [ (constants.CUDAVersion.v12_9, "https://download.pytorch.org/whl/cu129"), (constants.CUDAVersion.v12_6, "https://download.pytorch.org/whl/cu126"), (constants.CUDAVersion.v12_4, "https://download.pytorch.org/whl/cu124"), (constants.CUDAVersion.v12_1, "https://download.pytorch.org/whl/cu121"), (constants.CUDAVersion.v11_8, "https://download.pytorch.org/whl/cu118"), ], ) def test_nvidia_uses_index_url_with_cuda_version(self, tmp_path, cuda_version, expected_url): repo_dir = str(tmp_path) (tmp_path / "requirements.txt").write_text("some-package\n") with patch("comfy_cli.command.install.subprocess.run", return_value=MagicMock(returncode=0)) as mock_run: install.pip_install_comfyui_dependencies( repo_dir, gpu=GPU_OPTION.NVIDIA, plat=constants.OS.WINDOWS, cuda_version=cuda_version, skip_torch_or_directml=False, skip_requirement=False, python="/usr/bin/python", ) cmd = _get_torch_install_cmd(mock_run.call_args_list) assert "--index-url" in cmd assert "--extra-index-url" not in cmd assert expected_url in cmd def test_nvidia_linux_uses_index_url(self, tmp_path): repo_dir = str(tmp_path) (tmp_path / "requirements.txt").write_text("some-package\n") with patch("comfy_cli.command.install.subprocess.run", return_value=MagicMock(returncode=0)) as mock_run: install.pip_install_comfyui_dependencies( repo_dir, gpu=GPU_OPTION.NVIDIA, plat=constants.OS.LINUX, cuda_version=constants.CUDAVersion.v12_6, skip_torch_or_directml=False, skip_requirement=False, python="/usr/bin/python", ) cmd = _get_torch_install_cmd(mock_run.call_args_list) assert "--index-url" in cmd assert "https://download.pytorch.org/whl/cu126" in cmd ================================================ FILE: tests/comfy_cli/test_launch_python_resolution.py ================================================ import subprocess import sys from unittest.mock import MagicMock, patch import pytest from comfy_cli.command import launch class TestLaunchComfyui: def test_uses_python_param(self): mock_result = subprocess.CompletedProcess(args=[], returncode=0) with ( patch("comfy_cli.command.launch.ConfigManager"), patch("comfy_cli.command.launch.subprocess.run", return_value=mock_result) as mock_run, ): with pytest.raises(SystemExit): launch.launch_comfyui(extra=[], python="/resolved/python") mock_run.assert_called() cmd = mock_run.call_args[0][0] assert cmd[0] == "/resolved/python" assert cmd[0] != sys.executable @pytest.mark.parametrize("returncode", [0, 1, 42]) def test_foreground_exit_code_matches_subprocess(self, returncode): """exit() should receive the subprocess returncode, not the CompletedProcess object.""" mock_result = subprocess.CompletedProcess(args=[], returncode=returncode) with ( patch("comfy_cli.command.launch.ConfigManager"), patch("comfy_cli.command.launch.subprocess.run", return_value=mock_result), ): with pytest.raises(SystemExit) as exc_info: launch.launch_comfyui(extra=[], python="/resolved/python") assert exc_info.value.code == returncode class TestLaunchResolvesWorkspacePython: def test_resolves_and_passes_python(self): with ( patch("comfy_cli.command.launch.resolve_workspace_python", return_value="/resolved/python") as mock_resolve, patch.object(launch.workspace_manager, "workspace_path", "/fake/workspace"), patch.object(launch.workspace_manager, "workspace_type", launch.WorkspaceType.DEFAULT), patch.object(launch.workspace_manager, "config_manager", MagicMock()), patch.object(launch.workspace_manager, "set_recent_workspace"), patch("comfy_cli.command.launch.check_for_updates"), patch("comfy_cli.command.launch.os.chdir"), patch("comfy_cli.command.launch.launch_comfyui") as mock_launch_comfyui, ): launch.launch(background=False) mock_resolve.assert_called_once_with("/fake/workspace") mock_launch_comfyui.assert_called_once() assert mock_launch_comfyui.call_args[1]["python"] == "/resolved/python" ================================================ FILE: tests/comfy_cli/test_models_python_resolution.py ================================================ import builtins from unittest.mock import patch import typer.testing from comfy_cli.command.models.models import app runner = typer.testing.CliRunner() _real_import = builtins.__import__ def _block_huggingface_hub(name, *args, **kwargs): if name == "huggingface_hub": raise ImportError("blocked by test") return _real_import(name, *args, **kwargs) class TestDownloadHuggingfacePipInstall: def test_uses_resolved_python(self, tmp_path): workspace = tmp_path / "workspace" workspace.mkdir() url = "https://huggingface.co/CompVis/stable-diffusion-v1-4/resolve/main/sd-v1-4.ckpt" with ( patch("comfy_cli.command.models.models.get_workspace", return_value=workspace), patch("comfy_cli.command.models.models.check_unauthorized", return_value=True), patch( "comfy_cli.command.models.models.config_manager.get_or_override", side_effect=lambda env_key, config_key, set_value=None: "fake-hf-token" if "HF" in env_key else None, ), patch("builtins.__import__", side_effect=_block_huggingface_hub), patch("comfy_cli.resolve_python.resolve_workspace_python", return_value="/resolved/python"), patch("subprocess.check_call") as mock_check_call, ): result = runner.invoke( app, [ "download", "--url", url, "--relative-path", "models", "--filename", "sd-v1-4.ckpt", ], ) assert mock_check_call.called, f"check_call not called; output: {result.output}" cmd = mock_check_call.call_args[0][0] assert cmd[0] == "/resolved/python" assert cmd == ["/resolved/python", "-m", "pip", "install", "huggingface_hub"] ================================================ FILE: tests/comfy_cli/test_resolve_python.py ================================================ import os import subprocess import sys from unittest.mock import patch import pytest from comfy_cli.resolve_python import ( _get_python_binary, _is_externally_managed, create_workspace_venv, ensure_workspace_python, resolve_workspace_python, ) def _clean_env(**overrides): """Return a patch.dict that clears VIRTUAL_ENV and CONDA_PREFIX, then applies overrides.""" removals = {k: overrides.pop(k, None) for k in ("VIRTUAL_ENV", "CONDA_PREFIX")} env = {k: v for k, v in overrides.items()} env.update({k: v for k, v in removals.items() if v is not None}) keys_to_remove = [k for k, v in removals.items() if v is None and k in os.environ] class _Ctx: def __enter__(self_ctx): self_ctx._old = {k: os.environ.get(k) for k in keys_to_remove} for k in keys_to_remove: os.environ.pop(k, None) self_ctx._patcher = patch.dict(os.environ, env, clear=False) self_ctx._patcher.__enter__() return self_ctx def __exit__(self_ctx, *args): self_ctx._patcher.__exit__(*args) for k, v in self_ctx._old.items(): if v is not None: os.environ[k] = v return _Ctx() def _make_fake_python(base_dir, name="bin/python"): """Create a fake python binary (empty file) and return its path.""" p = base_dir / name p.parent.mkdir(parents=True, exist_ok=True) p.touch() return p def _make_real_venv(base_dir): """Create a real venv and return the python path inside it.""" subprocess.run([sys.executable, "-m", "venv", str(base_dir)], check=True) python = os.path.join(str(base_dir), "bin", "python") assert os.path.isfile(python) return python class TestIsExternallyManaged: def test_true_when_marker_exists(self, tmp_path): marker = tmp_path / "EXTERNALLY-MANAGED" marker.touch() with patch("comfy_cli.resolve_python.sysconfig.get_path", return_value=str(tmp_path)): assert _is_externally_managed() is True def test_false_when_no_marker(self, tmp_path): with patch("comfy_cli.resolve_python.sysconfig.get_path", return_value=str(tmp_path)): assert _is_externally_managed() is False def test_false_when_stdlib_is_none(self): with patch("comfy_cli.resolve_python.sysconfig.get_path", return_value=None): assert _is_externally_managed() is False class TestGetPythonBinary: @patch("comfy_cli.resolve_python.platform.system", return_value="Linux") def test_unix(self, _mock): assert _get_python_binary("/some/env") == "/some/env/bin/python" @patch("comfy_cli.resolve_python.platform.system", return_value="Darwin") def test_macos(self, _mock): assert _get_python_binary("/some/env") == "/some/env/bin/python" @patch("comfy_cli.resolve_python.platform.system", return_value="Windows") def test_windows(self, _mock): result = _get_python_binary("/some/env") assert result.endswith(os.path.join("Scripts", "python.exe")) class TestResolveWorkspacePython: def test_virtual_env_takes_precedence_over_workspace_venv(self, tmp_path): venv_dir = tmp_path / "user_venv" python = _make_fake_python(venv_dir) workspace = tmp_path / "workspace" _make_fake_python(workspace / ".venv") with _clean_env(VIRTUAL_ENV=str(venv_dir)): result = resolve_workspace_python(str(workspace)) assert result == str(python) def test_virtual_env_takes_precedence_over_conda(self, tmp_path): venv_dir = tmp_path / "user_venv" venv_python = _make_fake_python(venv_dir) conda_dir = tmp_path / "conda_env" _make_fake_python(conda_dir) with _clean_env(VIRTUAL_ENV=str(venv_dir), CONDA_PREFIX=str(conda_dir)): result = resolve_workspace_python(None) assert result == str(venv_python) def test_conda_prefix_when_no_virtual_env(self, tmp_path): conda_dir = tmp_path / "conda" python = _make_fake_python(conda_dir) with _clean_env(CONDA_PREFIX=str(conda_dir)): result = resolve_workspace_python(str(tmp_path / "workspace")) assert result == str(python) def test_conda_prefix_python_missing_falls_through(self, tmp_path): conda_dir = tmp_path / "broken_conda" conda_dir.mkdir() with _clean_env(CONDA_PREFIX=str(conda_dir)): result = resolve_workspace_python(None) assert result == sys.executable def test_virtual_env_missing_falls_to_conda(self, tmp_path): broken_venv = tmp_path / "broken_venv" broken_venv.mkdir() conda_dir = tmp_path / "conda" conda_python = _make_fake_python(conda_dir) with _clean_env(VIRTUAL_ENV=str(broken_venv), CONDA_PREFIX=str(conda_dir)): result = resolve_workspace_python(None) assert result == str(conda_python) def test_workspace_dot_venv_found(self, tmp_path): workspace = tmp_path / "workspace" python = _make_fake_python(workspace / ".venv") with _clean_env(): result = resolve_workspace_python(str(workspace)) assert result == str(python) def test_workspace_venv_found(self, tmp_path): workspace = tmp_path / "workspace" python = _make_fake_python(workspace / "venv") with _clean_env(): result = resolve_workspace_python(str(workspace)) assert result == str(python) def test_dot_venv_preferred_over_venv(self, tmp_path): workspace = tmp_path / "workspace" dot_python = _make_fake_python(workspace / ".venv") _make_fake_python(workspace / "venv") with _clean_env(): result = resolve_workspace_python(str(workspace)) assert result == str(dot_python) def test_workspace_dot_venv_dir_exists_but_python_missing(self, tmp_path): workspace = tmp_path / "workspace" (workspace / ".venv").mkdir(parents=True) with _clean_env(): result = resolve_workspace_python(str(workspace)) assert result == sys.executable def test_workspace_dot_venv_broken_falls_to_venv(self, tmp_path): workspace = tmp_path / "workspace" (workspace / ".venv").mkdir(parents=True) venv_python = _make_fake_python(workspace / "venv") with _clean_env(): result = resolve_workspace_python(str(workspace)) assert result == str(venv_python) def test_workspace_venv_dir_exists_but_python_missing(self, tmp_path): workspace = tmp_path / "workspace" (workspace / "venv").mkdir(parents=True) with _clean_env(): result = resolve_workspace_python(str(workspace)) assert result == sys.executable def test_fallback_to_sys_executable(self, tmp_path): with _clean_env(): result = resolve_workspace_python(str(tmp_path)) assert result == sys.executable def test_none_workspace_path(self): with _clean_env(): result = resolve_workspace_python(None) assert result == sys.executable def test_virtual_env_python_missing_falls_through(self, tmp_path): venv_dir = tmp_path / "broken_venv" venv_dir.mkdir() with _clean_env(VIRTUAL_ENV=str(venv_dir)): result = resolve_workspace_python(str(tmp_path)) assert result == sys.executable def test_with_real_venv(self, tmp_path): workspace = tmp_path / "workspace" workspace.mkdir() expected = _make_real_venv(workspace / ".venv") with _clean_env(): result = resolve_workspace_python(str(workspace)) assert result == expected r = subprocess.run([result, "-c", "print('ok')"], capture_output=True, text=True) assert r.returncode == 0 assert r.stdout.strip() == "ok" class TestEnsureWorkspacePython: def test_with_virtual_env_does_not_create_venv(self, tmp_path): venv_dir = tmp_path / "ext_venv" python = _make_fake_python(venv_dir) workspace = tmp_path / "workspace" workspace.mkdir() with _clean_env(VIRTUAL_ENV=str(venv_dir)): result = ensure_workspace_python(str(workspace)) assert result == str(python) assert not (workspace / ".venv").exists() def test_with_conda_does_not_create_venv(self, tmp_path): conda_dir = tmp_path / "conda" python = _make_fake_python(conda_dir) workspace = tmp_path / "workspace" workspace.mkdir() with _clean_env(CONDA_PREFIX=str(conda_dir)): result = ensure_workspace_python(str(workspace)) assert result == str(python) assert not (workspace / ".venv").exists() def test_with_both_env_vars_uses_virtual_env(self, tmp_path): venv_dir = tmp_path / "venv" venv_python = _make_fake_python(venv_dir) conda_dir = tmp_path / "conda" _make_fake_python(conda_dir) workspace = tmp_path / "workspace" workspace.mkdir() with _clean_env(VIRTUAL_ENV=str(venv_dir), CONDA_PREFIX=str(conda_dir)): result = ensure_workspace_python(str(workspace)) assert result == str(venv_python) assert not (workspace / ".venv").exists() def test_global_python_returns_sys_executable(self, tmp_path): workspace = tmp_path / "workspace" workspace.mkdir() with ( _clean_env(), patch("comfy_cli.resolve_python.sys") as mock_sys, patch("comfy_cli.resolve_python._is_externally_managed", return_value=False), ): mock_sys.executable = "/usr/bin/python3" mock_sys.prefix = "/usr" mock_sys.base_prefix = "/usr" result = ensure_workspace_python(str(workspace)) assert result == "/usr/bin/python3" assert not (workspace / ".venv").exists() def test_global_python_pep668_creates_venv(self, tmp_path): workspace = tmp_path / "workspace" workspace.mkdir() with ( _clean_env(), patch("comfy_cli.resolve_python.sys") as mock_sys, patch("comfy_cli.resolve_python._is_externally_managed", return_value=True), ): mock_sys.executable = sys.executable mock_sys.prefix = "/usr" mock_sys.base_prefix = "/usr" result = ensure_workspace_python(str(workspace)) assert (workspace / ".venv").is_dir() assert os.path.isfile(result) assert ".venv" in result def test_creates_venv_when_isolated_env(self, tmp_path): workspace = tmp_path / "workspace" workspace.mkdir() with _clean_env(): # Simulate isolated env (pipx/uv tool): prefix != base_prefix with patch("comfy_cli.resolve_python.sys") as mock_sys: mock_sys.executable = sys.executable mock_sys.prefix = "/home/user/.local/pipx/venvs/comfy-cli" mock_sys.base_prefix = "/usr" result = ensure_workspace_python(str(workspace)) assert (workspace / ".venv").is_dir() assert os.path.isfile(result) assert ".venv" in result r = subprocess.run([result, "-c", "print('ok')"], capture_output=True, text=True) assert r.returncode == 0 def test_existing_dot_venv_reused(self, tmp_path): workspace = tmp_path / "workspace" python = _make_fake_python(workspace / ".venv") with _clean_env(): result = ensure_workspace_python(str(workspace)) assert result == str(python) def test_existing_venv_reused(self, tmp_path): workspace = tmp_path / "workspace" python = _make_fake_python(workspace / "venv") with _clean_env(): result = ensure_workspace_python(str(workspace)) assert result == str(python) def test_broken_dot_venv_falls_to_venv(self, tmp_path): workspace = tmp_path / "workspace" (workspace / ".venv").mkdir(parents=True) venv_python = _make_fake_python(workspace / "venv") with _clean_env(): result = ensure_workspace_python(str(workspace)) assert result == str(venv_python) def test_broken_dot_venv_global_python_returns_sys_executable(self, tmp_path): workspace = tmp_path / "workspace" (workspace / ".venv").mkdir(parents=True) with ( _clean_env(), patch("comfy_cli.resolve_python.sys") as mock_sys, patch("comfy_cli.resolve_python._is_externally_managed", return_value=False), ): mock_sys.executable = "/usr/bin/python3" mock_sys.prefix = "/usr" mock_sys.base_prefix = "/usr" result = ensure_workspace_python(str(workspace)) assert result == "/usr/bin/python3" def test_broken_dot_venv_isolated_env_creates_new(self, tmp_path): workspace = tmp_path / "workspace" (workspace / ".venv").mkdir(parents=True) with _clean_env(), patch("comfy_cli.resolve_python.sys") as mock_sys: mock_sys.executable = sys.executable mock_sys.prefix = "/home/user/.local/pipx/venvs/comfy-cli" mock_sys.base_prefix = "/usr" result = ensure_workspace_python(str(workspace)) assert os.path.isfile(result) assert ".venv" in result r = subprocess.run([result, "-c", "print('ok')"], capture_output=True, text=True) assert r.returncode == 0 class TestCreateWorkspaceVenv: def test_creates_working_venv(self, tmp_path): workspace = tmp_path / "workspace" workspace.mkdir() result = create_workspace_venv(str(workspace)) assert (workspace / ".venv").is_dir() assert os.path.isfile(result) r = subprocess.run([result, "-c", "import sys; print(sys.prefix)"], capture_output=True, text=True) assert r.returncode == 0 assert str(workspace) in r.stdout.strip() def test_created_venv_has_pip(self, tmp_path): workspace = tmp_path / "workspace" workspace.mkdir() result = create_workspace_venv(str(workspace)) r = subprocess.run([result, "-m", "pip", "--version"], capture_output=True, text=True) assert r.returncode == 0 assert "pip" in r.stdout def test_created_venv_is_isolated(self, tmp_path): workspace = tmp_path / "workspace" workspace.mkdir() result = create_workspace_venv(str(workspace)) assert result != sys.executable r = subprocess.run([result, "-c", "import sys; print(sys.prefix)"], capture_output=True, text=True) assert r.returncode == 0 assert r.stdout.strip() != sys.prefix def test_returns_platform_specific_path(self, tmp_path): workspace = tmp_path / "workspace" workspace.mkdir() result = create_workspace_venv(str(workspace)) if sys.platform == "win32": assert "Scripts" in result else: assert "bin" in result def test_idempotent(self, tmp_path): workspace = tmp_path / "workspace" workspace.mkdir() result1 = create_workspace_venv(str(workspace)) result2 = create_workspace_venv(str(workspace)) assert result1 == result2 assert os.path.isfile(result2) r = subprocess.run([result2, "-c", "print('ok')"], capture_output=True, text=True) assert r.returncode == 0 def test_failure_raises(self, tmp_path): workspace = tmp_path / "workspace" workspace.mkdir() with patch("comfy_cli.resolve_python.subprocess.run", side_effect=subprocess.CalledProcessError(1, "venv")): with pytest.raises(subprocess.CalledProcessError): create_workspace_venv(str(workspace)) ================================================ FILE: tests/comfy_cli/test_standalone.py ================================================ import os import re from unittest.mock import MagicMock, patch import pytest import requests from comfy_cli.standalone import ( _latest_release_json_url, _resolve_python_version, download_standalone_python, ) # Minimal SHA256SUMS content matching real format SAMPLE_SHA256SUMS = """\ aaa cpython-3.10.20+20260310-aarch64-apple-darwin-install_only.tar.gz bbb cpython-3.10.20+20260310-x86_64-pc-windows-msvc-install_only.tar.gz ccc cpython-3.12.13+20260310-aarch64-apple-darwin-install_only.tar.gz ddd cpython-3.12.13+20260310-x86_64-pc-windows-msvc-install_only.tar.gz eee cpython-3.12.13+20260310-x86_64_v3-unknown-linux-gnu-install_only.tar.gz fff cpython-3.13.12+20260310-x86_64-pc-windows-msvc-install_only.tar.gz """ def _mock_response(text, status_code=200): resp = MagicMock() resp.text = text resp.status_code = status_code resp.raise_for_status = MagicMock() if status_code != 200: resp.raise_for_status.side_effect = Exception(f"HTTP {status_code}") return resp class TestResolvePythonVersion: @patch("comfy_cli.standalone.requests.get") def test_resolves_312(self, mock_get): mock_get.return_value = _mock_response(SAMPLE_SHA256SUMS) result = _resolve_python_version("https://example.com/release", "3.12") assert result == "3.12.13" @patch("comfy_cli.standalone.requests.get") def test_resolves_310(self, mock_get): mock_get.return_value = _mock_response(SAMPLE_SHA256SUMS) result = _resolve_python_version("https://example.com/release", "3.10") assert result == "3.10.20" @patch("comfy_cli.standalone.requests.get") def test_resolves_313(self, mock_get): mock_get.return_value = _mock_response(SAMPLE_SHA256SUMS) result = _resolve_python_version("https://example.com/release", "3.13") assert result == "3.13.12" @patch("comfy_cli.standalone.requests.get") def test_missing_version_raises(self, mock_get): mock_get.return_value = _mock_response(SAMPLE_SHA256SUMS) with pytest.raises(RuntimeError, match="No Python 3.14.x found"): _resolve_python_version("https://example.com/release", "3.14") @patch("comfy_cli.standalone.requests.get") def test_http_error_propagates(self, mock_get): mock_get.return_value = _mock_response("", status_code=404) with pytest.raises(Exception, match="HTTP 404"): _resolve_python_version("https://example.com/release", "3.12") @patch("comfy_cli.standalone.requests.get") def test_picks_highest_patch(self, mock_get): """If multiple patch versions exist for a minor series, pick the highest.""" sha256sums = """\ aaa cpython-3.12.10+20260310-x86_64-install_only.tar.gz bbb cpython-3.12.13+20260310-x86_64-install_only.tar.gz ccc cpython-3.12.9+20260310-x86_64-install_only.tar.gz """ mock_get.return_value = _mock_response(sha256sums) result = _resolve_python_version("https://example.com/release", "3.12") assert result == "3.12.13" @patch("comfy_cli.standalone.requests.get") def test_url_construction(self, mock_get): mock_get.return_value = _mock_response(SAMPLE_SHA256SUMS) _resolve_python_version("https://example.com/release/", "3.12") mock_get.assert_called_once_with("https://example.com/release/SHA256SUMS") @patch("comfy_cli.standalone.requests.get") def test_no_false_match_across_minor(self, mock_get): """3.1 should not match 3.12 or 3.10.""" mock_get.return_value = _mock_response(SAMPLE_SHA256SUMS) with pytest.raises(RuntimeError, match="No Python 3.1.x found"): _resolve_python_version("https://example.com/release", "3.1") class TestDownloadStandalonePython: @patch("comfy_cli.standalone.download_url") @patch("comfy_cli.standalone.requests.get") def test_minor_version_triggers_resolution(self, mock_get, mock_download): """When version is a minor version (X.Y), it should resolve the patch.""" mock_get.side_effect = [ _mock_response('{"tag": "20260310", "asset_url_prefix": "https://example.com/release"}'), _mock_response(SAMPLE_SHA256SUMS), ] mock_download.return_value = "python.tar.gz" download_standalone_python(platform="linux", proc="x86_64", version="3.12") # Should have fetched latest-release.json and SHA256SUMS assert mock_get.call_count == 2 # Download URL should contain resolved version call_args = mock_download.call_args assert "3.12.13" in call_args[1].get("url", "") or "3.12.13" in str(call_args) @patch("comfy_cli.standalone.download_url") @patch("comfy_cli.standalone.requests.get") def test_full_version_skips_resolution(self, mock_get, mock_download): """When version is a full version (X.Y.Z), no resolution needed.""" mock_get.return_value = _mock_response('{"tag": "20260310", "asset_url_prefix": "https://example.com/release"}') mock_download.return_value = "python.tar.gz" download_standalone_python(platform="linux", proc="x86_64", version="3.12.13") # Should have fetched only latest-release.json, not SHA256SUMS assert mock_get.call_count == 1 _require_network = pytest.mark.skipif( os.getenv("TEST_NETWORK", "false").lower() != "true", reason="Set TEST_NETWORK=true to run integration tests that hit the network", ) @_require_network class TestResolveVersionIntegration: """Integration tests that hit the real python-build-standalone release endpoints.""" def test_latest_release_json_is_reachable(self): response = requests.get(_latest_release_json_url) assert response.status_code == 200 data = response.json() assert "tag" in data assert "asset_url_prefix" in data # tag should be a date string like "20260310" assert re.fullmatch(r"\d{8}", data["tag"]), f"unexpected tag format: {data['tag']}" def test_resolve_312_from_real_release(self): response = requests.get(_latest_release_json_url) data = response.json() asset_url_prefix = data["asset_url_prefix"] version = _resolve_python_version(asset_url_prefix, "3.12") # Should be a valid 3.12.x version assert re.fullmatch(r"3\.12\.\d+", version), f"unexpected version: {version}" def test_sha256sums_contains_expected_platforms(self): """Verify the platforms we use in _platform_targets actually exist in the release.""" response = requests.get(_latest_release_json_url) data = response.json() asset_url_prefix = data["asset_url_prefix"] sha256sums_url = f"{asset_url_prefix}/SHA256SUMS" sha_response = requests.get(sha256sums_url) assert sha_response.status_code == 200 content = sha_response.text expected_targets = [ "aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64_v3-unknown-linux-gnu", "x86_64-pc-windows-msvc", ] for target in expected_targets: assert target in content, f"platform target '{target}' not found in SHA256SUMS" ================================================ FILE: tests/comfy_cli/test_tracking.py ================================================ from unittest.mock import MagicMock, patch import pytest from comfy_cli import constants from comfy_cli.config_manager import ConfigManager # Unwrap the singleton to get fresh ConfigManager instances per test. _ConfigManagerCls = ConfigManager.__closure__[0].cell_contents @pytest.fixture def tracking_module(tmp_path): """Yield comfy_cli.tracking with a fresh tmp-path ConfigManager and a mocked Mixpanel client.""" config_dir = tmp_path / "comfy-cli" config_dir.mkdir() with patch.object(_ConfigManagerCls, "get_config_path", return_value=str(config_dir)): cfg = _ConfigManagerCls() import comfy_cli.tracking as tracking_mod with ( patch.object(tracking_mod, "config_manager", cfg), patch.object(tracking_mod, "user_id", None), patch.object(tracking_mod, "cli_version", "test-cli-version"), patch.object(tracking_mod, "tracing_id", "test-tracing-id"), patch.object(tracking_mod, "mp", MagicMock()), ): yield tracking_mod class TestTrackEvent: def test_short_circuits_when_disabled(self, tracking_module): tracking_module.config_manager.set(constants.CONFIG_KEY_ENABLE_TRACKING, "False") tracking_module.track_event("some_event") tracking_module.mp.track.assert_not_called() def test_short_circuits_when_not_configured(self, tracking_module): tracking_module.track_event("some_event") tracking_module.mp.track.assert_not_called() def test_fires_when_enabled(self, tracking_module): tracking_module.config_manager.set(constants.CONFIG_KEY_ENABLE_TRACKING, "True") tracking_module.track_event("some_event", {"k": "v"}) tracking_module.mp.track.assert_called_once() _, kwargs = tracking_module.mp.track.call_args assert kwargs["event_name"] == "some_event" assert kwargs["properties"]["k"] == "v" assert "cli_version" in kwargs["properties"] assert "tracing_id" in kwargs["properties"] def test_properties_default_to_empty_dict(self, tracking_module): tracking_module.config_manager.set(constants.CONFIG_KEY_ENABLE_TRACKING, "True") tracking_module.track_event("some_event") tracking_module.mp.track.assert_called_once() _, kwargs = tracking_module.mp.track.call_args assert set(kwargs["properties"].keys()) == {"cli_version", "tracing_id"} def test_swallows_mixpanel_errors(self, tracking_module): tracking_module.config_manager.set(constants.CONFIG_KEY_ENABLE_TRACKING, "True") tracking_module.mp.track.side_effect = RuntimeError("boom") tracking_module.track_event("some_event") tracking_module.mp.track.assert_called_once() class TestTrackCommandRedaction: """track_command must redact secret-bearing kwargs before they reach the tracking system.""" def test_api_key_value_is_redacted(self, tracking_module): tracking_module.config_manager.set(constants.CONFIG_KEY_ENABLE_TRACKING, "True") @tracking_module.track_command() def some_cmd(workflow, api_key=None): return None some_cmd(workflow="wf.json", api_key="sk-supersecret") tracking_module.mp.track.assert_called_once() _, kwargs = tracking_module.mp.track.call_args props = kwargs["properties"] assert props["api_key"] == "" assert props["workflow"] == "wf.json" assert "sk-supersecret" not in str(props) def test_api_key_none_stays_none(self, tracking_module): # When the user didn't pass --api-key (or set $COMFY_API_KEY), we still # want to be able to see in the analytics that it was absent — not a # "" sentinel that would imply they did pass one. tracking_module.config_manager.set(constants.CONFIG_KEY_ENABLE_TRACKING, "True") @tracking_module.track_command() def some_cmd(workflow, api_key=None): return None some_cmd(workflow="wf.json", api_key=None) _, kwargs = tracking_module.mp.track.call_args assert kwargs["properties"]["api_key"] is None class TestInitTrackingRoundTrip: """End-to-end: init_tracking() writes the string "False"/"True", and track_event honors it. Regression for a prior bug where track_event used config_manager.get(), which returned the raw string "False" (a truthy value), so disabling via this code path had no effect. """ def test_disable_is_respected_by_track_event(self, tracking_module): tracking_module.init_tracking(False) tracking_module.track_event("some_event") tracking_module.mp.track.assert_not_called() def test_enable_is_respected_by_track_event(self, tracking_module): tracking_module.init_tracking(True) tracking_module.mp.track.reset_mock() tracking_module.track_event("some_event") tracking_module.mp.track.assert_called_once() def test_disable_persists_as_parseable_bool(self, tracking_module): tracking_module.init_tracking(False) assert tracking_module.config_manager.get_bool(constants.CONFIG_KEY_ENABLE_TRACKING) is False def test_enable_generates_user_id(self, tracking_module): assert tracking_module.config_manager.get(constants.CONFIG_KEY_USER_ID) is None tracking_module.init_tracking(True) generated_user_id = tracking_module.config_manager.get(constants.CONFIG_KEY_USER_ID) assert generated_user_id is not None assert tracking_module.user_id == generated_user_id _, kwargs = tracking_module.mp.track.call_args assert kwargs["distinct_id"] == generated_user_id def test_disable_does_not_generate_user_id(self, tracking_module): tracking_module.init_tracking(False) assert tracking_module.config_manager.get(constants.CONFIG_KEY_USER_ID) is None def test_install_event_fires_once_across_calls(self, tracking_module): tracking_module.init_tracking(True) assert tracking_module.mp.track.call_count == 1 tracking_module.init_tracking(True) assert tracking_module.mp.track.call_count == 1 ================================================ FILE: tests/comfy_cli/test_ui.py ================================================ import io from rich.console import Console import comfy_cli.ui as ui_module from comfy_cli.ui import display_error_message def _capture(fn, *args, **kwargs): """Run fn against a Console that writes to an in-memory buffer, return the output string.""" buf = io.StringIO() original = ui_module.console ui_module.console = Console(file=buf, force_terminal=False, markup=True) try: fn(*args, **kwargs) finally: ui_module.console = original return buf.getvalue() class TestDisplayErrorMessageMarkup: """display_error_message must accept any string content without raising rich.errors.MarkupError or silently stripping bracketed substrings. Error messages can contain server-controlled text (e.g. JSON body echoed into a DownloadException) which may include arbitrary [ and ] chars.""" def test_plain_message_rendered(self): out = _capture(display_error_message, "plain error") assert "plain error" in out def test_closing_tag_alone_does_not_crash(self): # Prior to the fix this raised rich.errors.MarkupError on console.print. out = _capture(display_error_message, "error with [/] in the middle") assert "[/]" in out def test_bracketed_substring_preserved(self): # Prior to the fix "[id]" was consumed as an unknown style and stripped. out = _capture(display_error_message, "URL /path/[id]/resource not found") assert "[id]" in out assert "/path/[id]/resource" in out def test_multiple_markup_like_tokens(self): out = _capture(display_error_message, "server said [redacted] at [host]:[port]") assert "[redacted]" in out assert "[host]" in out assert "[port]" in out def test_unbalanced_opening_bracket(self): out = _capture(display_error_message, "unbalanced [tag without close") assert "[tag without close" in out ================================================ FILE: tests/comfy_cli/test_update.py ================================================ from unittest.mock import MagicMock, patch import requests from comfy_cli.update import check_for_newer_pypi_version, check_for_updates def _mock_pypi_response(latest_version): mock_resp = MagicMock() mock_resp.status_code = 200 mock_resp.json.return_value = {"info": {"version": latest_version}} return mock_resp class TestCheckForNewerPypiVersion: @patch("comfy_cli.update.requests.get") def test_newer_version_available(self, mock_get): mock_get.return_value = _mock_pypi_response("99.0.0") has_newer, ver = check_for_newer_pypi_version("comfy-cli", "1.0.0") assert has_newer is True assert ver == "99.0.0" @patch("comfy_cli.update.requests.get") def test_no_update_when_current(self, mock_get): mock_get.return_value = _mock_pypi_response("1.0.0") has_newer, ver = check_for_newer_pypi_version("comfy-cli", "1.0.0") assert has_newer is False assert ver == "1.0.0" @patch("comfy_cli.update.requests.get") def test_network_failure_returns_false(self, mock_get): mock_get.side_effect = requests.Timeout("connection timed out") has_newer, ver = check_for_newer_pypi_version("comfy-cli", "1.0.0") assert has_newer is False assert ver == "1.0.0" @patch("comfy_cli.update.requests.get") def test_timeout_value_is_passed(self, mock_get): mock_get.return_value = _mock_pypi_response("1.0.0") check_for_newer_pypi_version("comfy-cli", "1.0.0") mock_get.assert_called_once_with("https://pypi.org/pypi/comfy-cli/json", timeout=5) class TestCheckForUpdates: @patch("comfy_cli.update.notify_update") @patch("comfy_cli.update.get_version_from_pyproject", return_value="1.0.0") @patch("comfy_cli.update.requests.get") def test_notifies_when_update_available(self, mock_get, _mock_ver, mock_notify): mock_get.return_value = _mock_pypi_response("2.0.0") check_for_updates() mock_notify.assert_called_once_with("1.0.0", "2.0.0") @patch("comfy_cli.update.notify_update") @patch("comfy_cli.update.get_version_from_pyproject", return_value="1.0.0") @patch("comfy_cli.update.requests.get") def test_no_notification_on_network_error(self, mock_get, _mock_ver, mock_notify): mock_get.side_effect = requests.ConnectionError("offline") check_for_updates() mock_notify.assert_not_called() ================================================ FILE: tests/comfy_cli/test_utils.py ================================================ import io from unittest.mock import MagicMock, patch from comfy_cli.utils import create_tarball, download_url, extract_tarball class _FakeRaw(io.BytesIO): """BytesIO that accepts decode_content kwarg like urllib3 responses. The production code does ``response.raw.read = functools.partial( response.raw.read, decode_content=True)`` which monkey-patches the read method. A plain BytesIO would blow up because its read() does not accept that kwarg. """ def read(self, amt=-1, decode_content=False): return super().read(amt) class TestDownloadUrl: @patch("comfy_cli.utils.requests.get") def test_writes_file(self, mock_get, tmp_path): content = b"file contents here" mock_response = MagicMock() mock_response.status_code = 200 mock_response.headers = {"Content-Length": str(len(content))} mock_response.raw = _FakeRaw(content) mock_get.return_value = mock_response result = download_url("http://example.com/f.bin", "f.bin", cwd=tmp_path, show_progress=False) assert result == tmp_path / "f.bin" assert (tmp_path / "f.bin").read_bytes() == content class TestTarballRoundTrip: def test_create_and_extract(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) src = tmp_path / "mydir" src.mkdir() (src / "hello.txt").write_text("hello world") (src / "sub").mkdir() (src / "sub" / "nested.txt").write_text("nested content") tarball = tmp_path / "mydir.tgz" with patch("comfy_cli.utils.Live"): create_tarball(src, tarball, cwd=tmp_path) assert tarball.exists() dest = tmp_path / "extracted" with patch("comfy_cli.utils.Live"): extract_tarball(tarball, dest) assert (dest / "hello.txt").read_text() == "hello world" assert (dest / "sub" / "nested.txt").read_text() == "nested content" ================================================ FILE: tests/comfy_cli/test_workflow_to_api.py ================================================ """Unit tests for the UI -> API workflow converter.""" import json import random from pathlib import Path from unittest.mock import patch import pytest from comfy_cli.workflow_to_api import ( WorkflowConversionError, convert_ui_to_api, is_api_format, is_subgraph_uuid, process_dynamic_prompt, ) FIXTURES = Path(__file__).parent / "fixtures" # --------------------------------------------------------------------------- # Reusable fixtures: a tiny `/object_info` covering the schemas the tests use. # --------------------------------------------------------------------------- @pytest.fixture def object_info(): return { "EmptyLatentImage": { "input": { "required": { "width": ["INT", {"default": 512}], "height": ["INT", {"default": 512}], "batch_size": ["INT", {"default": 1}], } }, "input_order": {"required": ["width", "height", "batch_size"]}, "output_node": False, "output": ["LATENT"], "display_name": "Empty Latent Image", }, "KSampler": { "input": { "required": { "model": ["MODEL"], "seed": ["INT", {"default": 0, "control_after_generate": True}], "steps": ["INT", {"default": 20}], "cfg": ["FLOAT", {"default": 8.0}], "sampler_name": [["euler", "ddim"], {"default": "euler"}], "scheduler": [["normal", "karras"], {"default": "normal"}], "positive": ["CONDITIONING"], "negative": ["CONDITIONING"], "latent_image": ["LATENT"], "denoise": ["FLOAT", {"default": 1.0}], } }, "input_order": { "required": [ "model", "seed", "steps", "cfg", "sampler_name", "scheduler", "positive", "negative", "latent_image", "denoise", ] }, "output_node": False, "output": ["LATENT"], "display_name": "KSampler", }, "PreviewImage": { "input": {"required": {"images": ["IMAGE"]}}, "input_order": {"required": ["images"]}, "output_node": True, "output": [], "display_name": "Preview Image", }, "CLIPTextEncode": { "input": { "required": { "text": ["STRING", {"multiline": True}], "clip": ["CLIP"], } }, "input_order": {"required": ["text", "clip"]}, "output_node": False, "output": ["CONDITIONING"], "display_name": "CLIP Text Encode", }, "VAEDecode": { "input": {"required": {"samples": ["LATENT"], "vae": ["VAE"]}}, "input_order": {"required": ["samples", "vae"]}, "output_node": False, "output": ["IMAGE"], "display_name": "VAE Decode", }, } def _node(node_id, node_type, *, inputs=None, outputs=None, widgets=None, mode=0, **extra): """Helper to build a minimal UI node entry.""" n = { "id": node_id, "type": node_type, "inputs": inputs or [], "outputs": outputs or [], "mode": mode, } if widgets is not None: n["widgets_values"] = widgets n.update(extra) return n # --------------------------------------------------------------------------- # Format detection # --------------------------------------------------------------------------- class TestIsApiFormat: def test_recognizes_api(self): assert is_api_format({"1": {"class_type": "Foo", "inputs": {}}}) def test_ui_is_not_api(self): assert not is_api_format({"nodes": [], "links": []}) def test_non_dict_is_not_api(self): assert not is_api_format([]) assert not is_api_format("string") assert not is_api_format(None) def test_empty_dict_is_not_api(self): assert not is_api_format({}) def test_metadata_only_is_not_api(self): # Keys exist but none has a class_type assert not is_api_format({"prompt": "x", "client_id": "y"}) class TestIsSubgraphUuid: def test_real_uuid(self): assert is_subgraph_uuid("b43bb7e6-178c-4f1a-b014-ac4d6a50fca2") def test_class_name_is_not_uuid(self): assert not is_subgraph_uuid("ImageScaleToTotalPixels") def test_wrong_length(self): assert not is_subgraph_uuid("b43bb7e6-178c-4f1a-b014-ac4d6a50fc") def test_wrong_dash_count(self): assert not is_subgraph_uuid("b43bb7e6_178c_4f1a_b014_ac4d6a50fca2x") def test_non_string(self): assert not is_subgraph_uuid(123) assert not is_subgraph_uuid(None) # --------------------------------------------------------------------------- # Core conversion: end-to-end shape # --------------------------------------------------------------------------- class TestConvertCore: def test_already_api_is_returned_unchanged(self, object_info): api = {"1": {"class_type": "EmptyLatentImage", "inputs": {}, "_meta": {"title": "x"}}} assert convert_ui_to_api(api, object_info) == api def test_minimal_workflow(self, object_info): # EmptyLatentImage(1) -> PreviewImage(2): mark via the VAEDecode chain # is overkill — just connect a single link. workflow = { "nodes": [ _node( 1, "EmptyLatentImage", outputs=[{"name": "LATENT", "type": "LATENT", "links": [100]}], widgets=[512, 512, 1], ), _node( 2, "PreviewImage", inputs=[{"name": "images", "link": 100}], outputs=[], ), ], "links": [[100, 1, 0, 2, 0, "IMAGE"]], } result = convert_ui_to_api(workflow, object_info) assert set(result) == {"1", "2"} assert result["1"]["class_type"] == "EmptyLatentImage" assert result["1"]["inputs"] == {"width": 512, "height": 512, "batch_size": 1} assert result["2"]["class_type"] == "PreviewImage" assert result["2"]["inputs"] == {"images": ["1", 0]} def test_input_order_follows_schema(self, object_info): # KSampler should emit widget values first in schema order, then link inputs. # Producer nodes use EmptyLatentImage stand-ins for all three connection # inputs; the converter doesn't typecheck, so this is enough to keep the # links from being treated as orphans. workflow = { "nodes": [ _node( 1, "KSampler", inputs=[ {"name": "model", "link": 10}, {"name": "positive", "link": 11}, {"name": "negative", "link": 12}, {"name": "latent_image", "link": 13}, ], outputs=[{"name": "LATENT", "type": "LATENT", "links": [20]}], widgets=[42, "randomize", 20, 8.0, "euler", "normal", 1.0], ), _node(2, "EmptyLatentImage", outputs=[{"links": [13]}], widgets=[512, 512, 1]), _node(91, "EmptyLatentImage", outputs=[{"links": [10]}], widgets=[64, 64, 1]), _node(92, "EmptyLatentImage", outputs=[{"links": [11]}], widgets=[64, 64, 1]), _node(93, "EmptyLatentImage", outputs=[{"links": [12]}], widgets=[64, 64, 1]), _node(3, "PreviewImage", inputs=[{"name": "images", "link": 20}], outputs=[]), ], "links": [ [10, 91, 0, 1, 0, "MODEL"], [11, 92, 0, 1, 6, "CONDITIONING"], [12, 93, 0, 1, 7, "CONDITIONING"], [13, 2, 0, 1, 8, "LATENT"], [20, 1, 0, 3, 0, "LATENT"], ], } result = convert_ui_to_api(workflow, object_info) inputs = result["1"]["inputs"] # All widget values come before all link inputs, both in schema order. keys = list(inputs) widget_keys = ["seed", "steps", "cfg", "sampler_name", "scheduler", "denoise"] link_keys = ["model", "positive", "negative", "latent_image"] # Each group should appear in this order. assert [k for k in keys if k in widget_keys] == widget_keys assert [k for k in keys if k in link_keys] == link_keys # Widgets come before links overall assert keys.index("denoise") < keys.index("model") # Control-after-generate "randomize" was stripped from after seed assert inputs["seed"] == 42 def test_unknown_node_type_uses_class_name_as_title(self, object_info): workflow = { "nodes": [ _node( 1, "TotallyUnknownNode", outputs=[{"links": [1]}], ), _node( 2, "PreviewImage", inputs=[{"name": "images", "link": 1}], outputs=[], ), ], "links": [[1, 1, 0, 2, 0, "IMAGE"]], } result = convert_ui_to_api(workflow, object_info) assert result["1"]["_meta"]["title"] == "TotallyUnknownNode" def test_node_title_overrides_display_name(self, object_info): workflow = { "nodes": [ _node( 1, "EmptyLatentImage", outputs=[{"links": [1]}], widgets=[512, 512, 1], title="My Custom Title", ), _node(2, "PreviewImage", inputs=[{"name": "images", "link": 1}]), ], "links": [[1, 1, 0, 2, 0, "LATENT"]], } result = convert_ui_to_api(workflow, object_info) assert result["1"]["_meta"]["title"] == "My Custom Title" def test_invalid_workflow_raises(self, object_info): with pytest.raises(WorkflowConversionError): convert_ui_to_api({"nodes": "not a list"}, object_info) # --------------------------------------------------------------------------- # Special node types # --------------------------------------------------------------------------- class TestSpecialNodes: def test_primitive_node_inlines_value(self, object_info): # PrimitiveNode(1, value=1024) -> EmptyLatentImage(2).width workflow = { "nodes": [ _node( 1, "PrimitiveNode", outputs=[{"links": [5]}], widgets=[1024, "fixed"], ), _node( 2, "EmptyLatentImage", inputs=[{"name": "width", "link": 5}], outputs=[{"links": [99]}], widgets=[1024, 512, 1], ), _node(3, "PreviewImage", inputs=[{"name": "images", "link": 99}]), ], "links": [ [5, 1, 0, 2, 0, "INT"], [99, 2, 0, 3, 0, "LATENT"], ], } result = convert_ui_to_api(workflow, object_info) assert "1" not in result # PrimitiveNode excluded # The value flowed from primitive into the consuming node's inputs assert result["2"]["inputs"]["width"] == 1024 def test_reroute_is_transparent(self, object_info): workflow = { "nodes": [ _node(1, "EmptyLatentImage", outputs=[{"links": [1]}], widgets=[512, 512, 1]), _node( 99, "Reroute", inputs=[{"name": "in", "link": 1}], outputs=[{"links": [2]}], ), _node(2, "PreviewImage", inputs=[{"name": "images", "link": 2}]), ], "links": [ [1, 1, 0, 99, 0, "LATENT"], [2, 99, 0, 2, 0, "LATENT"], ], } result = convert_ui_to_api(workflow, object_info) assert "99" not in result # Reroute excluded # The reroute's downstream consumer points at the reroute's source assert result["2"]["inputs"]["images"] == ["1", 0] def test_get_set_node_pair(self, object_info): # SetNode publishes node 1's output as variable "myvar" # GetNode reads "myvar" and forwards to node 2 workflow = { "nodes": [ _node(1, "EmptyLatentImage", outputs=[{"links": [10]}], widgets=[512, 512, 1]), _node( 20, "SetNode", inputs=[{"name": "value", "link": 10}], widgets=["myvar"], ), _node( 21, "GetNode", outputs=[{"links": [11]}], widgets=["myvar"], ), _node(2, "PreviewImage", inputs=[{"name": "images", "link": 11}]), ], "links": [ [10, 1, 0, 20, 0, "LATENT"], [11, 21, 0, 2, 0, "LATENT"], ], } result = convert_ui_to_api(workflow, object_info) assert "20" not in result # SetNode excluded assert "21" not in result # GetNode excluded assert result["2"]["inputs"]["images"] == ["1", 0] def test_muted_node_is_excluded(self, object_info): workflow = { "nodes": [ _node(1, "EmptyLatentImage", outputs=[{"links": [1]}], widgets=[512, 512, 1]), _node( 2, "PreviewImage", inputs=[{"name": "images", "link": 1}], mode=2, # muted ), ], "links": [[1, 1, 0, 2, 0, "LATENT"]], } result = convert_ui_to_api(workflow, object_info) # Both 1 (no downstream consumer after 2 is muted, and not OUTPUT_NODE # because it has no connected output) and 2 (muted) are excluded. assert "2" not in result def test_bypassed_node_passes_through(self, object_info): # 1 -> 99 (bypassed) -> 2; result should connect 1 directly to 2. workflow = { "nodes": [ _node(1, "EmptyLatentImage", outputs=[{"links": [1]}], widgets=[512, 512, 1]), _node( 99, "VAEDecode", # any passthrough-able node will do inputs=[ {"name": "samples", "type": "LATENT", "link": 1}, {"name": "vae", "type": "VAE", "link": None}, ], outputs=[{"name": "IMAGE", "type": "LATENT", "links": [2]}], mode=4, # bypassed ), _node(2, "PreviewImage", inputs=[{"name": "images", "link": 2}]), ], "links": [ [1, 1, 0, 99, 0, "LATENT"], [2, 99, 0, 2, 0, "LATENT"], ], } result = convert_ui_to_api(workflow, object_info) assert "99" not in result # bypassed assert result["2"]["inputs"]["images"] == ["1", 0] def test_load_image_output_excluded(self, object_info): # LoadImageOutput is the only hardcoded UI-only exclusion. workflow = { "nodes": [ _node( 1, "LoadImageOutput", outputs=[{"links": [1]}], widgets=["pic.png"], ), _node( 2, "PreviewImage", inputs=[{"name": "images", "link": 1}], ), ], "links": [[1, 1, 0, 2, 0, "IMAGE"]], } result = convert_ui_to_api(workflow, object_info) assert "1" not in result def test_note_node_excluded(self, object_info): workflow = { "nodes": [ _node(1, "Note", widgets=["just text"]), _node(2, "EmptyLatentImage", outputs=[{"links": []}], widgets=[512, 512, 1]), ], "links": [], } result = convert_ui_to_api(workflow, object_info) assert "1" not in result def test_output_node_kept_even_without_outgoing_links(self, object_info): workflow = { "nodes": [ _node(1, "EmptyLatentImage", outputs=[{"links": [1]}], widgets=[512, 512, 1]), # PreviewImage's `output_node` is True in the schema → kept. _node(2, "PreviewImage", inputs=[{"name": "images", "link": 1}], outputs=[]), ], "links": [[1, 1, 0, 2, 0, "IMAGE"]], } result = convert_ui_to_api(workflow, object_info) assert "2" in result def test_unwired_node_still_emitted(self, object_info): # A node with no connected outputs and no schema-declared output_node # used to be dropped by an aggressive "dead-branch" heuristic. The # frontend's graphToPrompt() emits every non-virtual, non-muted, # non-bypassed node regardless — the executor only runs nodes # reachable from sinks, so leftover unwired nodes are harmless. # See cloud-mcp-server/src/converter/nodeFilter.ts shouldIncludeInOutput. workflow = { "nodes": [ _node( 99, "EmptyLatentImage", outputs=[{"links": []}], widgets=[64, 64, 1], ), ], "links": [], } result = convert_ui_to_api(workflow, object_info) assert "99" in result assert result["99"]["class_type"] == "EmptyLatentImage" assert result["99"]["inputs"] == {"width": 64, "height": 64, "batch_size": 1} def test_unwired_load_node_still_emitted(self, object_info): # Real cloud-mcp regression: a saved workflow has a LoadAudio that the # user added but didn't yet wire to anything. The frontend's # graphToPrompt() emits it; we used to drop it via dead-branch # exclusion, losing the node entirely from the API output. load_audio_schema = { "LoadAudio": { "input": { "required": { "audio": [["song.mp3"], {}], } }, "input_order": {"required": ["audio"]}, "output_node": False, "output": ["AUDIO"], "display_name": "Load Audio", } } workflow = { "nodes": [ { "id": 1, "type": "LoadAudio", "inputs": [], "outputs": [{"name": "AUDIO", "type": "AUDIO", "links": None}], "widgets_values": ["song.mp3"], "mode": 0, }, ], "links": [], } result = convert_ui_to_api(workflow, load_audio_schema) assert "1" in result assert result["1"]["inputs"] == {"audio": "song.mp3"} def test_markdown_note_excluded(self, object_info): # MarkdownNote is a UI-only documentation node with no Python class # behind it. Must never appear in the API output even when not # otherwise filtered out by dead-branch logic (which we no longer # apply). workflow = { "nodes": [ _node( 1, "MarkdownNote", outputs=[], widgets=["# Heading\n\nSome documentation"], ), _node(2, "EmptyLatentImage", outputs=[{"links": [1]}], widgets=[512, 512, 1]), _node(3, "PreviewImage", inputs=[{"name": "images", "link": 1}], outputs=[]), ], "links": [[1, 2, 0, 3, 0, "IMAGE"]], } result = convert_ui_to_api(workflow, object_info) assert "1" not in result assert {"2", "3"} <= set(result) # --------------------------------------------------------------------------- # Schema-aware behaviors # --------------------------------------------------------------------------- class TestSchemaAwareBehavior: def test_combo_value_normalized_case_insensitively(self, object_info): workflow = { "nodes": [ _node( 1, "KSampler", inputs=[], outputs=[{"links": []}], widgets=[1, "fixed", 1, 1.0, "EULER", "Normal", 1.0], ), _node(2, "PreviewImage", inputs=[{"name": "images", "link": None}]), ], "links": [], } # KSampler with no inputs is not a viable workflow, but we just want the # combo normalization assertion. Bypass the dead-branch exclusion by # giving it a real downstream link. workflow["nodes"][0]["outputs"] = [{"links": [1]}] workflow["nodes"][1]["inputs"][0]["link"] = 1 workflow["links"] = [[1, 1, 0, 2, 0, "LATENT"]] result = convert_ui_to_api(workflow, object_info) assert result["1"]["inputs"]["sampler_name"] == "euler" # normalized to lowercase assert result["1"]["inputs"]["scheduler"] == "normal" def test_defaults_filled_when_widget_values_absent(self, object_info): # Node with only one widget value; the others should come from schema defaults # (object_info["EmptyLatentImage"]["input"]["required"]["height"]["default"] = 512) workflow = { "nodes": [ _node( 1, "EmptyLatentImage", outputs=[{"links": [1]}], widgets=[1024], # only width supplied ), _node(2, "PreviewImage", inputs=[{"name": "images", "link": 1}]), ], "links": [[1, 1, 0, 2, 0, "LATENT"]], } result = convert_ui_to_api(workflow, object_info) assert result["1"]["inputs"]["width"] == 1024 assert result["1"]["inputs"]["height"] == 512 # filled from schema default assert result["1"]["inputs"]["batch_size"] == 1 # --------------------------------------------------------------------------- # Subgraph expansion # --------------------------------------------------------------------------- class TestMalformedInputHardening: """The converter must never crash on a malformed workflow — only raise a typed :class:`WorkflowConversionError` (or skip the offending pieces with a log warning). The CLI wraps those into a clean exit; uncaught exceptions would bubble up as a raw Python traceback, which is unacceptable for an experimental feature. """ def test_rejects_non_dict_workflow(self, object_info): with pytest.raises(WorkflowConversionError): convert_ui_to_api(None, object_info) with pytest.raises(WorkflowConversionError): convert_ui_to_api("nope", object_info) def test_rejects_non_dict_object_info(self): with pytest.raises(WorkflowConversionError): convert_ui_to_api({"nodes": [], "links": []}, "not a dict") def test_rejects_missing_nodes_or_links(self, object_info): with pytest.raises(WorkflowConversionError): convert_ui_to_api({}, object_info) with pytest.raises(WorkflowConversionError): convert_ui_to_api({"nodes": "oops", "links": []}, object_info) def test_skips_non_dict_node_entries(self, object_info): # A workflow with mixed garbage in the nodes list should still convert # the well-formed nodes and ignore the rest. workflow = { "nodes": [ None, 42, "string", _node(1, "EmptyLatentImage", outputs=[{"links": [1]}], widgets=[512, 512, 1]), _node(2, "PreviewImage", inputs=[{"name": "images", "link": 1}], outputs=[]), ], "links": [[1, 1, 0, 2, 0, "IMAGE"]], } result = convert_ui_to_api(workflow, object_info) assert set(result) == {"1", "2"} def test_tolerates_garbage_in_inputs_and_outputs(self, object_info): # Outputs/inputs containing non-dict garbage shouldn't crash collection. workflow = { "nodes": [ { "id": 1, "type": "EmptyLatentImage", "inputs": [None, 42, {"name": "x", "link": None}], "outputs": [None, 42, {"name": "LATENT", "links": [1]}], "widgets_values": [512, 512, 1], "mode": 0, }, _node(2, "PreviewImage", inputs=[{"name": "images", "link": 1}], outputs=[]), ], "links": [[1, 1, 2, 2, 0, "IMAGE"]], } # Should not raise. result = convert_ui_to_api(workflow, object_info) assert "1" in result assert "2" in result def test_tolerates_non_list_widgets_values(self, object_info): workflow = { "nodes": [ _node(1, "EmptyLatentImage", outputs=[{"links": [1]}]), # no widgets at all { "id": 2, "type": "EmptyLatentImage", "outputs": [{"links": [2]}], "widgets_values": 42, # invalid: an int "mode": 0, }, _node(3, "PreviewImage", inputs=[{"name": "images", "link": 2}], outputs=[]), ], "links": [[1, 1, 0, 3, 0, "IMAGE"], [2, 2, 0, 3, 0, "IMAGE"]], } # Should not raise; the node with int widgets_values just emits no widgets. result = convert_ui_to_api(workflow, object_info) assert "2" in result def test_tolerates_non_numeric_slot_in_link(self, object_info): # A bypass-time link with a string slot index should fall back to slot 0. workflow = { "nodes": [ _node(1, "EmptyLatentImage", outputs=[{"links": [1]}], widgets=[512, 512, 1]), { "id": 99, "type": "VAEDecode", "inputs": [{"name": "samples", "type": "LATENT", "link": 1}], "outputs": [{"name": "IMAGE", "type": "IMAGE", "links": [2]}], "mode": 4, }, _node(2, "PreviewImage", inputs=[{"name": "images", "link": 2}], outputs=[]), ], # Note: source_slot is the string "weird" instead of an int. "links": [[1, 1, 0, 99, 0, "LATENT"], [2, 99, "weird", 2, 0, "IMAGE"]], } # Should not raise. result = convert_ui_to_api(workflow, object_info) assert "2" in result def test_tolerates_garbage_definitions(self, object_info): # definitions could be a list, None, or otherwise wrong-shape. for bad_defs in ([], "string", 42, {"subgraphs": "not a list"}): workflow = { "nodes": [ _node(1, "EmptyLatentImage", outputs=[{"links": [1]}], widgets=[512, 512, 1]), _node(2, "PreviewImage", inputs=[{"name": "images", "link": 1}], outputs=[]), ], "links": [[1, 1, 0, 2, 0, "IMAGE"]], "definitions": bad_defs, } result = convert_ui_to_api(workflow, object_info) assert set(result) == {"1", "2"}, f"failed with definitions={bad_defs!r}" def test_set_get_node_with_unhashable_var_name_does_not_crash(self, object_info): # SetNode/GetNode publish/read a variable name that becomes a dict key # in the tracer. If the saved widgets_values[0] is a list or dict, # using it as a key raises TypeError. _collect_get_set_mappings runs # before the per-node try/except wrapper, so an unguarded SetNode in # particular aborts the whole conversion. for bad_var in (["list-as-var"], {"dict": "as-var"}, None, ""): workflow = { "nodes": [ _node(1, "EmptyLatentImage", outputs=[{"links": [1]}], widgets=[512, 512, 1]), { "id": 20, "type": "SetNode", "inputs": [{"name": "v", "link": 1}], "widgets_values": [bad_var], "mode": 0, }, ], "links": [[1, 1, 0, 20, 0, "LATENT"]], } # Should not raise, no matter how unhashable the var name is. convert_ui_to_api(workflow, object_info) def test_unhashable_link_value_in_global_helpers_does_not_crash(self, object_info): # ``link_id in link_map`` raises TypeError on unhashable values, so # _collect_reroute_sources / _collect_get_set_mappings / subgraph # linkIds resolution used to abort the entire conversion when a # single saved Reroute / SetNode / subgraph input had ``link: []`` # (or ``{}``, etc.). cases = [ ( "reroute_list", { "nodes": [{"id": 1, "type": "Reroute", "inputs": [{"link": []}], "outputs": [], "mode": 0}], "links": [], }, ), ( "reroute_dict", { "nodes": [{"id": 1, "type": "Reroute", "inputs": [{"link": {}}], "outputs": [], "mode": 0}], "links": [], }, ), ( "setnode_list", { "nodes": [ { "id": 1, "type": "SetNode", "inputs": [{"link": []}], "outputs": [], "widgets_values": ["myvar"], "mode": 0, } ], "links": [], }, ), ( "subgraph_linkIds_list", { "nodes": [ { "id": 1, "type": "11111111-2222-3333-4444-555555555555", "inputs": [], "outputs": [], } ], "links": [], "definitions": { "subgraphs": [ { "id": "11111111-2222-3333-4444-555555555555", "nodes": [], "links": [], "inputs": [{"name": "x", "linkIds": [["bad"]]}], "outputs": [], } ] }, }, ), ] for _label, workflow in cases: # Should not raise — each malformed link is silently skipped. convert_ui_to_api(workflow, object_info) def test_subgraph_link_with_unhashable_id_is_skipped(self, object_info): # Internal link IDs are dict keys; an unhashable id used to crash # the whole subgraph expansion (which runs before the per-node # try/except), aborting conversion before anything could be emitted. SG_UUID = "11111111-2222-3333-4444-555555555555" for bad_id in (["x"], {"k": 1}, None): workflow = { "nodes": [{"id": 1, "type": SG_UUID, "inputs": [], "outputs": []}], "links": [], "definitions": { "subgraphs": [ { "id": SG_UUID, "nodes": [], "links": [{"id": bad_id, "origin_id": 1, "target_id": 2}], "inputs": [], "outputs": [], } ] }, } # Should not raise — bad link is just dropped. convert_ui_to_api(workflow, object_info) def test_inner_node_with_unhashable_link_id_does_not_crash(self, object_info): # An inner subgraph node whose input's ``link`` field is not an int # used to crash _rewrite_internal_input's ``internal_link_map.get`` # / ``link_id in link_id_remap`` lookup. SG_UUID = "22222222-3333-4444-5555-666666666666" workflow = { "nodes": [{"id": 1, "type": SG_UUID, "inputs": [], "outputs": []}], "links": [], "definitions": { "subgraphs": [ { "id": SG_UUID, "nodes": [ { "id": 9, "type": "Foo", "inputs": [{"link": ["weird"]}], "outputs": [], } ], "links": [{"id": 5, "origin_id": 1, "target_id": 9}], "inputs": [], "outputs": [], } ] }, } # Should not raise. convert_ui_to_api(workflow, object_info) def test_malformed_subgraph_definition_does_not_crash(self, object_info): # Subgraph expansion runs before the per-node try/except wrapper, so # the defensive checks live in the helpers themselves. Each of these # malformed-definition shapes used to leak an AttributeError/TypeError # before the helpers were guarded. sg_uuid = "11111111-2222-3333-4444-555555555555" cases = [ # sg.inputs contains non-dict entries {"id": sg_uuid, "nodes": [], "links": [], "inputs": [None, 42, ["x"]]}, # sg.outputs contains non-dict entries {"id": sg_uuid, "nodes": [], "links": [], "outputs": [None, 42]}, # sg.id is unhashable; the def is silently dropped {"id": {"weird": True}, "nodes": [], "links": []}, {"id": ["x"], "nodes": [], "links": []}, ] for sg in cases: workflow = { "nodes": [{"id": 1, "type": sg_uuid, "inputs": [], "outputs": []}], "links": [], "definitions": {"subgraphs": [sg]}, } # Should not raise, regardless of how malformed the subgraph def is. convert_ui_to_api(workflow, object_info) def test_outer_subgraph_node_with_non_dict_inputs_does_not_crash(self, object_info): sg_uuid = "11111111-2222-3333-4444-555555555555" workflow = { "nodes": [ { "id": 1, "type": sg_uuid, "inputs": [None, 42, {"name": "x"}], "outputs": [], } ], "links": [], "definitions": { "subgraphs": [{"id": sg_uuid, "nodes": [], "links": [], "inputs": [{"name": "x"}], "outputs": []}] }, } # Should not raise. convert_ui_to_api(workflow, object_info) def test_v3_combo_option_with_non_dict_inputs_keeps_node(self): # A V3 dynamic combo option whose ``inputs`` field is malformed # (string / list / etc., not the expected INPUT_TYPES-shaped dict) # used to crash _dynamic_combo_sub_inputs; the per-node wrapper # caught the AttributeError but silently dropped the entire node. # Now we degrade to "no sub-inputs" and keep the rest of the node. object_info = { "Foo": { "input": { "required": { "shape": [ "COMFY_DYNAMICCOMBO_V3", {"options": [{"key": "square", "inputs": "not-a-dict"}]}, ] } }, "input_order": {"required": ["shape"]}, "output_node": True, "display_name": "Foo", } } workflow = { "nodes": [ { "id": 1, "type": "Foo", "inputs": [], "outputs": [], "widgets_values": ["square", 5.0], "mode": 0, } ], "links": [], } result = convert_ui_to_api(workflow, object_info) # Node emitted, no crash, no silently-dropped node. assert result["1"]["class_type"] == "Foo" assert result["1"]["inputs"]["shape"] == "square" def test_malformed_schema_input_does_not_crash(self): # Several helpers do ``schema.get("input") or {}`` then ``.get(section)``. # If "input" was ever a non-dict, ``.get`` would AttributeError before # any per-node wrapper saw it. /object_info doesn't emit malformed # schemas today, but the rest of the converter is paranoid about # exactly this shape — keep the contract uniform. for bad_input in ([], "string", 42): object_info = { "Bar": { "input": bad_input, "input_order": {"required": []}, "output_node": True, "display_name": "Bar", } } workflow = { "nodes": [ { "id": 1, "type": "Bar", "inputs": [], "outputs": [], "widgets_values": [], "mode": 0, } ], "links": [], } result = convert_ui_to_api(workflow, object_info) assert "1" in result def test_malformed_schema_input_order_does_not_crash(self): # Same defensive contract for the ``input_order`` block. for bad_order in ([], "string", 42): object_info = { "Bar": { "input": {"required": {"x": ["INT", {"default": 0}]}}, "input_order": bad_order, "output_node": True, "display_name": "Bar", } } workflow = { "nodes": [ { "id": 1, "type": "Bar", "inputs": [], "outputs": [], "widgets_values": [42], "mode": 0, } ], "links": [], } result = convert_ui_to_api(workflow, object_info) assert "1" in result def test_single_bad_node_does_not_abort_conversion(self, object_info, caplog): # We can't easily induce _build_api_node to throw on real input, so # monkeypatch it for this test. import logging from comfy_cli import workflow_to_api as mod workflow = { "nodes": [ _node(1, "EmptyLatentImage", outputs=[{"links": [1]}], widgets=[512, 512, 1]), _node(2, "PreviewImage", inputs=[{"name": "images", "link": 1}], outputs=[]), ], "links": [[1, 1, 0, 2, 0, "IMAGE"]], } original_build = mod._build_api_node calls = {"n": 0} def flaky_build(**kwargs): calls["n"] += 1 if calls["n"] == 1: raise RuntimeError("simulated converter bug") return original_build(**kwargs) mod._build_api_node = flaky_build try: with caplog.at_level(logging.ERROR, logger="comfy_cli.workflow_to_api"): result = convert_ui_to_api(workflow, object_info) finally: mod._build_api_node = original_build # The second node still made it in even though the first crashed. assert "2" in result assert any("Failed to convert node" in rec.message for rec in caplog.records) class TestControlAfterGenerate: """The control_after_generate filter must be schema-aware so it doesn't silently corrupt legitimate widget values that happen to equal a control keyword. """ def test_seed_widget_with_control_marker_strips_correctly(self): # KSampler has ``control_after_generate: True`` on seed → the # synthetic marker string after the seed value must be stripped. object_info = { "KSampler": { "input": { "required": { "seed": ["INT", {"default": 0, "control_after_generate": True}], "steps": ["INT", {"default": 20}], "sampler_name": [["euler", "ddim"]], } }, "input_order": {"required": ["seed", "steps", "sampler_name"]}, "output_node": True, "display_name": "KSampler", } } workflow = { "nodes": [ { "id": 1, "type": "KSampler", "inputs": [], "outputs": [], "widgets_values": [42, "randomize", 20, "euler"], "mode": 0, } ], "links": [], } result = convert_ui_to_api(workflow, object_info) assert result["1"]["inputs"] == {"seed": 42, "steps": 20, "sampler_name": "euler"} def test_legitimate_value_named_fixed_is_preserved(self): # A COMBO option literally named "fixed" used to be stripped by the # naive filter, sliding every later widget out of alignment. object_info = { "ControlLike": { "input": { "required": { "mode": [["loose", "fixed", "strict"]], "label": ["STRING", {}], } }, "input_order": {"required": ["mode", "label"]}, "output_node": True, "display_name": "Control-like", } } workflow = { "nodes": [ { "id": 1, "type": "ControlLike", "inputs": [], "outputs": [], "widgets_values": ["fixed", "hello"], "mode": 0, } ], "links": [], } result = convert_ui_to_api(workflow, object_info) assert result["1"]["inputs"] == {"mode": "fixed", "label": "hello"} def test_unknown_node_falls_back_to_legacy_filter(self): # No schema → no schema-aware filter possible. We fall back to the # positional string-match heuristic, which matches SethRobinson's # reference behavior for unknown nodes. workflow = { "nodes": [ { "id": 1, "type": "TotallyUnknownNode", "inputs": [], "outputs": [{"links": [1]}], "widgets_values": [42, "randomize", 20], "mode": 0, }, { "id": 2, "type": "TotallyUnknownConsumer", "inputs": [{"name": "x", "link": 1}], "outputs": [], "mode": 0, }, ], "links": [[1, 1, 0, 2, 0, "*"]], } # Should not raise; widget_values processing for unknown types just # falls back to the legacy filter and produces an empty input map. convert_ui_to_api(workflow, {}) class TestWildcardInputType: """``*`` and ``""`` are wildcard *connection* types in litegraph. The frontend never renders a widget for them — ``PreviewAny.source`` is the canonical example. They previously slipped through the lowercase-fallback in ``_is_widget_input`` because ``"*".isupper()`` returns ``False`` (no cased characters), so the converter consumed a widgets_values slot for them and shifted every later widget out of alignment. """ OI = { "Source": { "input": {"required": {}}, "input_order": {"required": []}, "output_node": False, "output": ["INT"], "display_name": "Source", }, "PreviewAny": { "input": { "required": { "source": ["*", {}], # wildcard connection — NOT a widget } }, "input_order": {"required": ["source"]}, "output_node": True, "display_name": "Preview Any", }, "WildEmpty": { "input": { "required": { "anything": ["", {}], # empty-string wildcard "actual_widget": ["INT", {"default": 0}], } }, "input_order": {"required": ["anything", "actual_widget"]}, "output_node": True, "display_name": "WildEmpty", }, } def test_star_wildcard_not_treated_as_widget(self): workflow = { "nodes": [ _node(99, "Source", outputs=[{"links": [10]}]), { "id": 1, "type": "PreviewAny", "inputs": [{"name": "source", "type": "*", "link": 10}], "outputs": [], "widgets_values": [], "mode": 0, }, ], "links": [[10, 99, 0, 1, 0, "INT"]], } result = convert_ui_to_api(workflow, self.OI) assert result["1"]["inputs"]["source"] == ["99", 0] def test_empty_string_wildcard_does_not_consume_widget_slot(self): # Old behavior would consume widgets_values[0] for the wildcard and # emit nothing for actual_widget. Fixed: wildcard is connection-only, # the single widget value maps to actual_widget. workflow = { "nodes": [ { "id": 1, "type": "WildEmpty", "inputs": [{"name": "anything", "type": "", "link": None}], "outputs": [], "widgets_values": [42], "mode": 0, }, ], "links": [], } result = convert_ui_to_api(workflow, self.OI) assert result["1"]["inputs"]["actual_widget"] == 42 assert "anything" not in result["1"]["inputs"] class TestImplicitSeedCompanion: """The frontend's ``useIntWidget`` composable adds a ``control_after_generate`` companion widget for inputs named ``seed`` or ``noise_seed``, even when the schema doesn't declare the flag. Older workflows saved before this behavior may not have the companion value in widgets_values, so we use peek-based detection to handle both cases. """ OI = { "Sampler": { "input": { "required": { "seed": ["INT", {"default": 0}], # no control_after_generate flag "steps": ["INT", {"default": 20}], "sampler_name": [["euler", "ddim"], {}], } }, "input_order": {"required": ["seed", "steps", "sampler_name"]}, "output_node": True, "display_name": "Sampler", }, "NoiseUser": { "input": { "required": { "noise_seed": ["INT", {"default": 0}], "denoise": ["FLOAT", {"default": 1.0}], } }, "input_order": {"required": ["noise_seed", "denoise"]}, "output_node": True, "display_name": "NoiseUser", }, "RegularInt": { "input": { "required": { "value": ["INT", {"default": 0}], "label": ["STRING", {}], } }, "input_order": {"required": ["value", "label"]}, "output_node": True, "display_name": "RegularInt", }, } def test_seed_named_input_strips_implicit_companion(self): workflow = { "nodes": [ { "id": 1, "type": "Sampler", "inputs": [], "outputs": [], "widgets_values": [42, "randomize", 25, "euler"], "mode": 0, } ], "links": [], } result = convert_ui_to_api(workflow, self.OI) assert result["1"]["inputs"] == {"seed": 42, "steps": 25, "sampler_name": "euler"} def test_noise_seed_named_input_strips_implicit_companion(self): workflow = { "nodes": [ { "id": 1, "type": "NoiseUser", "inputs": [], "outputs": [], "widgets_values": [12345, "fixed", 0.85], "mode": 0, } ], "links": [], } result = convert_ui_to_api(workflow, self.OI) assert result["1"]["inputs"] == {"noise_seed": 12345, "denoise": 0.85} def test_seed_input_without_companion_still_works(self): # Older saved workflows from before the implicit-companion era don't # have the marker in widgets_values. Peek-based detection avoids # consuming a non-control value. workflow = { "nodes": [ { "id": 1, "type": "Sampler", "inputs": [], "outputs": [], "widgets_values": [42, 25, "euler"], "mode": 0, } ], "links": [], } result = convert_ui_to_api(workflow, self.OI) assert result["1"]["inputs"] == {"seed": 42, "steps": 25, "sampler_name": "euler"} def test_regular_int_input_does_not_strip_control_value(self): # A non-seed INT input has no implicit companion. A widget value that # happens to equal "randomize" must not be stripped — it slides into # the next slot. The user has bad data, but our filter shouldn't # silently eat it. workflow = { "nodes": [ { "id": 1, "type": "RegularInt", "inputs": [], "outputs": [], "widgets_values": [99, "randomize"], "mode": 0, } ], "links": [], } result = convert_ui_to_api(workflow, self.OI) assert result["1"]["inputs"]["value"] == 99 assert result["1"]["inputs"]["label"] == "randomize" class TestNodeNameForSAndRAlias: """When a node carries ``properties["Node name for S&R"]`` pointing at a different class name than its ``type`` field (legacy rename / group-node artifact), the schema lookup honors the alias in widget mapping. Before this fix, ``_meta.title``, default values, combo normalization, and the dead-branch exclusion all consulted ``object_info[node_type]`` directly and missed the schema entirely — silently dropping defaults, leaving combo values un-normalized, and (in some cases) excluding the node as a schemaless dead branch. """ OI = { "RealClass": { "input": { "required": { "sampler": [["euler", "ddim"]], "missing_widget": ["INT", {"default": 99}], } }, "input_order": {"required": ["sampler", "missing_widget"]}, "output_node": True, "display_name": "Real Sampler", }, "Sink": { "input": {"required": {"x": ["ANY"]}}, "input_order": {"required": ["x"]}, "output_node": True, "display_name": "Sink", }, } def _aliased_workflow(self, *, widgets_values): return { "nodes": [ { "id": 1, # `type` is the legacy/aliased name not in object_info. "type": "OldName", "properties": {"Node name for S&R": "RealClass"}, "inputs": [], "outputs": [{"links": [10]}], "widgets_values": widgets_values, "mode": 0, }, { "id": 2, "type": "Sink", "inputs": [{"name": "x", "link": 10}], "outputs": [], "mode": 0, }, ], "links": [[10, 1, 0, 2, 0, "ANY"]], } def test_meta_title_uses_aliased_schema(self): result = convert_ui_to_api(self._aliased_workflow(widgets_values=["euler"]), self.OI) assert result["1"]["_meta"]["title"] == "Real Sampler" def test_combo_normalization_uses_aliased_schema(self): # Wrong-case combo value must still be normalized via the aliased schema. result = convert_ui_to_api(self._aliased_workflow(widgets_values=["EULER"]), self.OI) assert result["1"]["inputs"]["sampler"] == "euler" def test_defaults_filled_from_aliased_schema(self): # Only the first widget is provided; the second should come from defaults. result = convert_ui_to_api(self._aliased_workflow(widgets_values=["euler"]), self.OI) assert result["1"]["inputs"]["missing_widget"] == 99 def test_aliased_node_with_no_connections_still_emits(self): # Even with no wired connections, the node should be emitted (we no # longer apply a dead-branch heuristic). The aliased schema's # display_name and defaults still apply correctly. workflow = { "nodes": [ { "id": 1, "type": "OldName", "properties": {"Node name for S&R": "RealClass"}, "inputs": [], "outputs": [], "widgets_values": ["euler"], "mode": 0, } ], "links": [], } result = convert_ui_to_api(workflow, self.OI) assert "1" in result assert result["1"]["_meta"]["title"] == "Real Sampler" assert result["1"]["inputs"]["sampler"] == "euler" # missing_widget filled from the aliased schema's default. assert result["1"]["inputs"]["missing_widget"] == 99 class TestForceInputHandling: """``forceInput: True`` (and its deprecated alias ``defaultInput``) demotes a widget-type input to a connection-only slot. The frontend doesn't render a widget for it and the saved workflow file has no corresponding entry in ``widgets_values``. Treating it as a widget here would consume a slot that doesn't exist and shift every later widget's value into the wrong input. """ def test_forceinput_widget_does_not_consume_value_slot(self): object_info = { "Source": { "input": {"required": {}}, "input_order": {"required": []}, "output_node": False, "output": ["INT"], "display_name": "Source", }, "Mixed": { "input": { "required": { "input_only": ["INT", {"forceInput": True}], "widget_a": ["INT", {"default": 0}], "widget_b": ["STRING", {}], } }, "input_order": {"required": ["input_only", "widget_a", "widget_b"]}, "output_node": True, "display_name": "Mixed", }, } workflow = { "nodes": [ _node(99, "Source", outputs=[{"links": [10]}]), { "id": 1, "type": "Mixed", "inputs": [{"name": "input_only", "type": "INT", "link": 10}], "outputs": [], "widgets_values": [42, "hello"], "mode": 0, }, ], "links": [[10, 99, 0, 1, 0, "INT"]], } result = convert_ui_to_api(workflow, object_info) assert result["1"]["inputs"]["widget_a"] == 42 assert result["1"]["inputs"]["widget_b"] == "hello" assert result["1"]["inputs"]["input_only"] == ["99", 0] def test_legacy_defaultinput_alias_works_the_same(self): # ``defaultInput`` is the deprecated alias the frontend migrates # from. The server's /object_info may still emit it for older # custom nodes that haven't updated. object_info = { "Source": { "input": {"required": {}}, "input_order": {"required": []}, "output_node": False, "output": ["INT"], "display_name": "Source", }, "Mixed": { "input": { "required": { "input_only": ["INT", {"defaultInput": True}], "widget_a": ["INT", {"default": 0}], } }, "input_order": {"required": ["input_only", "widget_a"]}, "output_node": True, "display_name": "Mixed", }, } workflow = { "nodes": [ _node(99, "Source", outputs=[{"links": [10]}]), { "id": 1, "type": "Mixed", "inputs": [{"name": "input_only", "type": "INT", "link": 10}], "outputs": [], "widgets_values": [42], "mode": 0, }, ], "links": [[10, 99, 0, 1, 0, "INT"]], } result = convert_ui_to_api(workflow, object_info) assert result["1"]["inputs"]["widget_a"] == 42 class TestFrontendParity: """Behaviors mirrored from ComfyUI_frontend/src/utils/executionUtil.ts.""" def test_list_widget_value_is_wrapped_to_disambiguate_from_link(self, object_info): # Imagine a widget value that's a 2-element [str, int] list — without the # ``{"__value__": ...}`` wrapper, ComfyUI's is_link() would mis-classify # this as a connection reference. object_info = { **object_info, "NodeWithListWidget": { "input": {"required": {"points": [["list", "of", "options"]]}}, "input_order": {"required": ["points"]}, "output_node": True, "display_name": "List Widget Node", }, } workflow = { "nodes": [ _node( 1, "NodeWithListWidget", outputs=[], widgets=[["foo", 3]], # widget value is a list ), ], "links": [], } result = convert_ui_to_api(workflow, object_info) assert result["1"]["inputs"]["points"] == {"__value__": ["foo", 3]} def test_orphan_link_inputs_are_stripped(self, object_info): # When a referenced upstream node ends up excluded, the cleanup pass # should drop the now-orphan link input — never leak a dangling # ["999", 0] reference into the prompt. object_info = { **object_info, "DummyExcluded": { "input": {"required": {}}, "input_order": {"required": []}, "output_node": False, # no outputs + no outgoing → excluded "display_name": "Dummy", }, "DummyConsumer": { "input": {"required": {"upstream": ["LATENT"]}}, "input_order": {"required": ["upstream"]}, "output_node": True, "display_name": "Dummy", }, } workflow = { "nodes": [ _node(999, "DummyExcluded", outputs=[{"links": [1]}]), _node(2, "DummyConsumer", inputs=[{"name": "upstream", "link": 1}], outputs=[]), ], "links": [[1, 999, 0, 2, 0, "LATENT"]], } result = convert_ui_to_api(workflow, object_info) # DummyExcluded has no schema-declared inputs and no downstream # consumer of its (zero) outputs — _collect_excluded won't prune it # because it has connected outputs, so this asserts the cleanup # branch instead by removing it via a different path. # Actually validate the simpler invariant: no input references a # node ID that's not in the result. for node in result.values(): for value in node["inputs"].values(): if isinstance(value, list) and len(value) == 2 and isinstance(value[0], str): assert value[0] in result def test_bypass_matches_any_type_wildcard(self, object_info): # When the bypassed node's input type is ``*``, the frontend's # isValidConnection treats it as compatible with any output. Our # tracer should pass through such a node even though the types # don't string-match. workflow = { "nodes": [ _node(1, "EmptyLatentImage", outputs=[{"links": [1]}], widgets=[512, 512, 1]), _node( 99, "VAEDecode", inputs=[ {"name": "samples", "type": "*", "link": 1}, # wildcard input {"name": "vae", "type": "VAE", "link": None}, ], outputs=[{"name": "IMAGE", "type": "IMAGE", "links": [2]}], mode=4, ), _node(2, "PreviewImage", inputs=[{"name": "images", "link": 2}]), ], "links": [ [1, 1, 0, 99, 0, "LATENT"], [2, 99, 0, 2, 0, "IMAGE"], ], } result = convert_ui_to_api(workflow, object_info) assert result["2"]["inputs"]["images"] == ["1", 0] def test_bypass_falls_back_to_first_linked_input_when_types_mismatch(self, object_info): # SethRobinson's reference converter falls back to the first connected # input regardless of type when no type-compatible match exists. We # match that behavior so users who bypass a non-passthrough node still # get a wired connection — the executor will surface any type error. workflow = { "nodes": [ _node(1, "EmptyLatentImage", outputs=[{"links": [1]}], widgets=[512, 512, 1]), _node( 99, "VAEDecode", # Input types don't match the IMAGE output type. inputs=[ {"name": "samples", "type": "LATENT", "link": 1}, {"name": "vae", "type": "VAE", "link": None}, ], outputs=[{"name": "IMAGE", "type": "IMAGE", "links": [2]}], mode=4, ), _node(2, "PreviewImage", inputs=[{"name": "images", "link": 2}]), ], "links": [[1, 1, 0, 99, 0, "LATENT"], [2, 99, 0, 2, 0, "IMAGE"]], } result = convert_ui_to_api(workflow, object_info) # First-linked-input fallback wires PreviewImage to node 1 even though # types don't match — preserves the user's intent rather than dropping # the edge silently. assert result["2"]["inputs"]["images"] == ["1", 0] def test_muted_node_does_not_leave_dangling_reference(self, object_info): # Intentional divergence from SethRobinson, who leaves a stray # reference to the muted node ID (the executor would reject it). # Our orphan cleanup pass mirrors the frontend's final pass. workflow = { "nodes": [ _node(1, "EmptyLatentImage", outputs=[{"links": [1]}], widgets=[512, 512, 1]), _node( 99, "VAEDecode", inputs=[ {"name": "samples", "type": "LATENT", "link": 1}, {"name": "vae", "type": "VAE", "link": None}, ], outputs=[{"name": "IMAGE", "type": "IMAGE", "links": [2]}], mode=2, # muted ), _node(2, "PreviewImage", inputs=[{"name": "images", "link": 2}]), ], "links": [[1, 1, 0, 99, 0, "LATENT"], [2, 99, 0, 2, 0, "IMAGE"]], } result = convert_ui_to_api(workflow, object_info) assert "99" not in result # Critically, PreviewImage's input must NOT reference the muted node 99. assert "images" not in result["2"]["inputs"] def test_bypass_matches_comma_separated_types(self, object_info): # Comma-separated types ("IMAGE,MASK") should match either alternative. workflow = { "nodes": [ _node(1, "EmptyLatentImage", outputs=[{"links": [1]}], widgets=[512, 512, 1]), _node( 99, "VAEDecode", inputs=[ {"name": "samples", "type": "IMAGE,LATENT", "link": 1}, {"name": "vae", "type": "VAE", "link": None}, ], outputs=[{"name": "IMAGE", "type": "IMAGE", "links": [2]}], mode=4, ), _node(2, "PreviewImage", inputs=[{"name": "images", "link": 2}]), ], "links": [ [1, 1, 0, 99, 0, "LATENT"], [2, 99, 0, 2, 0, "IMAGE"], ], } result = convert_ui_to_api(workflow, object_info) # LATENT output should connect to the LATENT alternative of the comma type assert result["2"]["inputs"]["images"] == ["1", 0] def test_group_node_workflow_emits_warning(self, object_info, caplog): # We don't expand legacy group nodes; we should warn loudly so users # know the conversion may be incomplete. import logging workflow = { "nodes": [ _node(1, "EmptyLatentImage", outputs=[{"links": [1]}], widgets=[512, 512, 1]), _node(2, "PreviewImage", inputs=[{"name": "images", "link": 1}]), ], "links": [[1, 1, 0, 2, 0, "IMAGE"]], "extra": {"groupNodes": {"MyGroup": {"nodes": []}}}, } with caplog.at_level(logging.WARNING, logger="comfy_cli.workflow_to_api"): convert_ui_to_api(workflow, object_info) assert any("group node" in record.message.lower() for record in caplog.records) class TestTracerChainDepth: """The three tracers (``trace_reroute``, ``trace_get_set``, ``trace_bypassed``) used to be tail-recursive. Python's default recursion limit (1000) meant chains longer than ~997 hit ``RecursionError`` which the per-node try/except then swallowed — silently dropping the downstream consumer from the prompt. The iterative rewrite makes them depth-unbounded. These tests pick chain lengths well past the old crash threshold so any future regression to the recursive form fails loudly. """ def _consumer_id(self): return "999999" def test_long_reroute_chain(self): N = 2000 nodes = [ _node(0, "EmptyLatentImage", outputs=[{"links": [0]}], widgets=[256, 256, 1]), ] links = [] for i in range(1, N + 1): nodes.append( _node( i, "Reroute", inputs=[{"name": "", "type": "*", "link": i - 1}], outputs=[{"name": "", "type": "*", "links": [i]}], ) ) links.append([i - 1, i - 1, 0, i, 0, "*"]) nodes.append(_node(int(self._consumer_id()), "PreviewImage", inputs=[{"name": "images", "link": N}])) links.append([N, N, 0, int(self._consumer_id()), 0, "*"]) result = convert_ui_to_api({"nodes": nodes, "links": links}, {}) # Consumer must be present and wired through to node 0. assert result[self._consumer_id()]["inputs"]["images"] == ["0", 0] def test_long_bypass_chain(self): N = 2000 nodes = [ _node(0, "EmptyLatentImage", outputs=[{"links": [0]}], widgets=[256, 256, 1]), ] links = [] prev = 0 for i in range(N): nid = 1000 + i nodes.append( { "id": nid, "type": "VAEDecode", "inputs": [ {"name": "samples", "type": "LATENT", "link": prev}, {"name": "vae", "type": "VAE", "link": None}, ], "outputs": [{"name": "IMAGE", "type": "LATENT", "links": [10000 + i]}], "mode": 4, # bypassed } ) links.append([prev, prev if i == 0 else 1000 + i - 1, 0, nid, 0, "LATENT"]) prev = 10000 + i nodes.append(_node(int(self._consumer_id()), "PreviewImage", inputs=[{"name": "images", "link": prev}])) links.append([prev, 1000 + N - 1, 0, int(self._consumer_id()), 0, "LATENT"]) result = convert_ui_to_api({"nodes": nodes, "links": links}, {}) assert result[self._consumer_id()]["inputs"]["images"] == ["0", 0] def test_long_getset_chain(self): N = 2000 nodes = [ _node(0, "EmptyLatentImage", outputs=[{"links": [0]}], widgets=[256, 256, 1]), ] links = [] prev = 0 for i in range(N): sid = 1000 + i gid = 2000 + i nodes.append( { "id": sid, "type": "SetNode", "inputs": [{"name": "value", "link": prev}], "widgets_values": [f"v{i}"], "mode": 0, } ) nodes.append( { "id": gid, "type": "GetNode", "outputs": [{"links": [10000 + i]}], "widgets_values": [f"v{i}"], "mode": 0, } ) links.append([prev, prev if i == 0 else 2000 + i - 1, 0, sid, 0, "LATENT"]) prev = 10000 + i nodes.append(_node(int(self._consumer_id()), "PreviewImage", inputs=[{"name": "images", "link": prev}])) links.append([prev, 2000 + N - 1, 0, int(self._consumer_id()), 0, "LATENT"]) result = convert_ui_to_api({"nodes": nodes, "links": links}, {}) assert result[self._consumer_id()]["inputs"]["images"] == ["0", 0] class TestMutedBypassedSubgraph: """Per frontend semantics (executionUtil.ts), if the subgraph *instance* node is itself muted or bypassed, its inner nodes do NOT enter the prompt. Without this we'd unconditionally expand and silently keep running the workflow the user explicitly told to skip. """ SG_UUID = "11111111-2222-3333-4444-555555555555" def _workflow(self, mode, with_external_wires=False): sg_def = { "id": self.SG_UUID, "name": "Inner", "nodes": [ { "id": 1, "type": "EmptyLatentImage", "outputs": [{"links": [10]}], "widgets_values": [512, 512, 1], "mode": 0, }, { "id": 2, "type": "PreviewImage", "inputs": [{"name": "images", "link": 10}], "outputs": [], "mode": 0, }, ], "links": [ { "id": 10, "origin_id": 1, "origin_slot": 0, "target_id": 2, "target_slot": 0, "type": "IMAGE", } ], "inputs": [{"name": "in_img"}] if with_external_wires else [], "outputs": [{"name": "out_img"}] if with_external_wires else [], } nodes = [ { "id": 100, "type": self.SG_UUID, "inputs": [{"name": "in_img", "type": "IMAGE", "link": 200}] if with_external_wires else [], "outputs": [{"name": "out_img", "type": "IMAGE", "links": [201]}] if with_external_wires else [], "mode": mode, } ] links = [] if with_external_wires: nodes.insert( 0, _node(7, "EmptyLatentImage", outputs=[{"links": [200]}], widgets=[512, 512, 1]), ) nodes.append( _node(8, "PreviewImage", inputs=[{"name": "images", "type": "IMAGE", "link": 201}], outputs=[]), ) links = [[200, 7, 0, 100, 0, "LATENT"], [201, 100, 0, 8, 0, "IMAGE"]] return {"nodes": nodes, "links": links, "definitions": {"subgraphs": [sg_def]}} def test_muted_subgraph_drops_inner_nodes(self, object_info): result = convert_ui_to_api(self._workflow(mode=2), object_info) assert result == {} def test_bypassed_subgraph_drops_inner_nodes(self, object_info): result = convert_ui_to_api(self._workflow(mode=4), object_info) assert result == {} def test_normal_subgraph_still_expands(self, object_info): result = convert_ui_to_api(self._workflow(mode=0), object_info) # Both inner nodes with the subgraph-prefixed IDs. assert "100:1" in result assert "100:2" in result def test_bypassed_subgraph_passes_external_input_through(self, object_info): # When the bypassed subgraph has external wires, downstream consumers # should be routed to the subgraph's upstream source (same as bypass # behavior on a regular node). result = convert_ui_to_api(self._workflow(mode=4, with_external_wires=True), object_info) assert "100" not in result # subgraph instance gone assert result["8"]["inputs"]["images"] == ["7", 0] class TestDynamicComboAfterControlMarker: """Regression: _get_widget_name_order must walk the filtered widget list, not the raw one. Without this, a V3 dynamic combo whose schema sits after a control_after_generate widget reads its selector from the wrong slot (the control marker), fails to identify the option, and silently drops every sub-input value for it. Affects 38 stock API nodes that pair a seed with a dynamic combo: Bria*, ByteDance*, Grok*, Kling*, Meshy*, Recraft*, Reve*, Vidu*, Wan2*, HappyHorse*, Tencent*, Quiver*. """ def test_dynamic_combo_selector_reads_from_filtered_slot(self): object_info = { "VulnerableNode": { "input": { "required": { "seed": ["INT", {"default": 0, "control_after_generate": True}], "shape": [ "COMFY_DYNAMICCOMBO_V3", { "options": [ {"key": "circle", "inputs": {"required": {"radius": ["FLOAT"]}}}, {"key": "square", "inputs": {"required": {"side": ["FLOAT"]}}}, ] }, ], } }, "input_order": {"required": ["seed", "shape"]}, "output_node": True, "display_name": "VN", } } workflow = { "nodes": [ { "id": 1, "type": "VulnerableNode", "inputs": [], "outputs": [], # seed, control_marker, shape selector, then sub-input "widgets_values": [42, "randomize", "square", 10.0], "mode": 0, } ], "links": [], } result = convert_ui_to_api(workflow, object_info) inputs = result["1"]["inputs"] assert inputs["seed"] == 42 assert inputs["shape"] == "square" # Without the fix the sub-input was silently dropped. assert inputs["shape.side"] == 10.0 class TestDynamicPrompts: """Port of frontend's processDynamicPrompt behavior (formatUtil.ts). Tests pin ``random.choice`` deterministically; the runtime behavior is genuinely random, matching the frontend's ``Math.random()`` semantics. """ # -- Pure algorithm -------------------------------------------------- def test_no_braces_passes_through(self): assert process_dynamic_prompt("abcdef") == "abcdef" assert process_dynamic_prompt("") == "" def test_strips_line_comments(self): # // to end of line assert process_dynamic_prompt("abc // a comment\nrest") == "abc \nrest" def test_strips_block_comments(self): # /* ... */ across or within lines assert process_dynamic_prompt("/*\nStart\n*/Hello /* mid */ world") == "Hello world" def test_picks_one_option_per_group(self): with patch("comfy_cli.workflow_to_api.random.choice", side_effect=lambda opts: opts[0]): assert process_dynamic_prompt("{option1|option2}") == "option1" with patch("comfy_cli.workflow_to_api.random.choice", side_effect=lambda opts: opts[-1]): assert process_dynamic_prompt("{option1|option2}") == "option2" def test_handles_empty_alternatives(self): # Trailing empty with patch("comfy_cli.workflow_to_api.random.choice", side_effect=lambda opts: opts[-1]): assert process_dynamic_prompt("{a|}") == "" # Leading empty with patch("comfy_cli.workflow_to_api.random.choice", side_effect=lambda opts: opts[0]): assert process_dynamic_prompt("{|a}") == "" # All empty with patch("comfy_cli.workflow_to_api.random.choice", side_effect=lambda opts: opts[0]): assert process_dynamic_prompt("{||}") == "" def test_handles_nested_groups(self): # Always pick first → outer 'a' with patch("comfy_cli.workflow_to_api.random.choice", side_effect=lambda opts: opts[0]): assert process_dynamic_prompt("{a|{b|{c|d}}}") == "a" # Always pick last → innermost 'd' with patch("comfy_cli.workflow_to_api.random.choice", side_effect=lambda opts: opts[-1]): assert process_dynamic_prompt("{a|{b|{c|d}}}") == "d" def test_escapes_preserve_literal_characters(self): # Escaped braces remain literal assert process_dynamic_prompt("\\{a|b\\}") == "{a|b}" # Escaped pipe outside group assert process_dynamic_prompt("a\\|b") == "a|b" # Escapes inside group survive with patch("comfy_cli.workflow_to_api.random.choice", side_effect=lambda opts: opts[0]): assert process_dynamic_prompt("{\\{escaped\\}\\|escaped pipe}") == "{escaped}|escaped pipe" def test_unterminated_group_degrades_gracefully(self): # Frontend never throws on malformed input; we match that. with patch("comfy_cli.workflow_to_api.random.choice", side_effect=lambda opts: opts[0]): assert process_dynamic_prompt("{option1|option2|{nested1|nested2") == "option1" def test_multiple_groups_in_one_string(self): with patch("comfy_cli.workflow_to_api.random.choice", side_effect=lambda opts: opts[1]): assert process_dynamic_prompt("1{a|b|c}2{d|e|f}3") == "1b2e3" # -- Integration via convert_ui_to_api ------------------------------- OI = { "CLIPTextEncode": { "input": { "required": { "text": ["STRING", {"multiline": True, "dynamicPrompts": True}], "clip": ["CLIP"], } }, "input_order": {"required": ["text", "clip"]}, "output_node": False, "output": ["CONDITIONING"], "display_name": "CLIP Text Encode", }, "PreviewImage": { "input": {"required": {"images": ["IMAGE"]}}, "input_order": {"required": ["images"]}, "output_node": True, "display_name": "Preview Image", }, "PlainText": { "input": {"required": {"text": ["STRING", {}]}}, "input_order": {"required": ["text"]}, "output_node": True, "display_name": "Plain Text", }, } def test_clip_text_encode_resolves_groups(self): with patch("comfy_cli.workflow_to_api.random.choice", side_effect=lambda opts: opts[0]): workflow = { "nodes": [ { "id": 1, "type": "CLIPTextEncode", "inputs": [{"name": "clip", "link": None}], "outputs": [{"links": [10]}], "widgets_values": ["a {red|blue} hat"], "mode": 0, }, { "id": 2, "type": "PreviewImage", "inputs": [{"name": "images", "link": 10}], "outputs": [], "mode": 0, }, ], "links": [[10, 1, 0, 2, 0, "IMAGE"]], } result = convert_ui_to_api(workflow, self.OI) assert result["1"]["inputs"]["text"] == "a red hat" def test_widget_without_dynamic_prompts_flag_left_alone(self): # PlainText.text does NOT declare dynamicPrompts → literal passthrough. workflow = { "nodes": [ { "id": 1, "type": "PlainText", "inputs": [], "outputs": [], "widgets_values": ["a {red|blue} hat"], "mode": 0, }, ], "links": [], } result = convert_ui_to_api(workflow, self.OI) assert result["1"]["inputs"]["text"] == "a {red|blue} hat" def test_non_string_value_passes_through_unchanged(self): # Numeric values on a dynamicPrompts input shouldn't be regex'd workflow = { "nodes": [ { "id": 1, "type": "CLIPTextEncode", "inputs": [{"name": "clip", "link": None}], "outputs": [{"links": [10]}], "widgets_values": [42], "mode": 0, }, { "id": 2, "type": "PreviewImage", "inputs": [{"name": "images", "link": 10}], "outputs": [], "mode": 0, }, ], "links": [[10, 1, 0, 2, 0, "IMAGE"]], } result = convert_ui_to_api(workflow, self.OI) assert result["1"]["inputs"]["text"] == 42 def test_random_choice_is_deterministic_under_seed(self): # Sanity: seeding the global RNG fixes the choice — useful for the # rare downstream test/script that wants reproducible runs. random.seed(0) first = process_dynamic_prompt("{alpha|beta|gamma}") random.seed(0) second = process_dynamic_prompt("{alpha|beta|gamma}") assert first == second assert first in {"alpha", "beta", "gamma"} class TestFixtureParity: """Regression test against a real workflow + the exact API output that ComfyUI's /workflow/convert endpoint produced for it. Regenerate the fixtures by running a live ComfyUI with Seth Robinson's /workflow/convert node and POSTing the UI JSON to the endpoint. """ def test_sd15_workflow_matches_reference(self): ui = json.loads((FIXTURES / "sd15_ui_workflow.json").read_text()) object_info = json.loads((FIXTURES / "sd15_object_info.json").read_text()) expected = json.loads((FIXTURES / "sd15_expected_api.json").read_text()) assert convert_ui_to_api(ui, object_info) == expected class TestSubgraphExpansion: def test_simple_subgraph_expansion(self, object_info): sg_uuid = "11111111-2222-3333-4444-555555555555" # Outer workflow: an EmptyLatentImage feeds a subgraph instance whose # internal pipeline ends with a PreviewImage. After expansion the # PreviewImage should appear with a prefixed id ("100:50"). workflow = { "nodes": [ _node(7, "EmptyLatentImage", outputs=[{"links": [200]}], widgets=[512, 512, 1]), # The subgraph instance — its `type` is the UUID. { "id": 100, "type": sg_uuid, "inputs": [{"name": "incoming", "link": 200}], "outputs": [], "mode": 0, }, ], "links": [[200, 7, 0, 100, 0, "LATENT"]], "definitions": { "subgraphs": [ { "id": sg_uuid, "name": "MySubgraph", "inputs": [{"name": "incoming", "linkIds": [301]}], "outputs": [], "nodes": [ { "id": 50, "type": "PreviewImage", "inputs": [{"name": "images", "link": 301}], "outputs": [], "mode": 0, }, ], "links": [ { "id": 301, "origin_id": -10, # subgraph input proxy "origin_slot": 0, "target_id": 50, "target_slot": 0, "type": "LATENT", }, ], } ] }, } result = convert_ui_to_api(workflow, object_info) # The subgraph instance itself is gone; internal node appears with prefix. assert "100" not in result assert "100:50" in result # Link from the external EmptyLatentImage was retargeted at the internal node. assert result["100:50"]["inputs"]["images"] == ["7", 0] ================================================ FILE: tests/comfy_cli/test_workspace_manager.py ================================================ import os from unittest.mock import MagicMock, patch import pytest from comfy_cli.workspace_manager import ( WorkspaceType, _find_comfyui_root, _has_comfyui_markers, _paths_match, check_comfy_repo, ) class TestPathsMatch: def test_identical_paths(self, tmp_path): d = tmp_path / "comfy" d.mkdir() assert _paths_match(str(d), str(d)) def test_symlink_to_same_dir(self, tmp_path): real = tmp_path / "real" real.mkdir() link = tmp_path / "link" link.symlink_to(real) assert _paths_match(str(real), str(link)) def test_different_paths(self, tmp_path): a = tmp_path / "a" b = tmp_path / "b" a.mkdir() b.mkdir() assert not _paths_match(str(a), str(b)) def test_nonexistent_paths_same(self): assert _paths_match("/nonexistent/same", "/nonexistent/same") def test_nonexistent_paths_different(self): assert not _paths_match("/nonexistent/a", "/nonexistent/b") def test_trailing_slash(self, tmp_path): d = tmp_path / "comfy" d.mkdir() assert _paths_match(str(d), str(d) + "/") def test_dot_components(self, tmp_path): d = tmp_path / "comfy" d.mkdir() assert _paths_match(str(d), str(d) + "/./") def test_parent_component(self, tmp_path): d = tmp_path / "comfy" d.mkdir() sub = d / "sub" sub.mkdir() assert _paths_match(str(d), str(sub) + "/..") def test_one_exists_one_not(self, tmp_path): d = tmp_path / "exists" d.mkdir() # samefile will raise because the second path doesn't exist; # fallback compares realpath strings, which will differ assert not _paths_match(str(d), "/nonexistent/path") def test_double_symlink(self, tmp_path): real = tmp_path / "real" real.mkdir() link1 = tmp_path / "link1" link1.symlink_to(real) link2 = tmp_path / "link2" link2.symlink_to(link1) assert _paths_match(str(link1), str(link2)) assert _paths_match(str(real), str(link2)) _ALL_MARKERS = ["main.py", "comfy", "nodes.py", "comfy_extras", "comfy_api"] _MARKER_DIRS = {"comfy", "comfy_extras", "comfy_api"} def _create_comfyui_markers(path, markers=None): """Create ComfyUI marker files/directories under *path*.""" if markers is None: markers = _ALL_MARKERS for m in markers: p = path / m if m in _MARKER_DIRS: p.mkdir(exist_ok=True) else: p.touch() class TestHasComfyuiMarkers: def test_all_five_markers(self, tmp_path): _create_comfyui_markers(tmp_path) assert _has_comfyui_markers(str(tmp_path)) is True @pytest.mark.parametrize("omit", _ALL_MARKERS) def test_any_four_of_five_sufficient(self, tmp_path, omit): remaining = [m for m in _ALL_MARKERS if m != omit] _create_comfyui_markers(tmp_path, remaining) assert _has_comfyui_markers(str(tmp_path)) is True @pytest.mark.parametrize( "present", [ ["main.py", "comfy", "nodes.py"], ["comfy", "comfy_extras", "comfy_api"], ["main.py", "nodes.py", "comfy_api"], ], ) def test_three_markers_insufficient(self, tmp_path, present): _create_comfyui_markers(tmp_path, present) assert _has_comfyui_markers(str(tmp_path)) is False def test_empty_directory(self, tmp_path): assert _has_comfyui_markers(str(tmp_path)) is False def test_nonexistent_path(self): assert _has_comfyui_markers("/nonexistent/path/xyz") is False def _make_manager(*, use_here=None, specified_workspace=None, use_recent=None): """Create a fresh WorkspaceManager with reset singleton.""" from comfy_cli.workspace_manager import WorkspaceManager WorkspaceManager._instances = {} mgr = WorkspaceManager() mgr.use_here = use_here mgr.use_recent = use_recent mgr.specified_workspace = specified_workspace return mgr def _mock_config(mgr, default_workspace=None, recent_workspace=None): """Replace config_manager with a mock that returns the given values.""" mock_cm = MagicMock() def _get(key): from comfy_cli import constants if key == constants.CONFIG_KEY_DEFAULT_WORKSPACE: return default_workspace if key == constants.CONFIG_KEY_RECENT_WORKSPACE: return recent_workspace return None mock_cm.get.side_effect = _get mgr.config_manager = mock_cm return mock_cm class TestFindComfyuiRoot: """Tests for the upward directory walk in _find_comfyui_root.""" def test_markers_at_given_path(self, tmp_path): _create_comfyui_markers(tmp_path) assert _find_comfyui_root(str(tmp_path)) == str(tmp_path) def test_walks_up_to_parent_with_markers(self, tmp_path): _create_comfyui_markers(tmp_path) subdir = tmp_path / "custom_nodes" / "MyNode" subdir.mkdir(parents=True) assert _find_comfyui_root(str(subdir)) == str(tmp_path) def test_walks_up_multiple_levels(self, tmp_path): _create_comfyui_markers(tmp_path) deep = tmp_path / "custom_nodes" / "MyNode" / "lib" / "utils" deep.mkdir(parents=True) assert _find_comfyui_root(str(deep)) == str(tmp_path) def test_no_markers_anywhere(self, tmp_path): subdir = tmp_path / "a" / "b" subdir.mkdir(parents=True) assert _find_comfyui_root(str(subdir)) is None def test_returns_nearest_root(self, tmp_path): """Nested ComfyUI installs: returns the closest (deepest) match.""" outer = tmp_path / "outer" inner = outer / "inner" inner.mkdir(parents=True) _create_comfyui_markers(outer) _create_comfyui_markers(inner) subdir = inner / "custom_nodes" subdir.mkdir() assert _find_comfyui_root(str(subdir)) == str(inner) class TestCheckComfyRepoFallback: """Tests for the marker-based fallback in check_comfy_repo.""" def test_nonexistent_path(self): found, path = check_comfy_repo("/nonexistent/path/xyz") assert found is False assert path is None def test_non_git_dir_with_all_markers(self, tmp_path): _create_comfyui_markers(tmp_path) found, path = check_comfy_repo(str(tmp_path)) assert found is True assert path == str(tmp_path) def test_non_git_dir_with_four_markers(self, tmp_path): _create_comfyui_markers(tmp_path, ["main.py", "comfy", "nodes.py", "comfy_api"]) found, path = check_comfy_repo(str(tmp_path)) assert found is True assert path == str(tmp_path) def test_non_git_dir_insufficient_markers(self, tmp_path): _create_comfyui_markers(tmp_path, ["main.py", "comfy", "nodes.py"]) found, path = check_comfy_repo(str(tmp_path)) assert found is False assert path is None def test_non_git_empty_dir(self, tmp_path): found, path = check_comfy_repo(str(tmp_path)) assert found is False assert path is None def test_returned_path_is_absolute(self, tmp_path): """Path with '..' components is resolved to a clean absolute path.""" _create_comfyui_markers(tmp_path) subdir = tmp_path / "sub" subdir.mkdir() dotdot_path = os.path.join(str(subdir), "..") found, path = check_comfy_repo(dotdot_path) assert found is True assert os.path.isabs(path) assert ".." not in path def test_subdirectory_walks_up_to_root(self, tmp_path): """check_comfy_repo from a subdirectory resolves to the ComfyUI root.""" _create_comfyui_markers(tmp_path) subdir = tmp_path / "custom_nodes" / "MyNode" subdir.mkdir(parents=True) found, path = check_comfy_repo(str(subdir)) assert found is True assert path == str(tmp_path) def test_fork_repo_with_markers_detected(self, tmp_path): """Git repo with non-ComfyUI remote + markers → detected via fallback.""" import git as gitmodule repo = gitmodule.Repo.init(str(tmp_path)) repo.create_remote("origin", "https://github.com/someone/ComfyUI-fork") (tmp_path / "README.md").write_text("fork") repo.index.add(["README.md"]) repo.index.commit("init") _create_comfyui_markers(tmp_path) found, path = check_comfy_repo(str(tmp_path)) assert found is True assert path == str(tmp_path) def test_fork_repo_without_markers_not_detected(self, tmp_path): """Git repo with non-ComfyUI remote and no markers → not detected.""" import git as gitmodule repo = gitmodule.Repo.init(str(tmp_path)) repo.create_remote("origin", "https://github.com/someone/other-project") (tmp_path / "README.md").write_text("other") repo.index.add(["README.md"]) repo.index.commit("init") found, path = check_comfy_repo(str(tmp_path)) assert found is False assert path is None class TestStep1Workspace: def test_workspace_flag_takes_priority(self): mgr = _make_manager(specified_workspace="/opt/comfy") _mock_config(mgr) path, ws_type = mgr.get_workspace_path() assert ws_type == WorkspaceType.SPECIFIED assert path == "/opt/comfy" @patch("comfy_cli.workspace_manager.check_comfy_repo") @patch("comfy_cli.workspace_manager.os.getcwd") def test_workspace_overrides_cwd_matching_default(self, mock_getcwd, mock_check): """--workspace wins even when cwd is the default workspace.""" mock_getcwd.return_value = "/home/user/comfy/ComfyUI" mock_check.return_value = (True, "/home/user/comfy/ComfyUI") mgr = _make_manager(specified_workspace="/other/ComfyUI") _mock_config(mgr, default_workspace="/home/user/comfy/ComfyUI") path, ws_type = mgr.get_workspace_path() assert ws_type == WorkspaceType.SPECIFIED assert path == "/other/ComfyUI" class TestStep3Here: @patch("comfy_cli.workspace_manager.check_comfy_repo") @patch("comfy_cli.workspace_manager.os.getcwd") def test_here_flag_forces_current_dir_even_if_matches_default(self, mock_getcwd, mock_check): """--here always returns CURRENT_DIR, even when cwd IS the default.""" mock_getcwd.return_value = "/home/user/comfy/ComfyUI" mock_check.return_value = (True, "/home/user/comfy/ComfyUI") mgr = _make_manager(use_here=True) _mock_config(mgr, default_workspace="/home/user/comfy/ComfyUI") path, ws_type = mgr.get_workspace_path() assert ws_type == WorkspaceType.CURRENT_DIR assert path == "/home/user/comfy/ComfyUI" @patch("comfy_cli.workspace_manager.check_comfy_repo") @patch("comfy_cli.workspace_manager.os.getcwd") def test_here_flag_non_comfy_dir_appends_comfyui(self, mock_getcwd, mock_check): """--here in a non-ComfyUI dir returns cwd/ComfyUI.""" mock_getcwd.return_value = "/home/user/projects" mock_check.return_value = (False, None) mgr = _make_manager(use_here=True) _mock_config(mgr) path, ws_type = mgr.get_workspace_path() assert ws_type == WorkspaceType.CURRENT_DIR assert path == os.path.join("/home/user/projects", "ComfyUI") class TestStep4AutoDetect: @patch("comfy_cli.workspace_manager.check_comfy_repo") @patch("comfy_cli.workspace_manager.os.getcwd") def test_cwd_matches_default_returns_default_type(self, mock_getcwd, mock_check): """Core fix: cwd is the configured default workspace -> DEFAULT.""" mock_getcwd.return_value = "/home/user/comfy/ComfyUI" mock_check.return_value = (True, "/home/user/comfy/ComfyUI") mgr = _make_manager(use_here=None) _mock_config(mgr, default_workspace="/home/user/comfy/ComfyUI") with patch("comfy_cli.workspace_manager._paths_match", return_value=True): path, ws_type = mgr.get_workspace_path() assert ws_type == WorkspaceType.DEFAULT assert path == "/home/user/comfy/ComfyUI" @patch("comfy_cli.workspace_manager.check_comfy_repo") @patch("comfy_cli.workspace_manager.os.getcwd") def test_cwd_different_repo_returns_current_dir(self, mock_getcwd, mock_check): """cwd is a ComfyUI repo but NOT the default -> CURRENT_DIR.""" mock_getcwd.return_value = "/home/user/other/ComfyUI" mock_check.return_value = (True, "/home/user/other/ComfyUI") mgr = _make_manager(use_here=None) _mock_config(mgr, default_workspace="/home/user/comfy/ComfyUI") with patch("comfy_cli.workspace_manager._paths_match", return_value=False): path, ws_type = mgr.get_workspace_path() assert ws_type == WorkspaceType.CURRENT_DIR assert path == "/home/user/other/ComfyUI" @patch("comfy_cli.workspace_manager.check_comfy_repo") @patch("comfy_cli.workspace_manager.os.getcwd") def test_cwd_repo_no_default_configured(self, mock_getcwd, mock_check): """cwd is a ComfyUI repo, no default configured -> CURRENT_DIR.""" mock_getcwd.return_value = "/home/user/comfy/ComfyUI" mock_check.return_value = (True, "/home/user/comfy/ComfyUI") mgr = _make_manager(use_here=None) _mock_config(mgr, default_workspace=None) path, ws_type = mgr.get_workspace_path() assert ws_type == WorkspaceType.CURRENT_DIR assert path == "/home/user/comfy/ComfyUI" @patch("comfy_cli.workspace_manager.check_comfy_repo") @patch("comfy_cli.workspace_manager.os.getcwd") def test_cwd_repo_empty_default_returns_current_dir(self, mock_getcwd, mock_check): """default_workspace is empty string -> treated as not configured.""" mock_getcwd.return_value = "/home/user/comfy/ComfyUI" mock_check.return_value = (True, "/home/user/comfy/ComfyUI") mgr = _make_manager(use_here=None) _mock_config(mgr, default_workspace="") path, ws_type = mgr.get_workspace_path() assert ws_type == WorkspaceType.CURRENT_DIR @patch("comfy_cli.workspace_manager.check_comfy_repo") @patch("comfy_cli.workspace_manager.os.getcwd") def test_paths_match_called_with_correct_args(self, mock_getcwd, mock_check): """Verify _paths_match receives resolved path and default_workspace.""" mock_getcwd.return_value = "/cwd" mock_check.return_value = (True, "/resolved/ComfyUI") mgr = _make_manager(use_here=None) _mock_config(mgr, default_workspace="/configured/default") with patch("comfy_cli.workspace_manager._paths_match", return_value=False) as mock_pm: mgr.get_workspace_path() mock_pm.assert_called_once_with("/resolved/ComfyUI", "/configured/default") class TestNoHereSkipsStep4: @patch("comfy_cli.workspace_manager.check_comfy_repo") @patch("comfy_cli.workspace_manager.os.getcwd") def test_no_here_skips_cwd_detection(self, mock_getcwd, mock_check): """--no-here (use_here=False) skips step 4 entirely, falls to step 5.""" mock_getcwd.return_value = "/home/user/comfy/ComfyUI" mock_check.return_value = (True, "/home/user/comfy/ComfyUI") mgr = _make_manager(use_here=False) _mock_config(mgr, default_workspace="/home/user/comfy/ComfyUI") path, ws_type = mgr.get_workspace_path() assert ws_type == WorkspaceType.DEFAULT assert path == "/home/user/comfy/ComfyUI" # getcwd should never be called because step 4 is skipped mock_getcwd.assert_not_called() class TestStep5ConfiguredDefault: @patch("comfy_cli.workspace_manager.check_comfy_repo") @patch("comfy_cli.workspace_manager.os.getcwd") def test_not_comfy_repo_falls_through_to_default(self, mock_getcwd, mock_check): """cwd is NOT a ComfyUI repo -> falls through to configured default.""" mock_getcwd.return_value = "/home/user/projects" mock_check.side_effect = lambda path: ( (True, "/home/user/comfy/ComfyUI") if path == "/home/user/comfy/ComfyUI" else (False, None) ) mgr = _make_manager(use_here=None) _mock_config(mgr, default_workspace="/home/user/comfy/ComfyUI") path, ws_type = mgr.get_workspace_path() assert ws_type == WorkspaceType.DEFAULT assert path == "/home/user/comfy/ComfyUI" class TestStep6RecentFallback: @patch("comfy_cli.workspace_manager.check_comfy_repo") @patch("comfy_cli.workspace_manager.os.getcwd") def test_no_default_falls_to_recent(self, mock_getcwd, mock_check): """No default configured, valid recent workspace -> RECENT.""" mock_getcwd.return_value = "/home/user/projects" mock_check.side_effect = lambda path: ( (True, "/home/user/recent/ComfyUI") if path == "/home/user/recent/ComfyUI" else (False, None) ) mgr = _make_manager(use_here=None, use_recent=None) _mock_config( mgr, default_workspace=None, recent_workspace="/home/user/recent/ComfyUI", ) path, ws_type = mgr.get_workspace_path() assert ws_type == WorkspaceType.RECENT assert path == "/home/user/recent/ComfyUI" class TestStep7FallbackDefault: @patch("comfy_cli.workspace_manager.utils.get_not_user_set_default_workspace") @patch("comfy_cli.workspace_manager.check_comfy_repo", return_value=(False, None)) @patch("comfy_cli.workspace_manager.os.getcwd", return_value="/tmp/random") def test_all_fallbacks_exhausted(self, _cwd, _check, mock_fallback): """Nothing configured, cwd not a repo -> system fallback DEFAULT.""" mock_fallback.return_value = "/home/user/comfy" mgr = _make_manager(use_here=None, use_recent=None) _mock_config(mgr, default_workspace=None, recent_workspace=None) path, ws_type = mgr.get_workspace_path() assert ws_type == WorkspaceType.DEFAULT assert path == "/home/user/comfy" class TestFullIntegration: """Create a real git repo that looks like ComfyUI (with the right remote) and exercise the entire get_workspace_path flow with no mocked internals. Only os.getcwd and ConfigManager are faked.""" @staticmethod def _create_comfy_repo(path): """Create a bare-minimum git repo with a ComfyUI remote.""" import git as gitmodule repo = gitmodule.Repo.init(path) repo.create_remote("origin", "https://github.com/comfyanonymous/ComfyUI") # Need at least one commit for repo to be fully valid readme = os.path.join(path, "main.py") with open(readme, "w") as f: f.write("# ComfyUI\n") repo.index.add(["main.py"]) repo.index.commit("init") return repo def test_cwd_is_default_workspace_real_repo(self, tmp_path): """Bug repro: cd into default workspace -> must return DEFAULT.""" comfy_dir = str(tmp_path / "ComfyUI") self._create_comfy_repo(comfy_dir) mgr = _make_manager(use_here=None) _mock_config(mgr, default_workspace=comfy_dir) with patch("comfy_cli.workspace_manager.os.getcwd", return_value=comfy_dir): path, ws_type = mgr.get_workspace_path() assert ws_type == WorkspaceType.DEFAULT assert path == comfy_dir def test_cwd_is_default_workspace_via_symlink(self, tmp_path): """Default stored as symlink, cwd is the real path -> DEFAULT.""" real_dir = tmp_path / "real_comfy" self._create_comfy_repo(str(real_dir)) link = tmp_path / "comfy_link" link.symlink_to(real_dir) mgr = _make_manager(use_here=None) _mock_config(mgr, default_workspace=str(link)) with patch("comfy_cli.workspace_manager.os.getcwd", return_value=str(real_dir)): path, ws_type = mgr.get_workspace_path() assert ws_type == WorkspaceType.DEFAULT def test_cwd_is_subdir_of_default_workspace(self, tmp_path): """cd into custom_nodes/ inside default workspace -> DEFAULT.""" comfy_dir = tmp_path / "ComfyUI" self._create_comfy_repo(str(comfy_dir)) subdir = comfy_dir / "custom_nodes" subdir.mkdir() mgr = _make_manager(use_here=None) _mock_config(mgr, default_workspace=str(comfy_dir)) with patch("comfy_cli.workspace_manager.os.getcwd", return_value=str(subdir)): path, ws_type = mgr.get_workspace_path() assert ws_type == WorkspaceType.DEFAULT assert path == str(comfy_dir) def test_two_repos_cwd_in_non_default(self, tmp_path): """Two ComfyUI repos exist; cwd is in the non-default one -> CURRENT_DIR.""" default_dir = tmp_path / "default_comfy" other_dir = tmp_path / "other_comfy" self._create_comfy_repo(str(default_dir)) self._create_comfy_repo(str(other_dir)) mgr = _make_manager(use_here=None) _mock_config(mgr, default_workspace=str(default_dir)) with patch("comfy_cli.workspace_manager.os.getcwd", return_value=str(other_dir)): path, ws_type = mgr.get_workspace_path() assert ws_type == WorkspaceType.CURRENT_DIR assert path == str(other_dir) def test_default_workspace_trailing_slash(self, tmp_path): """Config has trailing slash, git working_dir doesn't -> DEFAULT.""" comfy_dir = str(tmp_path / "ComfyUI") self._create_comfy_repo(comfy_dir) mgr = _make_manager(use_here=None) _mock_config(mgr, default_workspace=comfy_dir + "/") with patch("comfy_cli.workspace_manager.os.getcwd", return_value=comfy_dir): path, ws_type = mgr.get_workspace_path() assert ws_type == WorkspaceType.DEFAULT def test_here_flag_overrides_even_with_real_repo(self, tmp_path): """--here forces CURRENT_DIR even in a real default workspace repo.""" comfy_dir = str(tmp_path / "ComfyUI") self._create_comfy_repo(comfy_dir) mgr = _make_manager(use_here=True) _mock_config(mgr, default_workspace=comfy_dir) with patch("comfy_cli.workspace_manager.os.getcwd", return_value=comfy_dir): path, ws_type = mgr.get_workspace_path() assert ws_type == WorkspaceType.CURRENT_DIR def test_not_in_any_repo_falls_to_configured_default(self, tmp_path): """cwd is a plain dir, configured default exists -> DEFAULT via step 5.""" comfy_dir = str(tmp_path / "ComfyUI") self._create_comfy_repo(comfy_dir) plain_dir = str(tmp_path / "plain") os.makedirs(plain_dir) mgr = _make_manager(use_here=None) _mock_config(mgr, default_workspace=comfy_dir) with patch("comfy_cli.workspace_manager.os.getcwd", return_value=plain_dir): path, ws_type = mgr.get_workspace_path() assert ws_type == WorkspaceType.DEFAULT assert path == comfy_dir def test_non_git_comfyui_detected_as_cwd(self, tmp_path): """Non-git ComfyUI (zip download) detected when cd'd into it.""" comfy_dir = tmp_path / "ComfyUI" comfy_dir.mkdir() _create_comfyui_markers(comfy_dir) mgr = _make_manager(use_here=None) _mock_config(mgr, default_workspace=str(comfy_dir)) with patch("comfy_cli.workspace_manager.os.getcwd", return_value=str(comfy_dir)): path, ws_type = mgr.get_workspace_path() assert ws_type == WorkspaceType.DEFAULT assert path == str(comfy_dir) def test_non_git_comfyui_as_configured_default(self, tmp_path): """Non-git ComfyUI install works as configured default (step 5).""" comfy_dir = tmp_path / "ComfyUI" comfy_dir.mkdir() _create_comfyui_markers(comfy_dir) plain_dir = tmp_path / "other" plain_dir.mkdir() mgr = _make_manager(use_here=None) _mock_config(mgr, default_workspace=str(comfy_dir)) with patch("comfy_cli.workspace_manager.os.getcwd", return_value=str(plain_dir)): path, ws_type = mgr.get_workspace_path() assert ws_type == WorkspaceType.DEFAULT assert path == str(comfy_dir) def test_non_git_comfyui_from_subdirectory(self, tmp_path): """Non-git ComfyUI detected when cd'd into custom_nodes/ subdirectory.""" comfy_dir = tmp_path / "ComfyUI" comfy_dir.mkdir() _create_comfyui_markers(comfy_dir) subdir = comfy_dir / "custom_nodes" / "MyNode" subdir.mkdir(parents=True) mgr = _make_manager(use_here=None) _mock_config(mgr, default_workspace=str(comfy_dir)) with patch("comfy_cli.workspace_manager.os.getcwd", return_value=str(subdir)): path, ws_type = mgr.get_workspace_path() assert ws_type == WorkspaceType.DEFAULT assert path == str(comfy_dir) def test_fork_repo_detected_as_comfyui(self, tmp_path): """ComfyUI fork (non-standard remote) detected via marker fallback.""" import git as gitmodule comfy_dir = tmp_path / "ComfyUI" repo = gitmodule.Repo.init(str(comfy_dir)) repo.create_remote("origin", "https://github.com/someone/ComfyUI-fork") (comfy_dir / "README.md").write_text("fork") repo.index.add(["README.md"]) repo.index.commit("init") _create_comfyui_markers(comfy_dir) mgr = _make_manager(use_here=None) _mock_config(mgr, default_workspace=str(comfy_dir)) with patch("comfy_cli.workspace_manager.os.getcwd", return_value=str(comfy_dir)): path, ws_type = mgr.get_workspace_path() assert ws_type == WorkspaceType.DEFAULT assert path == str(comfy_dir) ================================================ FILE: tests/e2e/test_e2e.py ================================================ import os import subprocess from datetime import datetime from textwrap import dedent import pytest from comfy_cli.resolve_python import resolve_workspace_python def e2e_test(func): return pytest.mark.skipif( os.getenv("TEST_E2E", "false") != "true", reason="Test e2e is not explicitly enabled", )(func) def exec(cmd: str, **kwargs) -> subprocess.CompletedProcess[str]: cmd = dedent(cmd).strip() print(f"cmd: {cmd}") proc = subprocess.run( args=cmd, capture_output=True, text=True, shell=True, encoding="utf-8", check=False, **kwargs, ) print(proc.stdout, proc.stderr) return proc @pytest.fixture(scope="module") def workspace(): ws = os.path.join(os.getcwd(), f"comfy-{datetime.now().timestamp()}") install_flags = os.getenv("TEST_E2E_COMFY_INSTALL_FLAGS", "--cpu") comfy_url = os.getenv("TEST_E2E_COMFY_URL", "") url_flag = f"--url {comfy_url}" if comfy_url else "" proc = exec( f""" comfy --skip-prompt --workspace {ws} install {url_flag} {install_flags} comfy --skip-prompt set-default {ws} comfy --skip-prompt --no-enable-telemetry env """ ) assert proc.returncode == 0 # Populate Manager cache before any node operations (blocking fetch). proc = exec(f"comfy --workspace {ws} node update-cache") assert proc.returncode == 0, f"update-cache failed:\n{proc.stderr}" proc = exec( f""" comfy --workspace {ws} launch --background -- {os.getenv("TEST_E2E_COMFY_LAUNCH_FLAGS_EXTRA", "--cpu")} """ ) assert proc.returncode == 0 yield ws proc = exec( f""" comfy --workspace {ws} stop """ ) assert proc.returncode == 0 @pytest.fixture() def comfy_cli(workspace): exec("comfy --skip-prompt --no-enable-telemetry env") return f"comfy --workspace {workspace}" @e2e_test def test_model(comfy_cli): url = "https://huggingface.co/guoyww/animatediff/resolve/cd71ae134a27ec6008b968d6419952b0c0494cf2/mm_sd_v14.ckpt?download=true" path = os.path.join("models", "animatediff_models") proc = exec( f""" {comfy_cli} model download --url {url} --relative-path {path} --filename animatediff_models """ ) assert proc.returncode == 0 proc = exec( f""" {comfy_cli} model list --relative-path {path} """ ) assert proc.returncode == 0 assert "animatediff_models" in proc.stdout proc = exec( f""" {comfy_cli} model remove --relative-path {path} --model-names animatediff_models --confirm """ ) assert proc.returncode == 0 @e2e_test def test_node(comfy_cli, workspace): node = "comfyui-animatediff-evolved" # Use --exit-on-fail so the CLI returns non-zero on git clone failure # instead of silently succeeding. Retry to handle transient network # errors (GitHub rate-limiting git clones on Actions runners). for attempt in range(3): proc = exec( f""" {comfy_cli} node install --exit-on-fail {node} """ ) if proc.returncode == 0: break assert proc.returncode == 0, f"node install failed after 3 attempts:\n{proc.stderr}" for attempt in range(3): proc = exec( f""" {comfy_cli} node reinstall {node} """ ) if proc.returncode == 0: break assert proc.returncode == 0, f"node reinstall failed after 3 attempts:\n{proc.stderr}" proc = exec( f""" {comfy_cli} node show all """ ) assert proc.returncode == 0 # cm-cli may display the repo name (ComfyUI-AnimateDiff-Evolved) rather # than the registry id (comfyui-animatediff-evolved), so compare lowercase. assert node.lower() in proc.stdout.lower() proc = exec( f""" {comfy_cli} node update {node} """ ) assert proc.returncode == 0 proc = exec( f""" {comfy_cli} node disable {node} """ ) assert proc.returncode == 0 proc = exec( f""" {comfy_cli} node enable {node} """ ) assert proc.returncode == 0 pubID = "comfytest123" pubToken = "6075cf7b-47e7-4c58-a3de-38f59a9bcc22" proc = exec( f""" sed 's/PublisherId = ".*"/PublisherId = "{pubID}"/g' pyproject.toml {comfy_cli} node publish --token {pubToken} """, env={"ENVIRONMENT": "stage"}, cwd=os.path.join(workspace, "custom_nodes", node), ) @e2e_test def test_manager_installed(comfy_cli, workspace): """Verify ComfyUI-Manager was installed via manager_requirements.txt.""" proc = exec( f""" {comfy_cli} node show all """ ) assert proc.returncode == 0, f"node show all failed: {proc.stderr}" # Check cm_cli is importable (Manager v4 installed as pip package) ws_python = resolve_workspace_python(workspace) proc = exec( f""" {ws_python} -c "import cm_cli; print('cm_cli OK')" """ ) assert proc.returncode == 0, f"cm_cli import failed: {proc.stderr}" assert "cm_cli OK" in proc.stdout @e2e_test def test_node_uv_compile(comfy_cli): """Test --uv-compile flag for node install (requires Manager v4.1+).""" node = "comfyui-impact-pack" proc = exec( f""" {comfy_cli} node install --uv-compile {node} """ ) assert proc.returncode == 0 # Standalone uv-sync command proc = exec( f""" {comfy_cli} node uv-sync """ ) assert proc.returncode == 0 @e2e_test def test_uv_compile_default_config(comfy_cli): """Test comfy manager uv-compile-default config command.""" proc = exec( f""" {comfy_cli} manager uv-compile-default true """ ) assert proc.returncode == 0 assert "enabled" in proc.stdout.lower() # Verify it shows in env proc = exec( """ comfy --skip-prompt --no-enable-telemetry env """ ) assert proc.returncode == 0 assert "UV Compile Default" in proc.stdout assert "Enabled" in proc.stdout # Disable it back proc = exec( f""" {comfy_cli} manager uv-compile-default false """ ) assert proc.returncode == 0 assert "disabled" in proc.stdout.lower() @e2e_test def test_install_version_latest_no_github_api(tmp_path): """Regression test for issue #440. Runs `comfy install --version latest` end-to-end and verifies: - The command succeeds without a GitHub token in the environment. - The resulting clone has a stable semver tag (v*) checked out — proving the local-tag resolver actually picked something instead of failing over to the rate-limited API. Slow pip steps are skipped to keep this targeted at the version-resolution path; the real protection is exercising the actual CLI command, so any future refactor that puts `releases/latest` API calls back on this path fails CI loudly. """ # Use tmp_path (auto-cleaned) so the clone doesn't leak into cwd. ws = str(tmp_path / "comfy-latest") env = {**os.environ} env.pop("GITHUB_TOKEN", None) # mimic the user from the bug report # Keep the command on a single line: bash uses `\` for line continuation but # Windows cmd.exe uses `^` and treats a stray `\` as a positional argument. proc = exec( f"comfy --skip-prompt --workspace {ws} install --cpu --version latest " "--skip-manager --skip-torch-or-directml --skip-requirement", env=env, ) assert proc.returncode == 0, f"install --version latest failed:\n{proc.stderr}" # The actual property under test: we did NOT fall back to the GitHub API. # Both fallback messages from checkout_stable_comfyui mention "GitHub API" # ("querying GitHub API" and "trying GitHub API as a last resort"); catch # either via the shared substring so the assertion stays tight even if the # exact wording changes. combined = proc.stdout + proc.stderr assert "GitHub API" not in combined, ( f"Install fell back to the GitHub API — local-tag resolution must have failed.\nOutput:\n{combined}" ) # `--workspace ws` clones directly into ws (matches the existing fixture's behavior). assert os.path.isdir(os.path.join(ws, ".git")), f"no git repo at {ws}" head = subprocess.run( ["git", "-C", ws, "describe", "--tags", "--exact-match", "HEAD"], capture_output=True, text=True, check=False, ) assert head.returncode == 0, ( f"HEAD is not on a tag — local tag resolution must have silently failed. stderr: {head.stderr}" ) tag = head.stdout.strip() assert tag.startswith("v") and tag.count(".") == 2, f"Expected a v.. stable tag, got: {tag!r}" # Pre-releases (v*-rc1, v*-beta) must be skipped to mirror GitHub's releases/latest. assert "-" not in tag, f"Resolver picked a pre-release tag: {tag!r}" @e2e_test def test_run(comfy_cli): url = "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors?download=true" path = os.path.join("models", "checkpoints") name = "v1-5-pruned-emaonly.safetensors" proc = exec( f""" {comfy_cli} model download --url {url} --relative-path {path} --filename {name} """ ) assert proc.returncode == 0 workflow = os.path.join(os.path.dirname(os.path.realpath(__file__)), "workflow.json") proc = exec( f""" {comfy_cli} run --workflow {workflow} --wait --timeout 180 """ ) assert proc.returncode == 0 ================================================ FILE: tests/e2e/test_e2e_uv_compile.py ================================================ """E2E tests for comfy-cli uv-compile support (requires Manager v4.1+). Tests the full stack: comfy node → execute_cm_cli → cm_cli subprocess. Uses ltdrdata's dedicated test packs (nodepack-test1-do-not-install, nodepack-test2-do-not-install) which intentionally conflict on ansible versions and contain no executable code. Supply-chain safety policy: Only node packs from verified, controllable authors (ltdrdata, comfyanonymous) are used. Adding packs from unverified sources is prohibited. Usage: TEST_E2E=true \\ TEST_E2E_COMFY_URL="https://github.com/ltdrdata/ComfyUI.git@dr-bump-manager" \\ pytest tests/e2e/test_e2e_uv_compile.py -v """ import os import shutil import subprocess import sys from datetime import datetime from textwrap import dedent import pytest # Real node packs for normal installation testing PACK_IMPACT = "comfyui-impact-pack" PACK_INSPIRE = "comfyui-inspire-pack" # Test node packs from ltdrdata — intentionally conflict on ansible versions REPO_TEST1 = "https://github.com/ltdrdata/nodepack-test1-do-not-install" REPO_TEST2 = "https://github.com/ltdrdata/nodepack-test2-do-not-install" PACK_TEST1 = "nodepack-test1-do-not-install" PACK_TEST2 = "nodepack-test2-do-not-install" def _e2e_enabled(): return os.getenv("TEST_E2E", "false") == "true" pytestmark = [ pytest.mark.skipif(not _e2e_enabled(), reason="TEST_E2E not enabled"), ] def exec(cmd: str, timeout: int = 600, **kwargs) -> subprocess.CompletedProcess[str]: cmd = dedent(cmd).strip() print(f"cmd: {cmd}") try: proc = subprocess.run( args=cmd, capture_output=True, text=True, shell=True, encoding="utf-8", check=False, timeout=timeout, **kwargs, ) except subprocess.TimeoutExpired as e: print(f"[exec] TIMEOUT after {timeout}s: {cmd}", flush=True) # Return a synthetic failed result so tests get a clear failure message return subprocess.CompletedProcess( args=cmd, returncode=124, stdout=e.stdout or "", stderr=e.stderr or f"Timed out after {timeout}s", ) print(proc.stdout, proc.stderr) return proc def _rmtree_retry(path, retries=5, delay=2.0): """Remove directory with retries for Windows file lock delays. On Windows, .git/objects/pack/* files may be briefly locked after git clone exits. Retries with read-only file handling. """ import stat import time def _on_rm_error(func, fpath, _exc_info): """Handle read-only files on Windows (e.g. .git/objects/pack/*.idx).""" try: os.chmod(fpath, stat.S_IWRITE) func(fpath) except OSError: pass for attempt in range(retries): try: shutil.rmtree(path, onerror=_on_rm_error) return except (PermissionError, OSError): if attempt < retries - 1: time.sleep(delay) shutil.rmtree(path, ignore_errors=True) # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture(scope="module") def workspace(): """Install ComfyUI (with Manager v4) and launch in background.""" ws = os.path.join(os.getcwd(), f"comfy-uv-{datetime.now().timestamp()}") install_flags = os.getenv("TEST_E2E_COMFY_INSTALL_FLAGS", "--cpu") comfy_url = os.getenv("TEST_E2E_COMFY_URL", "") url_flag = f"--url {comfy_url}" if comfy_url else "" proc = exec( f""" comfy --skip-prompt --workspace {ws} install {url_flag} {install_flags} comfy --skip-prompt set-default {ws} comfy --skip-prompt --no-enable-telemetry env """ ) assert proc.returncode == 0 # Override Manager if MANAGER_OVERRIDE is set (skip PyPI/Core PR cycle). # Accepts: "4.1b7" (PyPI) or "Comfy-Org/ComfyUI-Manager@branch" (git clone + uv) venv_dir = os.path.join(ws, ".venv") if sys.platform == "win32": venv_python = os.path.join(venv_dir, "Scripts", "python.exe") else: venv_python = os.path.join(venv_dir, "bin", "python") manager_override = os.getenv("MANAGER_OVERRIDE", "") if manager_override: if "@" in manager_override and "/" in manager_override: # Branch install: "Comfy-Org/ComfyUI-Manager@fix-branch" # Uses uv (not pip) because Manager repo has flat-layout incompatible with setuptools. repo_spec, branch = manager_override.rsplit("@", 1) clone_dir = os.path.join(ws, "_manager_override") if os.path.isdir(clone_dir): shutil.rmtree(clone_dir) proc = exec(f"git clone --branch {branch} --depth 1 https://github.com/{repo_spec}.git {clone_dir}") assert proc.returncode == 0, f"Manager clone failed:\n{proc.stderr}" proc = exec(f"uv pip install {clone_dir} --reinstall-package comfyui-manager --python {venv_dir}") assert proc.returncode == 0, f"Manager override install failed:\n{proc.stderr}" else: # PyPI version: "4.1b7" proc = exec( f"{venv_python} -m pip install comfyui-manager=={manager_override} --pre --force-reinstall --no-deps" ) assert proc.returncode == 0, f"Manager override failed:\n{proc.stderr}" # Populate Manager cache before any node operations (blocking fetch). proc = exec(f"comfy --workspace {ws} node update-cache") assert proc.returncode == 0, f"update-cache failed:\n{proc.stderr}" # NOTE: No 'comfy launch --background' here. These tests only exercise # cm_cli commands (node install/reinstall/update/fix/uv-sync) and don't # need a running ComfyUI server. Launching ComfyUI in background causes # Windows file lock issues: ComfyUI scans custom_nodes/, holds handles # on .git/objects/pack/*.idx, and prevents cleanup between tests. yield ws @pytest.fixture() def comfy_cli(workspace): return f"comfy --workspace {workspace}" @pytest.fixture(autouse=True) def _clean_test_packs(workspace): """Remove test node packs before and after each test.""" custom_nodes = os.path.join(workspace, "custom_nodes") def _remove(name): path = os.path.join(custom_nodes, name) if os.path.islink(path): os.unlink(path) elif os.path.isdir(path): _rmtree_retry(path) _remove(PACK_TEST1) _remove(PACK_TEST2) yield _remove(PACK_TEST1) _remove(PACK_TEST2) # --------------------------------------------------------------------------- # Normal installation with real packs # --------------------------------------------------------------------------- def test_real_packs_sequential_no_conflict(comfy_cli): """Sequential install of two real packs with --uv-compile — no conflicts.""" proc = exec(f"{comfy_cli} node install --uv-compile {PACK_IMPACT}") combined = proc.stdout + proc.stderr assert proc.returncode == 0 assert "Resolving dependencies for" in combined proc = exec(f"{comfy_cli} node install --uv-compile {PACK_INSPIRE}") combined = proc.stdout + proc.stderr assert proc.returncode == 0 assert "Resolving dependencies for" in combined assert "Conflicting packages" not in combined def test_real_packs_simultaneous_no_conflict(comfy_cli): """Simultaneous install of two real packs with --uv-compile — no conflicts.""" proc = exec(f"{comfy_cli} node install --uv-compile {PACK_IMPACT} {PACK_INSPIRE}") combined = proc.stdout + proc.stderr assert proc.returncode == 0 assert "Resolving dependencies for" in combined assert "Conflicting packages" not in combined # --------------------------------------------------------------------------- # Progressive conflict (real packs + conflict packs) # --------------------------------------------------------------------------- def test_progressive_conflict(comfy_cli): """Real packs installed → +conflict-pack-1 OK → +conflict-pack-2 CONFLICT.""" # Step 1: Install real packs — no conflict proc = exec(f"{comfy_cli} node install --uv-compile {PACK_IMPACT} {PACK_INSPIRE}") combined = proc.stdout + proc.stderr assert proc.returncode == 0 assert "Conflicting packages" not in combined # Step 2: Add first conflict test pack — still no conflict proc = exec(f"{comfy_cli} node install --uv-compile {REPO_TEST1}") combined = proc.stdout + proc.stderr assert proc.returncode == 0 assert "Conflicting packages" not in combined # Step 3: Add second conflict test pack — conflict between test packs proc = exec(f"{comfy_cli} node install --uv-compile {REPO_TEST2}") combined = proc.stdout + proc.stderr assert "Conflicting packages (by node pack):" in combined assert PACK_TEST1 in combined assert PACK_TEST2 in combined # --------------------------------------------------------------------------- # Reinstall / Update / Fix with --uv-compile # --------------------------------------------------------------------------- def test_node_reinstall_uv_compile(comfy_cli): """Reinstall with --uv-compile → resolution runs.""" setup = exec(f"{comfy_cli} node install {REPO_TEST1}") assert setup.returncode == 0, f"Setup install failed: {setup.stderr}" proc = exec(f"{comfy_cli} node reinstall --uv-compile {REPO_TEST1}") combined = proc.stdout + proc.stderr assert proc.returncode == 0, f"reinstall failed: {proc.stderr}" assert "Resolving dependencies for" in combined def test_node_update_uv_compile(comfy_cli): """Update with --uv-compile → resolution runs.""" setup = exec(f"{comfy_cli} node install {REPO_TEST1}") assert setup.returncode == 0, f"Setup install failed: {setup.stderr}" proc = exec(f"{comfy_cli} node update --uv-compile {REPO_TEST1}") combined = proc.stdout + proc.stderr assert proc.returncode == 0, f"update failed: {proc.stderr}" assert "Resolving dependencies for" in combined def test_node_fix_uv_compile(comfy_cli): """Fix with --uv-compile → resolution runs.""" setup = exec(f"{comfy_cli} node install {REPO_TEST1}") assert setup.returncode == 0, f"Setup install failed: {setup.stderr}" proc = exec(f"{comfy_cli} node fix --uv-compile {REPO_TEST1}") combined = proc.stdout + proc.stderr assert proc.returncode == 0, f"fix failed: {proc.stderr}" assert "Resolving dependencies for" in combined def test_node_restore_deps_uv_compile(comfy_cli): """restore-dependencies --uv-compile → resolution runs.""" setup = exec(f"{comfy_cli} node install {REPO_TEST1}") assert setup.returncode == 0, f"Setup install failed: {setup.stderr}" proc = exec(f"{comfy_cli} node restore-dependencies --uv-compile") combined = proc.stdout + proc.stderr assert proc.returncode == 0, f"restore-dependencies failed: {proc.stderr}" assert "Resolving dependencies for" in combined # --------------------------------------------------------------------------- # Standalone uv-sync # --------------------------------------------------------------------------- def test_node_uv_sync_standalone(comfy_cli): """Standalone comfy node uv-sync with installed pack.""" setup = exec(f"{comfy_cli} node install {REPO_TEST1}") assert setup.returncode == 0, f"Setup install failed: {setup.stderr}" proc = exec(f"{comfy_cli} node uv-sync") combined = proc.stdout + proc.stderr assert proc.returncode == 0 assert "Resolving dependencies for" in combined def test_node_uv_sync_standalone_conflict(comfy_cli): """Standalone uv-sync with conflicting packs → conflict attribution.""" setup1 = exec(f"{comfy_cli} node install {REPO_TEST1}") assert setup1.returncode == 0, f"Setup install test1 failed: {setup1.stderr}" setup2 = exec(f"{comfy_cli} node install {REPO_TEST2}") assert setup2.returncode == 0, f"Setup install test2 failed: {setup2.stderr}" proc = exec(f"{comfy_cli} node uv-sync") combined = proc.stdout + proc.stderr assert "Conflicting packages (by node pack):" in combined assert PACK_TEST1 in combined assert PACK_TEST2 in combined # --------------------------------------------------------------------------- # Config default # --------------------------------------------------------------------------- def test_uv_compile_config_default(comfy_cli): """Config default true → install without flag triggers resolution.""" proc = exec(f"{comfy_cli} manager uv-compile-default true") assert proc.returncode == 0 try: proc = exec(f"{comfy_cli} node install {REPO_TEST1}") combined = proc.stdout + proc.stderr assert proc.returncode == 0, f"install failed: {proc.stderr}" assert "Resolving dependencies for" in combined finally: exec(f"{comfy_cli} manager uv-compile-default false") def test_no_uv_compile_overrides_config(comfy_cli): """--no-uv-compile overrides config default.""" proc = exec(f"{comfy_cli} manager uv-compile-default true") assert proc.returncode == 0 try: proc = exec(f"{comfy_cli} node install --no-uv-compile {REPO_TEST1}") combined = proc.stdout + proc.stderr assert proc.returncode == 0, f"install failed: {proc.stderr}" assert "Resolving dependencies for" not in combined finally: exec(f"{comfy_cli} manager uv-compile-default false") # --------------------------------------------------------------------------- # Mutual exclusivity # --------------------------------------------------------------------------- def test_uv_compile_mutual_exclusivity(comfy_cli): """--uv-compile cannot be used with --fast-deps or --no-deps.""" # --uv-compile + --fast-deps proc = exec(f"{comfy_cli} node install --uv-compile --fast-deps {REPO_TEST1}") assert proc.returncode != 0 assert "Cannot use" in (proc.stdout + proc.stderr) # --uv-compile + --no-deps proc = exec(f"{comfy_cli} node install --uv-compile --no-deps {REPO_TEST1}") assert proc.returncode != 0 assert "Cannot use" in (proc.stdout + proc.stderr) ================================================ FILE: tests/e2e/workflow.json ================================================ { "3": { "inputs": { "seed": 156680208700286, "steps": 20, "cfg": 8, "sampler_name": "euler", "scheduler": "normal", "denoise": 1, "model": [ "4", 0 ], "positive": [ "6", 0 ], "negative": [ "7", 0 ], "latent_image": [ "5", 0 ] }, "class_type": "KSampler", "_meta": { "title": "KSampler" } }, "4": { "inputs": { "ckpt_name": "v1-5-pruned-emaonly.safetensors" }, "class_type": "CheckpointLoaderSimple", "_meta": { "title": "Load Checkpoint" } }, "5": { "inputs": { "width": 512, "height": 512, "batch_size": 1 }, "class_type": "EmptyLatentImage", "_meta": { "title": "Empty Latent Image" } }, "6": { "inputs": { "text": "beautiful scenery nature glass bottle landscape, , purple galaxy bottle,", "clip": [ "4", 1 ] }, "class_type": "CLIPTextEncode", "_meta": { "title": "CLIP Text Encode (Prompt)" } }, "7": { "inputs": { "text": "text, watermark", "clip": [ "4", 1 ] }, "class_type": "CLIPTextEncode", "_meta": { "title": "CLIP Text Encode (Prompt)" } }, "8": { "inputs": { "samples": [ "3", 0 ], "vae": [ "4", 2 ] }, "class_type": "VAEDecode", "_meta": { "title": "VAE Decode" } }, "9": { "inputs": { "filename_prefix": "ComfyUI", "images": [ "8", 0 ] }, "class_type": "SaveImage", "_meta": { "title": "Save Image" } } } ================================================ FILE: tests/test_file_utils_network.py ================================================ import json import pathlib from unittest.mock import Mock, patch import httpx import pytest import requests from comfy_cli.file_utils import ( DownloadException, _cleanup_partial, _friendly_network_error, _TransientHTTPStatusError, check_unauthorized, download_file, extract_package_as_zip, guess_status_code_reason, upload_file_to_signed_url, ) def test_guess_status_code_reason_401_with_json(): message = json.dumps({"message": "API token required"}).encode() result = guess_status_code_reason(401, message) assert "API token required" in result assert "Unauthorized download (401)" in result def test_guess_status_code_reason_401_without_json(): result = guess_status_code_reason(401, "not json") assert "Unauthorized download (401)" in result assert "manually log into a browser" in result def test_guess_status_code_reason_403(): result = guess_status_code_reason(403, "") assert "Forbidden url (403)" in result def test_guess_status_code_reason_404(): result = guess_status_code_reason(404, "") assert "not found on server (404)" in result def test_guess_status_code_reason_unknown(): result = guess_status_code_reason(500, "") assert "Unknown error occurred (status code: 500)" in result @patch("requests.get") def test_check_unauthorized_true(mock_get): mock_response = Mock() mock_response.status_code = 401 mock_get.return_value = mock_response assert check_unauthorized("http://example.com") is True @patch("requests.get") def test_check_unauthorized_false(mock_get): mock_response = Mock() mock_response.status_code = 200 mock_get.return_value = mock_response assert check_unauthorized("http://example.com") is False @patch("requests.get") def test_check_unauthorized_exception(mock_get): mock_get.side_effect = requests.RequestException() assert check_unauthorized("http://example.com") is False @patch("httpx.stream") def test_download_file_success(mock_stream, tmp_path): mock_response = Mock() mock_response.status_code = 200 mock_response.headers = {"Content-Length": "1024"} mock_response.iter_bytes.return_value = [b"test data"] mock_response.__enter__ = Mock(return_value=mock_response) mock_response.__exit__ = Mock(return_value=None) mock_stream.return_value = mock_response test_file = tmp_path / "test.txt" download_file("http://example.com", test_file) assert test_file.exists() assert test_file.read_bytes() == b"test data" @patch("httpx.stream") def test_download_file_success_without_content_length(mock_stream, tmp_path): """Download should succeed when Content-Length header is missing (e.g. chunked/gzip responses).""" mock_response = Mock() mock_response.status_code = 200 mock_response.headers = {} mock_response.iter_bytes.return_value = [b"chunk1", b"chunk2"] mock_response.__enter__ = Mock(return_value=mock_response) mock_response.__exit__ = Mock(return_value=None) mock_stream.return_value = mock_response test_file = tmp_path / "test.txt" download_file("http://example.com", test_file) assert test_file.exists() assert test_file.read_bytes() == b"chunk1chunk2" @patch("httpx.stream") def test_download_file_failure(mock_stream): mock_response = Mock() mock_response.status_code = 404 mock_response.read.return_value = "" mock_response.__enter__ = Mock(return_value=mock_response) mock_response.__exit__ = Mock(return_value=None) mock_stream.return_value = mock_response with pytest.raises(DownloadException) as exc_info: download_file("http://example.com", pathlib.Path("test.txt")) assert "Failed to download file" in str(exc_info.value) @patch("requests.put") def test_upload_file_success(mock_put, tmp_path): test_file = tmp_path / "test.zip" test_file.write_bytes(b"test data") mock_response = Mock() mock_response.status_code = 200 mock_put.return_value = mock_response upload_file_to_signed_url("http://example.com", str(test_file)) mock_put.assert_called_once() @patch("requests.put") def test_upload_file_failure(mock_put, tmp_path): test_file = tmp_path / "test.zip" test_file.write_bytes(b"test data") mock_response = Mock() mock_response.status_code = 500 mock_response.text = "Server error" mock_put.return_value = mock_response with pytest.raises(Exception) as exc_info: upload_file_to_signed_url("http://example.com", str(test_file)) assert "Upload failed" in str(exc_info.value) def test_extract_package_as_zip(tmp_path): # Create a test zip file import zipfile zip_path = tmp_path / "test.zip" extract_path = tmp_path / "extracted" with zipfile.ZipFile(zip_path, "w") as test_zip: test_zip.writestr("test.txt", "test content") extract_package_as_zip(zip_path, extract_path) assert (extract_path / "test.txt").exists() assert (extract_path / "test.txt").read_text() == "test content" def _make_ok_response(content=b"data", content_length=None): """Create a mock httpx response that succeeds.""" mock = Mock() mock.status_code = 200 mock.headers = {} if content_length is not None: mock.headers["Content-Length"] = str(content_length) mock.iter_bytes.return_value = [content] mock.__enter__ = Mock(return_value=mock) mock.__exit__ = Mock(return_value=None) return mock def _make_failing_iter(data=b"partial", exc=None): """Return a callable that creates a generator yielding *data* then raising *exc*.""" if exc is None: exc = httpx.ReadTimeout("read timed out") def factory(): yield data raise exc return factory def _make_status_response(status_code, body=b""): """Create a mock httpx response for a non-200 status.""" mock = Mock() mock.status_code = status_code mock.read.return_value = body mock.__enter__ = Mock(return_value=mock) mock.__exit__ = Mock(return_value=None) return mock class TestCleanupPartial: def test_removes_existing_file(self, tmp_path): f = tmp_path / "partial.bin" f.write_bytes(b"partial") _cleanup_partial(f) assert not f.exists() def test_noop_when_file_missing(self, tmp_path): f = tmp_path / "nonexistent.bin" _cleanup_partial(f) # should not raise assert not f.exists() class TestFriendlyNetworkError: def test_read_timeout(self): msg = _friendly_network_error(httpx.ReadTimeout("timed out")) assert "read timeout" in msg def test_connect_timeout(self): msg = _friendly_network_error(httpx.ConnectTimeout("timed out")) assert "connect timeout" in msg def test_generic_timeout(self): msg = _friendly_network_error(httpx.PoolTimeout("pool full")) assert "PoolTimeout" in msg def test_network_error(self): msg = _friendly_network_error(httpx.ReadError("connection reset")) assert "ReadError" in msg def test_protocol_error(self): msg = _friendly_network_error(httpx.RemoteProtocolError("peer closed")) assert "protocol error" in msg assert "RemoteProtocolError" in msg def test_proxy_error(self): msg = _friendly_network_error(httpx.ProxyError("bad proxy")) assert "proxy error" in msg assert "ProxyError" in msg def test_other_exception(self): msg = _friendly_network_error(RuntimeError("boom")) assert msg == "boom" def test_transient_http_status_known_code_includes_phrase(self): # HTTP 503 -> "Service Unavailable" (from stdlib http.HTTPStatus). msg = _friendly_network_error(_TransientHTTPStatusError(503, "some reason from body")) assert "HTTP 503" in msg assert "Service Unavailable" in msg def test_transient_http_status_500_includes_phrase(self): msg = _friendly_network_error(_TransientHTTPStatusError(500, "")) assert "HTTP 500" in msg assert "Internal Server Error" in msg def test_transient_http_status_unknown_code_falls_back(self): # 599 is not a standard HTTPStatus; fall back to just the numeric code. msg = _friendly_network_error(_TransientHTTPStatusError(599, "weird")) assert "HTTP 599" in msg # No crash, no stdlib phrase embedded (since there isn't one). def test_invalid_url(self): msg = _friendly_network_error(httpx.InvalidURL("Request URL is missing a scheme")) assert "invalid URL" in msg assert "missing a scheme" in msg class TestDownloadTimeout: @patch("httpx.stream") def test_uses_generous_timeout(self, mock_stream, tmp_path): """httpx.stream is called with a 300s read timeout.""" mock_stream.return_value = _make_ok_response() download_file("http://example.com/f.bin", tmp_path / "f.bin") _, kwargs = mock_stream.call_args timeout = kwargs["timeout"] assert isinstance(timeout, httpx.Timeout) assert timeout.read == 300.0 assert timeout.connect == 10.0 class TestDownloadRetry: @patch("comfy_cli.file_utils.time.sleep") @patch("httpx.stream") def test_succeeds_after_transient_timeout(self, mock_stream, mock_sleep, tmp_path): """Download retries on ReadTimeout and eventually succeeds.""" mock_stream.side_effect = [ httpx.ReadTimeout("timeout"), _make_ok_response(content=b"full data"), ] dest = tmp_path / "model.bin" download_file("http://example.com/model.bin", dest) assert dest.read_bytes() == b"full data" assert mock_stream.call_count == 2 mock_sleep.assert_called_once_with(2) # backoff: 2 * (0+1) @patch("comfy_cli.file_utils.time.sleep") @patch("httpx.stream") def test_succeeds_after_network_error(self, mock_stream, mock_sleep, tmp_path): """Download retries on NetworkError (e.g. connection reset).""" mock_stream.side_effect = [ httpx.ReadError("connection reset"), httpx.ConnectError("refused"), _make_ok_response(content=b"ok"), ] dest = tmp_path / "model.bin" download_file("http://example.com/model.bin", dest) assert dest.read_bytes() == b"ok" assert mock_stream.call_count == 3 @patch("comfy_cli.file_utils.time.sleep") @patch("httpx.stream") def test_succeeds_after_protocol_error(self, mock_stream, mock_sleep, tmp_path): """Download retries on RemoteProtocolError (e.g. peer closed connection mid-stream).""" mock_stream.side_effect = [ httpx.RemoteProtocolError("peer closed connection"), _make_ok_response(content=b"ok"), ] dest = tmp_path / "model.bin" download_file("http://example.com/model.bin", dest) assert dest.read_bytes() == b"ok" assert mock_stream.call_count == 2 @patch("comfy_cli.file_utils.time.sleep") @patch("httpx.stream") def test_succeeds_after_proxy_error(self, mock_stream, mock_sleep, tmp_path): """Download retries on ProxyError.""" mock_stream.side_effect = [ httpx.ProxyError("bad gateway"), _make_ok_response(content=b"ok"), ] dest = tmp_path / "model.bin" download_file("http://example.com/model.bin", dest) assert dest.read_bytes() == b"ok" assert mock_stream.call_count == 2 @patch("comfy_cli.file_utils.time.sleep") @patch("httpx.stream") def test_all_retries_exhausted_read_timeout(self, mock_stream, mock_sleep, tmp_path): """DownloadException after all retries fail with ReadTimeout.""" mock_stream.side_effect = httpx.ReadTimeout("timeout") dest = tmp_path / "model.bin" with pytest.raises(DownloadException, match="Download failed after 3 attempts") as exc_info: download_file("http://example.com/model.bin", dest) assert "read timeout" in str(exc_info.value) assert "try again" in str(exc_info.value).lower() assert mock_stream.call_count == 3 assert not dest.exists() @patch("comfy_cli.file_utils.time.sleep") @patch("httpx.stream") def test_all_retries_exhausted_connect_error(self, mock_stream, mock_sleep, tmp_path): """DownloadException after all retries fail with ConnectError.""" mock_stream.side_effect = httpx.ConnectError("refused") dest = tmp_path / "model.bin" with pytest.raises(DownloadException, match="Download failed after 3 attempts") as exc_info: download_file("http://example.com/model.bin", dest) assert "network error" in str(exc_info.value).lower() assert mock_stream.call_count == 3 @patch("comfy_cli.file_utils.time.sleep") @patch("httpx.stream") def test_http_error_not_retried(self, mock_stream, mock_sleep, tmp_path): """Non-200 HTTP status raises DownloadException immediately, no retry.""" resp = Mock() resp.status_code = 404 resp.read.return_value = "" resp.__enter__ = Mock(return_value=resp) resp.__exit__ = Mock(return_value=None) mock_stream.return_value = resp with pytest.raises(DownloadException, match="Failed to download file"): download_file("http://example.com/model.bin", tmp_path / "model.bin") assert mock_stream.call_count == 1 mock_sleep.assert_not_called() @patch("comfy_cli.file_utils.time.sleep") @patch("httpx.stream") def test_backoff_increases_with_attempts(self, mock_stream, mock_sleep, tmp_path): """Retry backoff is 2s, 4s for attempts 1, 2.""" mock_stream.side_effect = httpx.ReadTimeout("timeout") with pytest.raises(DownloadException): download_file("http://example.com/model.bin", tmp_path / "model.bin") # Two sleeps: after attempt 0 and attempt 1 (not after the last attempt) assert mock_sleep.call_count == 2 mock_sleep.assert_any_call(2) # 2 * (0+1) mock_sleep.assert_any_call(4) # 2 * (1+1) @patch("comfy_cli.file_utils.time.sleep") @patch("httpx.stream") def test_original_exception_chained(self, mock_stream, mock_sleep, tmp_path): """The original httpx exception is chained as __cause__.""" mock_stream.side_effect = httpx.ReadTimeout("the real cause") with pytest.raises(DownloadException) as exc_info: download_file("http://example.com/model.bin", tmp_path / "model.bin") assert isinstance(exc_info.value.__cause__, httpx.ReadTimeout) class TestDownloadPartialCleanup: @patch("comfy_cli.file_utils.time.sleep") @patch("httpx.stream") def test_partial_file_removed_after_midstream_timeout(self, mock_stream, mock_sleep, tmp_path): """A file partially written before a timeout is cleaned up.""" resp = Mock() resp.status_code = 200 resp.headers = {} resp.iter_bytes = Mock(side_effect=_make_failing_iter(b"partial data")) resp.__enter__ = Mock(return_value=resp) resp.__exit__ = Mock(return_value=None) mock_stream.return_value = resp dest = tmp_path / "model.bin" with pytest.raises(DownloadException): download_file("http://example.com/model.bin", dest) assert not dest.exists() @patch("comfy_cli.file_utils.time.sleep") @patch("httpx.stream") def test_partial_file_removed_between_retries(self, mock_stream, mock_sleep, tmp_path): """Partial file from a failed attempt doesn't persist into the next attempt.""" # First attempt: write partial data then timeout fail_resp = Mock() fail_resp.status_code = 200 fail_resp.headers = {} fail_resp.iter_bytes = Mock(side_effect=_make_failing_iter(b"stale")) fail_resp.__enter__ = Mock(return_value=fail_resp) fail_resp.__exit__ = Mock(return_value=None) # Second attempt: success ok_resp = _make_ok_response(content=b"fresh data") mock_stream.side_effect = [fail_resp, ok_resp] dest = tmp_path / "model.bin" download_file("http://example.com/model.bin", dest) # File should contain only data from the successful attempt assert dest.read_bytes() == b"fresh data" @patch("comfy_cli.file_utils.time.sleep") @patch("httpx.stream") def test_preexisting_file_preserved_on_http_error(self, mock_stream, mock_sleep, tmp_path): """A pre-existing file at the destination is NOT touched when the server returns an HTTP error. HTTP errors are raised before _download_file_httpx opens the output file, so there is no partial download to clean up. The helper must not destroy unrelated pre-existing data. """ resp = Mock() resp.status_code = 403 resp.read.return_value = "" resp.__enter__ = Mock(return_value=resp) resp.__exit__ = Mock(return_value=None) mock_stream.return_value = resp dest = tmp_path / "model.bin" dest.write_bytes(b"IMPORTANT pre-existing data") with pytest.raises(DownloadException): download_file("http://example.com/model.bin", dest) assert dest.exists() assert dest.read_bytes() == b"IMPORTANT pre-existing data" @patch("comfy_cli.file_utils.time.sleep") @patch("httpx.stream") def test_preexisting_file_preserved_on_connect_error(self, mock_stream, mock_sleep, tmp_path): """A pre-existing file is NOT deleted when all retries fail with a pre-open transient error. ConnectError/ConnectTimeout are raised at httpx.stream() entry, before the output file is opened. Cleanup must not run in that case, or it would wipe out an unrelated pre-existing file at the destination path. """ mock_stream.side_effect = httpx.ConnectError("refused") dest = tmp_path / "model.bin" dest.write_bytes(b"IMPORTANT pre-existing data") with pytest.raises(DownloadException, match="Download failed after 3 attempts"): download_file("http://example.com/model.bin", dest) assert dest.exists() assert dest.read_bytes() == b"IMPORTANT pre-existing data" @patch("comfy_cli.file_utils.ui.prompt_confirm_action", return_value=True) @patch("httpx.stream") def test_preexisting_file_preserved_on_interrupt_before_open(self, mock_stream, mock_prompt, tmp_path): """KeyboardInterrupt during connection setup (before output file is opened) must not prompt the user or delete an unrelated pre-existing file. """ mock_stream.side_effect = KeyboardInterrupt() dest = tmp_path / "model.bin" dest.write_bytes(b"IMPORTANT pre-existing data") with pytest.raises(KeyboardInterrupt): download_file("http://example.com/model.bin", dest) # Prompt should NOT have been shown — the file was never opened this attempt. mock_prompt.assert_not_called() assert dest.exists() assert dest.read_bytes() == b"IMPORTANT pre-existing data" @patch("comfy_cli.file_utils.ui.prompt_confirm_action", return_value=True) @patch("httpx.stream") def test_keyboard_interrupt_cleans_up_when_user_confirms(self, mock_stream, mock_prompt, tmp_path): """On KeyboardInterrupt the user is prompted; confirming removes the partial file and re-raises.""" resp = Mock() resp.status_code = 200 resp.headers = {} resp.iter_bytes = Mock(side_effect=_make_failing_iter(b"partial", KeyboardInterrupt())) resp.__enter__ = Mock(return_value=resp) resp.__exit__ = Mock(return_value=None) mock_stream.return_value = resp dest = tmp_path / "model.bin" with pytest.raises(KeyboardInterrupt): download_file("http://example.com/model.bin", dest) mock_prompt.assert_called_once() assert not dest.exists() @patch("comfy_cli.file_utils.ui.prompt_confirm_action", return_value=False) @patch("httpx.stream") def test_keyboard_interrupt_keeps_partial_when_user_declines(self, mock_stream, mock_prompt, tmp_path): """On KeyboardInterrupt the user is prompted; declining keeps the partial file on disk.""" resp = Mock() resp.status_code = 200 resp.headers = {} resp.iter_bytes = Mock(side_effect=_make_failing_iter(b"partial data", KeyboardInterrupt())) resp.__enter__ = Mock(return_value=resp) resp.__exit__ = Mock(return_value=None) mock_stream.return_value = resp dest = tmp_path / "model.bin" with pytest.raises(KeyboardInterrupt): download_file("http://example.com/model.bin", dest) mock_prompt.assert_called_once() assert dest.exists() assert dest.read_bytes() == b"partial data" class TestDownloadHTTPStatusRetry: """Retry behavior for transient HTTP status codes (5xx, 429, 408).""" @patch("comfy_cli.file_utils.time.sleep") @patch("httpx.stream") def test_500_retried_and_succeeds(self, mock_stream, mock_sleep, tmp_path): """Download retries on HTTP 500 and succeeds on the next attempt.""" mock_stream.side_effect = [ _make_status_response(500), _make_ok_response(content=b"ok"), ] dest = tmp_path / "model.bin" download_file("http://example.com/model.bin", dest) assert dest.read_bytes() == b"ok" assert mock_stream.call_count == 2 mock_sleep.assert_called_once_with(2) @patch("comfy_cli.file_utils.time.sleep") @patch("httpx.stream") def test_502_retried(self, mock_stream, mock_sleep, tmp_path): mock_stream.side_effect = [ _make_status_response(502), _make_ok_response(content=b"ok"), ] dest = tmp_path / "model.bin" download_file("http://example.com/model.bin", dest) assert mock_stream.call_count == 2 @patch("comfy_cli.file_utils.time.sleep") @patch("httpx.stream") def test_503_retried(self, mock_stream, mock_sleep, tmp_path): mock_stream.side_effect = [ _make_status_response(503), _make_ok_response(content=b"ok"), ] dest = tmp_path / "model.bin" download_file("http://example.com/model.bin", dest) assert mock_stream.call_count == 2 @patch("comfy_cli.file_utils.time.sleep") @patch("httpx.stream") def test_504_retried(self, mock_stream, mock_sleep, tmp_path): mock_stream.side_effect = [ _make_status_response(504), _make_ok_response(content=b"ok"), ] dest = tmp_path / "model.bin" download_file("http://example.com/model.bin", dest) assert mock_stream.call_count == 2 @patch("comfy_cli.file_utils.time.sleep") @patch("httpx.stream") def test_429_retried(self, mock_stream, mock_sleep, tmp_path): mock_stream.side_effect = [ _make_status_response(429), _make_ok_response(content=b"ok"), ] dest = tmp_path / "model.bin" download_file("http://example.com/model.bin", dest) assert mock_stream.call_count == 2 @patch("comfy_cli.file_utils.time.sleep") @patch("httpx.stream") def test_408_retried(self, mock_stream, mock_sleep, tmp_path): mock_stream.side_effect = [ _make_status_response(408), _make_ok_response(content=b"ok"), ] dest = tmp_path / "model.bin" download_file("http://example.com/model.bin", dest) assert mock_stream.call_count == 2 @patch("comfy_cli.file_utils.time.sleep") @patch("httpx.stream") def test_all_retries_exhausted_on_500(self, mock_stream, mock_sleep, tmp_path): """After 3 failed attempts on 500, a DownloadException is raised with a friendly message.""" mock_stream.side_effect = [ _make_status_response(500), _make_status_response(500), _make_status_response(500), ] dest = tmp_path / "model.bin" with pytest.raises(DownloadException, match="Download failed after 3 attempts") as exc_info: download_file("http://example.com/model.bin", dest) assert "HTTP 500" in str(exc_info.value) # The stdlib HTTPStatus phrase is surfaced so the user knows what 500 means. assert "Internal Server Error" in str(exc_info.value) assert mock_stream.call_count == 3 # The last transient HTTP error must be chained as __cause__ for debuggability. assert isinstance(exc_info.value.__cause__, _TransientHTTPStatusError) assert exc_info.value.__cause__.status_code == 500 @patch("comfy_cli.file_utils.time.sleep") @patch("httpx.stream") def test_retry_body_read_timeout_still_retries(self, mock_stream, mock_sleep, tmp_path): """If reading the 500 response body itself times out, we still retry the request.""" fail_resp = Mock() fail_resp.status_code = 500 fail_resp.read.side_effect = httpx.ReadTimeout("body read timed out") fail_resp.__enter__ = Mock(return_value=fail_resp) fail_resp.__exit__ = Mock(return_value=None) mock_stream.side_effect = [fail_resp, _make_ok_response(content=b"ok")] dest = tmp_path / "model.bin" download_file("http://example.com/model.bin", dest) assert dest.read_bytes() == b"ok" assert mock_stream.call_count == 2 @patch("comfy_cli.file_utils.time.sleep") @patch("httpx.stream") def test_mixed_transient_errors_eventually_succeed(self, mock_stream, mock_sleep, tmp_path): """Retries work across a mix of network-level and HTTP-status errors.""" mock_stream.side_effect = [ _make_status_response(503), httpx.ReadTimeout("timeout"), _make_ok_response(content=b"finally"), ] dest = tmp_path / "model.bin" download_file("http://example.com/model.bin", dest) assert dest.read_bytes() == b"finally" assert mock_stream.call_count == 3 @patch("comfy_cli.file_utils.time.sleep") @patch("httpx.stream") def test_404_not_retried(self, mock_stream, mock_sleep, tmp_path): """404 fails fast without retry.""" mock_stream.return_value = _make_status_response(404) with pytest.raises(DownloadException, match="Failed to download file"): download_file("http://example.com/model.bin", tmp_path / "model.bin") assert mock_stream.call_count == 1 mock_sleep.assert_not_called() @patch("comfy_cli.file_utils.time.sleep") @patch("httpx.stream") def test_401_not_retried(self, mock_stream, mock_sleep, tmp_path): """401 fails fast without retry.""" mock_stream.return_value = _make_status_response(401) with pytest.raises(DownloadException, match="Failed to download file"): download_file("http://example.com/model.bin", tmp_path / "model.bin") assert mock_stream.call_count == 1 mock_sleep.assert_not_called() @patch("comfy_cli.file_utils.time.sleep") @patch("httpx.stream") def test_403_not_retried(self, mock_stream, mock_sleep, tmp_path): """403 fails fast without retry.""" mock_stream.return_value = _make_status_response(403) with pytest.raises(DownloadException, match="Failed to download file"): download_file("http://example.com/model.bin", tmp_path / "model.bin") assert mock_stream.call_count == 1 mock_sleep.assert_not_called() @patch("comfy_cli.file_utils.time.sleep") @patch("httpx.stream") def test_preexisting_file_preserved_on_http_status_retry_exhaust(self, mock_stream, mock_sleep, tmp_path): """A pre-existing file at the destination is NOT deleted when all retries fail on HTTP 500. The retriable HTTP status is raised before _download_file_httpx opens the output file. """ mock_stream.side_effect = [ _make_status_response(500), _make_status_response(500), _make_status_response(500), ] dest = tmp_path / "model.bin" dest.write_bytes(b"IMPORTANT pre-existing data") with pytest.raises(DownloadException, match="Download failed after 3 attempts"): download_file("http://example.com/model.bin", dest) assert dest.exists() assert dest.read_bytes() == b"IMPORTANT pre-existing data" class TestDownloadNonRetriableHTTPError: """Non-retriable httpx errors (UnsupportedProtocol, TooManyRedirects, etc.) are wrapped as DownloadException so callers only need to handle one error type and users don't see a raw Python traceback.""" @patch("comfy_cli.file_utils.time.sleep") @patch("httpx.stream") def test_unsupported_protocol_wrapped(self, mock_stream, mock_sleep, tmp_path): mock_stream.side_effect = httpx.UnsupportedProtocol("Request URL has an unsupported protocol 'ftp://'") with pytest.raises(DownloadException, match="Download failed") as exc_info: download_file("ftp://example.com/model.bin", tmp_path / "model.bin") assert isinstance(exc_info.value.__cause__, httpx.UnsupportedProtocol) assert mock_stream.call_count == 1 mock_sleep.assert_not_called() @patch("comfy_cli.file_utils.time.sleep") @patch("httpx.stream") def test_too_many_redirects_wrapped(self, mock_stream, mock_sleep, tmp_path): mock_stream.side_effect = httpx.TooManyRedirects("Exceeded maximum allowed redirects") with pytest.raises(DownloadException, match="Download failed") as exc_info: download_file("http://example.com/model.bin", tmp_path / "model.bin") assert isinstance(exc_info.value.__cause__, httpx.TooManyRedirects) assert mock_stream.call_count == 1 mock_sleep.assert_not_called() @patch("comfy_cli.file_utils.time.sleep") @patch("httpx.stream") def test_decoding_error_wrapped(self, mock_stream, mock_sleep, tmp_path): mock_stream.side_effect = httpx.DecodingError("Invalid compressed data") with pytest.raises(DownloadException, match="Download failed") as exc_info: download_file("http://example.com/model.bin", tmp_path / "model.bin") assert isinstance(exc_info.value.__cause__, httpx.DecodingError) assert mock_stream.call_count == 1 mock_sleep.assert_not_called() @patch("comfy_cli.file_utils.time.sleep") @patch("httpx.stream") def test_invalid_url_wrapped(self, mock_stream, mock_sleep, tmp_path): """httpx.InvalidURL does NOT subclass httpx.HTTPError — it must still be wrapped as DownloadException so a malformed URL doesn't leak as a Typer traceback.""" mock_stream.side_effect = httpx.InvalidURL("Request URL is missing a scheme") with pytest.raises(DownloadException, match="Download failed") as exc_info: download_file("no-scheme-url", tmp_path / "model.bin") assert isinstance(exc_info.value.__cause__, httpx.InvalidURL) assert "invalid URL" in str(exc_info.value) assert mock_stream.call_count == 1 mock_sleep.assert_not_called() @patch("httpx.stream") def test_invalid_url_preserves_preexisting_file(self, mock_stream, tmp_path): """InvalidURL is raised before the output file is opened — any pre-existing file at the destination path must be left intact.""" mock_stream.side_effect = httpx.InvalidURL("bad") dest = tmp_path / "model.bin" dest.write_bytes(b"IMPORTANT pre-existing data") with pytest.raises(DownloadException): download_file("not-a-url", dest) assert dest.exists() assert dest.read_bytes() == b"IMPORTANT pre-existing data" @patch("httpx.stream") def test_preexisting_file_preserved_on_non_retriable_error(self, mock_stream, tmp_path): """A non-retriable httpx error before the output file is opened must not delete an unrelated pre-existing file at the destination path.""" mock_stream.side_effect = httpx.UnsupportedProtocol("nope") dest = tmp_path / "model.bin" dest.write_bytes(b"IMPORTANT pre-existing data") with pytest.raises(DownloadException): download_file("ftp://example.com/model.bin", dest) assert dest.exists() assert dest.read_bytes() == b"IMPORTANT pre-existing data" @patch("httpx.stream") def test_partial_file_cleaned_up_on_mid_stream_non_retriable(self, mock_stream, tmp_path): """If a non-retriable error is raised AFTER the output file is opened (mid-stream), the partial file is cleaned up.""" resp = Mock() resp.status_code = 200 resp.headers = {"Content-Length": "100"} resp.iter_bytes = Mock(side_effect=_make_failing_iter(b"partial", httpx.DecodingError("bad"))) resp.__enter__ = Mock(return_value=resp) resp.__exit__ = Mock(return_value=None) mock_stream.return_value = resp dest = tmp_path / "model.bin" with pytest.raises(DownloadException): download_file("http://example.com/model.bin", dest) assert not dest.exists() ================================================ FILE: tests/uv/mock_comfy/custom_nodes/x/pyproject.toml ================================================ ================================================ FILE: tests/uv/mock_comfy/custom_nodes/x/requirements.txt ================================================ ================================================ FILE: tests/uv/mock_comfy/custom_nodes/x/setup.cfg ================================================ ================================================ FILE: tests/uv/mock_comfy/custom_nodes/x/setup.py ================================================ ================================================ FILE: tests/uv/mock_comfy/custom_nodes/y/setup.cfg ================================================ ================================================ FILE: tests/uv/mock_comfy/custom_nodes/y/setup.py ================================================ ================================================ FILE: tests/uv/mock_comfy/custom_nodes/z/setup.py ================================================ ================================================ FILE: tests/uv/mock_comfy/pyproject.toml ================================================ ================================================ FILE: tests/uv/mock_comfy/setup.cfg ================================================ ================================================ FILE: tests/uv/mock_comfy/setup.py ================================================ ================================================ FILE: tests/uv/mock_requirements/core_reqs.txt ================================================ tqdm==4.66.4 ================================================ FILE: tests/uv/mock_requirements/x_reqs.txt ================================================ numpy>=2.0.0 sympy<=1.10.1 tqdm==1.0 ================================================ FILE: tests/uv/mock_requirements/y_reqs.txt ================================================ mpmath==1.3.0 numpy<=2.0.2 sympy>=1.13.0 tqdm==2.0.0 ================================================ FILE: tests/uv/test_torch_backend_compile.py ================================================ """Integration tests for torch backend compilation. These tests do real uv pip compile with a torch requirement and verify the compiled output contains the correct torch variant for each backend. Requires network access. Gated behind TEST_TORCH_BACKEND=true. Platform constraints (PyTorch wheel availability): - NVIDIA (cu126): Linux, Windows - AMD (rocm6.1): Linux only - CPU: Linux, Windows, macOS - Default PyPI (mac path): all platforms """ import os import shutil import sys from pathlib import Path import pytest from comfy_cli.constants import GPU_OPTION from comfy_cli.uv import DependencyCompiler pytestmark = pytest.mark.skipif( os.environ.get("TEST_TORCH_BACKEND") != "true", reason="Set TEST_TORCH_BACKEND=true to run torch backend integration tests", ) _here = Path(__file__).parent.resolve() _temp = _here.parent / "temp" / "test_torch_backend" @pytest.fixture(autouse=True) def _setup_temp(): shutil.rmtree(_temp, ignore_errors=True) _temp.mkdir(exist_ok=True, parents=True) (_temp / "reqs.txt").write_text("torch\n") def _compile_for(gpu): dc = DependencyCompiler( cwd=_temp, gpu=gpu, outDir=_temp, reqFilesCore=[_temp / "reqs.txt"], reqFilesExt=[], ) dc.make_override() dc.compile_core_plus_ext() return dc.out.read_text() @pytest.mark.skipif(sys.platform == "darwin", reason="No CUDA wheels for macOS") def test_compile_nvidia(): content = _compile_for(GPU_OPTION.NVIDIA) assert "+cu126" in content assert "download.pytorch.org/whl/cu126" in content assert "--extra-index-url" not in content @pytest.mark.skipif(sys.platform != "linux", reason="ROCm wheels are Linux-only") def test_compile_amd(): content = _compile_for(GPU_OPTION.AMD) assert "+rocm" in content assert "download.pytorch.org/whl/rocm" in content assert "--extra-index-url" not in content @pytest.mark.skipif(sys.platform == "darwin", reason="No +cpu variant wheels for macOS") def test_compile_cpu(): content = _compile_for(GPU_OPTION.CPU) assert "+cpu" in content assert "download.pytorch.org/whl/cpu" in content assert "--extra-index-url" not in content # must not pull in nvidia CUDA libraries assert "nvidia-" not in content @pytest.mark.skipif(sys.platform != "linux", reason="CUDA toolkit extras are Linux-only") def test_compile_nvidia_cu130_preserves_cuda_runtime(): """Regression test for #412: cu130 compile must include CUDA runtime packages. torch >= 2.11 depends on cuda-toolkit[cublas,cudart,...] for CUDA runtime libs. make_override() must not strip these extras, or nvidia-cuda-runtime and nvidia-cuda-nvrtc will be missing from the final compiled output, causing 'libcudart.so: cannot open shared object file' at import time. """ dc = DependencyCompiler( cwd=_temp, gpu=GPU_OPTION.NVIDIA, outDir=_temp, reqFilesCore=[_temp / "reqs.txt"], reqFilesExt=[], cuda_version="13.0", ) dc.compile_deps() content = dc.out.read_text() assert "+cu130" in content, "Expected torch+cu130 in compiled output" # These provide libcudart.so and libnvrtc.so — the exact libraries # reported missing in issue #412 assert "nvidia-cuda-runtime==" in content, ( "nvidia-cuda-runtime missing from compiled output — " "cuda-toolkit extras were likely stripped by the override. " "See: https://github.com/Comfy-Org/comfy-cli/issues/412" ) assert "nvidia-cuda-nvrtc==" in content, ( "nvidia-cuda-nvrtc missing from compiled output — " "cuda-toolkit extras were likely stripped by the override. " "See: https://github.com/Comfy-Org/comfy-cli/issues/412" ) def test_compile_mac(): content = _compile_for(GPU_OPTION.MAC_M_SERIES) assert "torch==" in content # default PyPI torch — no GPU-specific local version suffix assert "+cu" not in content assert "+rocm" not in content assert "+cpu" not in content assert "--extra-index-url" not in content ================================================ FILE: tests/uv/test_uv.py ================================================ import shutil import subprocess from pathlib import Path from unittest.mock import patch import pytest from comfy_cli import ui from comfy_cli.constants import GPU_OPTION from comfy_cli.uv import DependencyCompiler, _check_call, parse_req_file hereDir = Path(__file__).parent.resolve() mockComfyDir = hereDir / "mock_comfy" mockReqsDir = hereDir / "mock_requirements" # set up a temp dir to write files to testsDir = hereDir.parent.resolve() temp = testsDir / "temp" / "test_uv" shutil.rmtree(temp, ignore_errors=True) temp.mkdir(exist_ok=True, parents=True) @pytest.fixture def mock_prompt_select(monkeypatch): mockChoices = ["==1.13.0", "==2.0.0"] def _mock_prompt_select(*args, **kwargs): return mockChoices.pop(0) monkeypatch.setattr(ui, "prompt_select", _mock_prompt_select) def test_find_req_files(): mockNodesDir = mockComfyDir / "custom_nodes" knownReqFilesCore = [mockComfyDir / "pyproject.toml"] knownReqFilesExt = sorted( [ mockNodesDir / "x" / "requirements.txt", mockNodesDir / "y" / "setup.cfg", mockNodesDir / "z" / "setup.py", ] ) depComp = DependencyCompiler(cwd=mockComfyDir) testReqFilesCore = depComp.reqFilesCore testReqFilesExt = sorted(depComp.reqFilesExt) assert knownReqFilesCore == testReqFilesCore assert knownReqFilesExt == testReqFilesExt def test_compile(mock_prompt_select): depComp = DependencyCompiler( cwd=temp, gpu=GPU_OPTION.AMD, outDir=temp, reqFilesCore=[mockReqsDir / "core_reqs.txt"], reqFilesExt=[mockReqsDir / "x_reqs.txt", mockReqsDir / "y_reqs.txt"], ) depComp.make_override() depComp.compile_core_plus_ext() with open(mockReqsDir / "requirements.compiled") as known, open(temp / "requirements.compiled") as test: # compare all non-commented lines in generated file vs reference file knownLines, testLines = [ [line for line in known.readlines() if not line.strip().startswith("#")], [line for line in test.readlines() if not line.strip().startswith("#")], ] optionalPrefixes = ("colorama==",) def _filter_optional(lines: list[str]) -> list[str]: # drop platform-specific extras (Windows pulls in colorama via tqdm) return [line for line in lines if not any(line.strip().startswith(prefix) for prefix in optionalPrefixes)] knownLines, testLines = [_filter_optional(lines) for lines in (knownLines, testLines)] assert knownLines == testLines def test_torch_backend_nvidia(): depComp = DependencyCompiler(cwd=temp, gpu=GPU_OPTION.NVIDIA, outDir=temp, reqFilesCore=[], reqFilesExt=[]) assert depComp.torchBackend == "cu126" assert depComp.gpuUrl == DependencyCompiler.nvidiaPytorchUrl def test_torch_backend_amd(): depComp = DependencyCompiler(cwd=temp, gpu=GPU_OPTION.AMD, outDir=temp, reqFilesCore=[], reqFilesExt=[]) assert depComp.torchBackend == "rocm6.3" assert depComp.gpuUrl == DependencyCompiler.rocmPytorchUrl def test_torch_backend_cpu(): depComp = DependencyCompiler(cwd=temp, gpu=GPU_OPTION.CPU, outDir=temp, reqFilesCore=[], reqFilesExt=[]) assert depComp.torchBackend == "cpu" assert depComp.gpuUrl == DependencyCompiler.cpuPytorchUrl def test_torch_backend_none(): with patch.object(DependencyCompiler, "Resolve_Gpu", return_value=None): depComp = DependencyCompiler(cwd=temp, gpu=None, outDir=temp, reqFilesCore=[], reqFilesExt=[]) assert depComp.torchBackend is None assert depComp.gpuUrl is None def test_compile_passes_torch_backend(): """Verify that Compile() includes --torch-backend in the command when provided.""" with patch("comfy_cli.uv._run") as mock_run: mock_run.return_value = type("R", (), {"stdout": "", "stderr": "", "returncode": 0})() DependencyCompiler.Compile( cwd=temp, reqFiles=[mockReqsDir / "core_reqs.txt"], torch_backend="cu126", ) cmd = mock_run.call_args[0][0] idx = cmd.index("--torch-backend") assert cmd[idx + 1] == "cu126" def test_compile_omits_torch_backend_when_none(): """Verify that Compile() does not include --torch-backend when torch_backend is None.""" with patch("comfy_cli.uv._run") as mock_run: mock_run.return_value = type("R", (), {"stdout": "", "stderr": "", "returncode": 0})() DependencyCompiler.Compile( cwd=temp, reqFiles=[mockReqsDir / "core_reqs.txt"], torch_backend=None, ) cmd = mock_run.call_args[0][0] assert "--torch-backend" not in cmd def test_compiled_output_has_no_extra_index_url(mock_prompt_select): """The compiled output must not contain --extra-index-url (torch-backend handles routing).""" depComp = DependencyCompiler( cwd=temp, gpu=GPU_OPTION.AMD, outDir=temp, reqFilesCore=[mockReqsDir / "core_reqs.txt"], reqFilesExt=[mockReqsDir / "x_reqs.txt", mockReqsDir / "y_reqs.txt"], ) depComp.make_override() depComp.compile_core_plus_ext() content = depComp.out.read_text() assert "--extra-index-url" not in content def test_override_file_has_no_extra_index_url(): depComp = DependencyCompiler( cwd=temp, gpu=GPU_OPTION.AMD, outDir=temp, reqFilesCore=[mockReqsDir / "core_reqs.txt"], reqFilesExt=[], ) depComp.make_override() content = depComp.override.read_text() assert "--extra-index-url" not in content assert "torch" in content def test_make_override_does_not_strip_cuda_toolkit_extras(): """Regression test for #412: override must not pin cuda-toolkit without extras. torch >= 2.11 depends on cuda-toolkit[cublas,cudart,...]==13.0.2. make_override() appends the first compile's flat output to override.txt, which writes 'cuda-toolkit==13.0.2' (no extras). When compile_core_plus_ext() uses this override, uv replaces torch's extras-bearing requirement with the bare pin, silently dropping nvidia-cuda-runtime, nvidia-cuda-nvrtc, and 8 other CUDA packages. """ # Simulate torch >= 2.11 first-compile output (flat pins, no extras) mock_stdout = "\n".join( [ "cuda-bindings==13.2.0", "cuda-pathfinder==1.5.1", "cuda-toolkit==13.0.2", "nvidia-cublas==13.1.0.3", "nvidia-cuda-cupti==13.0.85", "nvidia-cuda-nvrtc==13.0.88", "nvidia-cuda-runtime==13.0.96", "nvidia-cudnn-cu13==9.19.0.56", "nvidia-cufft==12.0.0.61", "nvidia-cufile==1.15.1.6", "nvidia-curand==10.4.0.35", "nvidia-cusolver==12.0.4.66", "nvidia-cusparse==12.6.3.3", "nvidia-cusparselt-cu13==0.8.0", "nvidia-nccl-cu13==2.28.9", "nvidia-nvjitlink==13.0.88", "nvidia-nvshmem-cu13==3.4.5", "nvidia-nvtx==13.0.85", "torch==2.11.0+cu130", "torchaudio==2.11.0+cu130", "torchsde==0.2.6", "torchvision==0.26.0+cu130", "", ] ) mock_result = type("R", (), {"stdout": mock_stdout, "stderr": "", "returncode": 0})() depComp = DependencyCompiler( cwd=temp, gpu=GPU_OPTION.NVIDIA, outDir=temp, reqFilesCore=[mockReqsDir / "core_reqs.txt"], reqFilesExt=[], cuda_version="13.0", ) with patch.object(DependencyCompiler, "Compile", return_value=mock_result): depComp.make_override() override_content = depComp.override.read_text() # The override must not contain a bare 'cuda-toolkit==X.Y.Z' pin. # If cuda-toolkit appears, it must include extras like [cublas,cudart,...]. # A bare pin causes uv --override to replace torch's extras-bearing # requirement, dropping all extras-only transitive CUDA runtime packages. for line in override_content.splitlines(): stripped = line.strip() if stripped.startswith("cuda-toolkit=="): pytest.fail( f"Override contains bare cuda-toolkit pin without extras: {stripped!r}\n" "This causes uv to strip extras from torch's cuda-toolkit dependency, " "dropping nvidia-cuda-runtime, nvidia-cuda-nvrtc, and other CUDA packages.\n" "See: https://github.com/Comfy-Org/comfy-cli/issues/412" ) def test_nvidia_custom_cuda_version(): depComp = DependencyCompiler( cwd=temp, gpu=GPU_OPTION.NVIDIA, outDir=temp, reqFilesCore=[], reqFilesExt=[], cuda_version="11.8" ) assert depComp.torchBackend == "cu118" assert depComp.gpuUrl == "https://download.pytorch.org/whl/cu118" def test_nvidia_cuda_13(): depComp = DependencyCompiler( cwd=temp, gpu=GPU_OPTION.NVIDIA, outDir=temp, reqFilesCore=[], reqFilesExt=[], cuda_version="13.0" ) assert depComp.torchBackend == "cu130" assert depComp.gpuUrl == "https://download.pytorch.org/whl/cu130" def test_amd_custom_rocm_version(): depComp = DependencyCompiler( cwd=temp, gpu=GPU_OPTION.AMD, outDir=temp, reqFilesCore=[], reqFilesExt=[], rocm_version="7.1" ) assert depComp.torchBackend == "rocm7.1" assert depComp.gpuUrl == "https://download.pytorch.org/whl/rocm7.1" def test_nvidia_auto_detected_tag(): depComp = DependencyCompiler( cwd=temp, gpu=GPU_OPTION.NVIDIA, outDir=temp, reqFilesCore=[], reqFilesExt=[], cuda_version="12.8" ) assert depComp.torchBackend == "cu128" assert depComp.gpuUrl == "https://download.pytorch.org/whl/cu128" def test_nvidia_no_cuda_version_uses_default(): depComp = DependencyCompiler( cwd=temp, gpu=GPU_OPTION.NVIDIA, outDir=temp, reqFilesCore=[], reqFilesExt=[], cuda_version=None ) assert depComp.torchBackend == DependencyCompiler.nvidiaTorchBackend assert depComp.gpuUrl == DependencyCompiler.nvidiaPytorchUrl @pytest.mark.parametrize("gpu", [GPU_OPTION.NVIDIA, GPU_OPTION.AMD, GPU_OPTION.CPU]) def test_skip_torch_disables_gpu_url_and_backend(gpu): depComp = DependencyCompiler(cwd=temp, gpu=gpu, outDir=temp, reqFilesCore=[], reqFilesExt=[], skip_torch=True) assert depComp.torchBackend is None assert depComp.gpuUrl is None def test_skip_torch_override_has_no_torch(): depComp = DependencyCompiler( cwd=temp, gpu=GPU_OPTION.NVIDIA, outDir=temp, reqFilesCore=[mockReqsDir / "core_reqs.txt"], reqFilesExt=[], skip_torch=True, ) depComp.make_override() content = depComp.override.read_text() assert "torch" not in content def test_skip_torch_install_deps_no_extra_index_url(): depComp = DependencyCompiler( cwd=temp, gpu=GPU_OPTION.NVIDIA, outDir=temp, reqFilesCore=[], reqFilesExt=[], skip_torch=True ) depComp.out.write_text("requests==2.31.0\n") with patch("comfy_cli.uv._check_call") as mock_check_call: depComp.install_deps() cmd = mock_check_call.call_args[0][0] assert "--extra-index-url" not in cmd def test_check_call_prints_nfs_hint_on_uv_install_failure(capsys): """When a uv pip install command fails, _check_call should print an NFS hint.""" cmd = ["python", "-m", "uv", "pip", "install", "--requirement", "reqs.txt"] with patch("subprocess.check_call", side_effect=subprocess.CalledProcessError(2, cmd)): with pytest.raises(subprocess.CalledProcessError): _check_call(cmd) captured = capsys.readouterr().out assert "network filesystem" in captured assert "UV_LINK_MODE" in captured assert "UV_CACHE_DIR" in captured def test_check_call_prints_nfs_hint_on_uv_sync_failure(capsys): """When a uv pip sync command fails, _check_call should print an NFS hint.""" cmd = ["python", "-m", "uv", "pip", "sync", "reqs.txt"] with patch("subprocess.check_call", side_effect=subprocess.CalledProcessError(2, cmd)): with pytest.raises(subprocess.CalledProcessError): _check_call(cmd) captured = capsys.readouterr().out assert "network filesystem" in captured def test_check_call_no_hint_for_non_uv_failure(capsys): """Non-uv commands should not trigger the NFS hint.""" cmd = ["python", "-m", "pip", "install", "requests"] with patch("subprocess.check_call", side_effect=subprocess.CalledProcessError(1, cmd)): with pytest.raises(subprocess.CalledProcessError): _check_call(cmd) captured = capsys.readouterr().out assert "network filesystem" not in captured def test_check_call_no_hint_on_uv_compile_failure(capsys): """uv pip compile failures should not trigger the NFS hint (only install/sync).""" cmd = ["python", "-m", "uv", "pip", "compile", "reqs.in"] with patch("subprocess.check_call", side_effect=subprocess.CalledProcessError(1, cmd)): with pytest.raises(subprocess.CalledProcessError): _check_call(cmd) captured = capsys.readouterr().out assert "network filesystem" not in captured def test_check_call_no_hint_for_pip_install_uv(capsys): """'pip install uv' must not trigger the hint even though 'uv' and 'install' are both present.""" cmd = ["python", "-m", "pip", "install", "--upgrade", "pip", "uv"] with patch("subprocess.check_call", side_effect=subprocess.CalledProcessError(1, cmd)): with pytest.raises(subprocess.CalledProcessError): _check_call(cmd) captured = capsys.readouterr().out assert "network filesystem" not in captured # Issue #431: parse_req_file feeds its output into pip argv (pip download / # pip wheel). Inline comments would be rejected by pip; VCS URL fragments must # be preserved verbatim (e.g. `#subdirectory=pkg`, `#egg=foo`). def test_parse_req_file_strips_inline_comments(tmp_path): rf = tmp_path / "requirements.txt" rf.write_text("foo>=1.0 # trailing comment\n") assert parse_req_file(rf) == ["foo>=1.0"] def test_parse_req_file_strips_inline_comment_with_single_space(tmp_path): rf = tmp_path / "requirements.txt" rf.write_text("bar==2.3 # single space before hash\n") assert parse_req_file(rf) == ["bar==2.3"] def test_parse_req_file_skips_full_line_comments(tmp_path): rf = tmp_path / "requirements.txt" rf.write_text("# heading\nfoo>=1.0\n # indented heading\nbaz\n") assert parse_req_file(rf) == ["foo>=1.0", "baz"] def test_parse_req_file_preserves_vcs_subdirectory_fragment(tmp_path): # Regression guard: any naive `split("#")[0]` would break this. `#` is only # a comment marker when preceded by whitespace (pip's rule). rf = tmp_path / "requirements.txt" rf.write_text("git+https://github.com/org/mono.git#subdirectory=pkg\n") assert parse_req_file(rf) == ["git+https://github.com/org/mono.git#subdirectory=pkg"] def test_parse_req_file_preserves_vcs_egg_fragment(tmp_path): rf = tmp_path / "requirements.txt" rf.write_text("git+https://github.com/org/repo.git@main#egg=foo\n") assert parse_req_file(rf) == ["git+https://github.com/org/repo.git@main#egg=foo"] def test_parse_req_file_preserves_direct_url_hash(tmp_path): rf = tmp_path / "requirements.txt" rf.write_text("foo @ https://host/f.whl#sha256=abc123\n") assert parse_req_file(rf) == ["foo @ https://host/f.whl#sha256=abc123"] def test_parse_req_file_vcs_with_inline_comment_strips_only_comment(tmp_path): # The trickiest case: a VCS spec with a fragment AND a trailing comment. # Comment is preceded by whitespace so it must be stripped; fragment is # part of the URL and must survive. rf = tmp_path / "requirements.txt" rf.write_text("git+https://host/r.git#subdirectory=pkg # note\n") assert parse_req_file(rf) == ["git+https://host/r.git#subdirectory=pkg"] def test_parse_req_file_preserves_double_dash_options(tmp_path): rf = tmp_path / "requirements.txt" rf.write_text("--extra-index-url https://example.com/simple\nfoo\n") assert parse_req_file(rf) == ["--extra-index-url", "https://example.com/simple", "foo"] def test_parse_req_file_handles_crlf_line_endings(tmp_path): # Windows-authored requirements.txt files use CRLF. Verify the comment # stripper + .strip() cleanly handles the trailing \r. rf = tmp_path / "requirements.txt" rf.write_bytes(b"foo>=1.0 # note\r\nbar>=2.0\r\n") assert parse_req_file(rf) == ["foo>=1.0", "bar>=2.0"]